模型显示

在这边文章中,我们将使用 assimp 读取模型文件,并使用自己开发的软件渲染流程,来显示模型。

如果你和我一样,第一次接触 assimp 库,可以参照文章 《assimp 介绍》。它介绍了 assimp 的编译和使用,以及基本的模型概念。

显示模型的整体思路是,我们内部也仿照 assimp 的模型组织结构,维护一组 mesh 和 node 数据。然后采用本地渲染接口进行数据初始化和渲染操作。

1. Mesh

我们由下至上实现,首先实现最基础的 Mesh 结构。如代码清单 1.1 所示,Mesh 中包含顶点、顶点索引、法线和纹理。

代码清单 1.1 Mesh 结构
  1. class TMesh
  2. {
  3. public:
  4.     struct Vertex
  5.     {
  6.         tmath::Vec3f position;
  7.         tmath::Vec3f normal;
  8.         tmath::Vec2f texCoords;
  9.     };
  10.  
  11.     TMesh(
  12.         TSoftRenderer& sr,
  13.         const std::vector<Vertex>& vertices,
  14.         const std::vector<uint32_t>& indices,
  15.         uint32_t m_textureID);
  16.  
  17.     void Draw(const tmath::Mat4f& transform, TLambertianShader* shader);
  18. private:
  19.     uint32_t m_vao;
  20.     uint32_t m_vbo;
  21.     uint32_t m_ebo;
  22.     uint32_t m_tex;
  23.     uint32_t m_indicesCount;
  24.  
  25.     TSoftRenderer& m_sr;
  26. };

如代码清单 1.2 所示,我们看到 Mesh 的构造函数。我们根据输入的顶点数据(位置、法线和 uv),生成渲染管线中维护的 vao 和 vbo。根据输入的索引数组,生成 ebo。

纹理我们直接使用处理好的纹理 id。因为纹理对象可以多个 mesh 共用,放在上层更便于管理。

代码清单 1.2 Mesh 构造
  1. TMesh::TMesh(
  2.     TSoftRenderer& sr,
  3.     const std::vector<Vertex>& vertices,
  4.     const std::vector<uint32_t>& indices,
  5.     uint32_t m_textureID)
  6.     : m_sr(sr), m_tex(m_textureID)
  7. {
  8.     m_sr.GenVertexArrays(1, &m_vao);
  9.     m_sr.GenBuffers(1, &m_vbo);
  10.     m_sr.GenBuffers(1, &m_ebo);
  11.  
  12.     m_sr.BindVertexArray(m_vao);
  13.  
  14.     m_sr.BindBuffer(TBufferType::ArrayBuffer, m_vbo);
  15.     m_sr.BufferData(TBufferType::ArrayBuffer, vertices.size() * sizeof(Vertex), (void*)vertices.data());
  16.     m_sr.VertexAttribPointer(0, 3, sizeof(Vertex), offsetof(Vertex, position));
  17.     m_sr.VertexAttribPointer(1, 3, sizeof(Vertex), offsetof(Vertex, normal));
  18.     m_sr.VertexAttribPointer(2, 2, sizeof(Vertex), offsetof(Vertex, texCoords));
  19.  
  20.     m_sr.BindBuffer(TBufferType::ElementArrayBuffer, m_ebo);
  21.     m_sr.BufferData(TBufferType::ElementArrayBuffer, indices.size() * sizeof(uint32_t), (void*)indices.data());
  22.     m_indicesCount = indices.size();
  23. }

我们再看到 Mesh 的绘制操作。如代码清单 1.3 所示,绑定创建的 vao、vbo、ebo 和纹理对象后,调用 DrawElements 进行绘制。注意,此处需要设置上层传递下来的变换矩阵。

代码清单 1.3 Mesh 绘制
  1. void TMesh::Draw(const tmath::Mat4f& transform, TLambertianShader* shader)
  2. {
  3.     shader->modelMatrix = transform;
  4.  
  5.     m_sr.BindVertexArray(m_vao);
  6.     m_sr.BindBuffer(TBufferType::ArrayBuffer, m_vbo);
  7.     m_sr.BindBuffer(TBufferType::ElementArrayBuffer, m_ebo);
  8.     m_sr.BindTexture(m_tex);
  9.  
  10.     m_sr.DrawElements(TDrawMode::Triangles, m_indicesCount, 0);
  11. }

2. Node

接着我们看到 Node 结构。在文章 《assimp 介绍》 中,我们已经了解到,Node 是类似“组”的概念,有一个本地变换矩阵作用于其下的所有对象。一个 Node 下可能会有多个 Mesh 和 Node。

所以如代码清单 2.1 所示,我们在 Node 结构中记录变换矩阵、Mesh 数组和 Node 数组。并增加 Mesh 和 Node 的 Add 接口。

代码清单 2.1 Node 结构
  1. class TNode
  2. {
  3. public:
  4.     TNode(const tmath::Mat4f& transformMatrix);
  5.  
  6.     void AddMesh(std::unique_ptr<TMesh> mesh);
  7.     void AddChild(std::unique_ptr<TNode> child);
  8.  
  9.     void Draw(const tmath::Mat4f& transform, TLambertianShader* shader);
  10.  
  11. private:
  12.     tmath::Mat4f m_localMatrix;
  13.  
  14.     std::vector<std::unique_ptr<TMesh>> m_meshes;
  15.     std::vector<std::unique_ptr<TNode>> m_children;
  16. };

Node 的绘制流程如代码清单 2.2 所示,我们首先计算最终的变换矩阵,然后依次绘制各个子 Mesh 和子 Node。

代码清单 2.2 Node 绘制
  1. void TNode::Draw(const tmath::Mat4f& transform, TLambertianShader* shader)
  2. {
  3.     tmath::Mat4f finalTransform = transform * m_localMatrix;
  4.     for (auto& mesh : m_meshes)
  5.         mesh->Draw(finalTransform, shader);
  6.  
  7.     for (auto& child : m_children)
  8.         child->Draw(finalTransform, shader);
  9. }

3. Model

最后,如代码清单 3.1 所示,我们看到模型的结构。模型由一个根 Node 构成,它是我们处理和绘制的起点。

代码清单 3.1 Model 结构
  1. class TModel
  2. {
  3. public:
  4.     TModel(TSoftRenderer& sr, const std::string path);
  5.     void Draw(const tmath::Mat4f& transform, TLambertianShader* shader);
  6.  
  7. private:
  8.     void LoadModel(const std::string& path);
  9.  
  10.     std::unique_ptr<TNode> ProcessNode(aiNode* node, const aiScene* scene);
  11.     std::unique_ptr<TMesh> ProcessMesh(aiMesh* mesh, const aiScene* scene);
  12.     uint32_t LoadMaterialTexture(aiMaterial* material, aiTextureType type, const aiScene* scene);
  13.     TImage LoadEmbeddedTexture(const aiTexture* texture);
  14.     TImage LoadTextureFromFile(const aiString& aiPath);
  15.  
  16. private:
  17.     tmath::Mat4f ConvertToMatrix(const aiMatrix4x4& m);
  18.     uint32_t CreateTexture(const TImage& img);
  19.  
  20. private:
  21.     TSoftRenderer& m_sr;
  22.     std::unique_ptr<TNode> m_rootNode;
  23.  
  24.     std::string m_modelDirectory;
  25.     std::unordered_map<std::string, uint32_t> m_textureCache;
  26. };

根 Node 的获取流程,如代码清单 3.2 所示,我们通过 Assimp::Importer::ReadFile 导入模型,可以得到 aiScene 变量,aiScene.mRootNode 是 assimp 内部的 node 结构,我们使用 ProcessNode 函数将其转换成我们自己维护的 Node 结构。

代码清单 3.2 获取 RootNode
  1. void TModel::LoadModel(const std::string& path)
  2. {
  3.     std::filesystem::path filePath = std::filesystem::absolute(path);
  4.     m_modelDirectory = filePath.parent_path().string();
  5.  
  6.     Assimp::Importer importer;
  7.     const aiScene* scene = importer.ReadFile(
  8.         path,
  9.         aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_GenNormals);
  10.     if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
  11.     {
  12.         std::cerr << "Error: " << importer.GetErrorString() << std::endl;
  13.         assert(0);
  14.         return;
  15.     }
  16.  
  17.     m_rootNode = ProcessNode(scene->mRootNode, scene);
  18. }

此处还记录了模型文件的路径。assimp 中记录的纹理贴图是相对路径,基于模型文件路径。

ProcessNode 的具体实现如代码清单 3.3 所示,它的作用可以理解成将 assimp 的 aiNode 转换成我们自己的 TNode 结构。

aiNode 下的变换矩阵,我们使用 ConvertToMatrix 函数,将其转换成我们自己的矩阵类型;aiNode 下的子 node,我们同样使用 ProcessNode 处理;aiNode 下的子 mesh,我们调用 ProcessMesh 进行转换。

代码清单 3.3 获取 aiNode 转 TNode
  1. std::unique_ptr<TNode> TModel::ProcessNode(aiNode* node, const aiScene* scene)
  2. {
  3.     std::unique_ptr<TNode> tNode = std::make_unique<TNode>(ConvertToMatrix(node->mTransformation));
  4.  
  5.     for (uint32_t i = 0; i < node->mNumMeshes; i++)
  6.     {
  7.         aiMesh* mesh = scene->mMeshes[node->mMeshes[i]];
  8.         tNode->AddMesh(ProcessMesh(mesh, scene));
  9.     }
  10.  
  11.     for (uint32_t i = 0; i < node->mNumChildren; i++)
  12.     {
  13.         tNode->AddChild(ProcessNode(node->mChildren[i], scene));
  14.     }
  15.  
  16.     return tNode;
  17. }

ProcessMesh 的具体实现如代码清单 3.4 所示,它的作用可以理解成将 assimp 的 aiMesh 转换成我们自己的 TMesh 结构。

代码清单 3.4 的第 10 至 14 行,得到顶点位置;第 15 至 19 行,得到顶点法线;第 21 至 31 行,得到 uv 坐标。第 36 至 43 行,得到顶点索引。第 45 至 49 行,得到纹理对象。和顶点信息的获取方式相比,纹理的获取比较繁琐,具体在 LoadMaterialTexture 函数中实现。

代码清单 3.4 获取 aiMesh 转 TMesh
  1. std::unique_ptr<TMesh> TModel::ProcessMesh(aiMesh* mesh, const aiScene* scene)
  2. {
  3.     std::vector<TMesh::Vertex> vertices;
  4.     std::vector<uint32_t> indices;
  5.     uint32_t textureID = 0;
  6.  
  7.     for (uint32_t i = 0; i < mesh->mNumVertices; i++)
  8.     {
  9.         TMesh::Vertex vertex;
  10.         vertex.position = tmath::Vec3f(
  11.             mesh->mVertices[i].x,
  12.             mesh->mVertices[i].y,
  13.             mesh->mVertices[i].z
  14.         );
  15.         vertex.normal = tmath::Vec3f(
  16.             mesh->mNormals[i].x,
  17.             mesh->mNormals[i].y,
  18.             mesh->mNormals[i].z
  19.         );
  20.  
  21.         if (mesh->mTextureCoords[0])
  22.         {
  23.             vertex.texCoords = tmath::Vec2f(
  24.                 mesh->mTextureCoords[0][i].x,
  25.                 mesh->mTextureCoords[0][i].y
  26.             );
  27.         }
  28.         else
  29.         {
  30.             vertex.texCoords = tmath::Vec2f(0, 0);
  31.         }
  32.  
  33.         vertices.push_back(vertex);
  34.     }
  35.  
  36.     for (uint32_t i = 0; i < mesh->mNumFaces; i++)
  37.     {
  38.         aiFace face = mesh->mFaces[i];
  39.         for (uint32_t j = 0; j < face.mNumIndices; j++)
  40.         {
  41.             indices.push_back(face.mIndices[j]);
  42.         }
  43.     }
  44.  
  45.     if (mesh->mMaterialIndex >= 0)
  46.     {
  47.         aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex];
  48.         textureID = LoadMaterialTexture(material, aiTextureType_DIFFUSE, scene);
  49.     }
  50.  
  51.     return std::make_unique<TMesh>(m_sr, vertices, indices, textureID);
  52. }

LoadMaterialTexture 的具体实现如代码清单 3.5 所示,我们通过 aiMaterial 结构获取到纹理贴图路径。纹理贴图可能内嵌在模型文件中,也可能直接由外部独立文件指定,所以这两种情况我们都需要处理。

代码清单 3.5 获取纹理
  1. uint32_t TModel::LoadMaterialTexture(aiMaterial* material, aiTextureType type, const aiScene* scene)
  2. {
  3.     uint32_t textureID = 0;
  4.     aiString aiPath;
  5.  
  6.     if (material->Get(AI_MATKEY_TEXTURE(type, 0), aiPath) == AI_SUCCESS)
  7.     {
  8.         std::string pathKey = aiPath.C_Str();
  9.         if (m_textureCache.find(pathKey) != m_textureCache.end())
  10.             return m_textureCache[pathKey];
  11.  
  12.         TImage img;
  13.         const aiTexture* texture = scene->GetEmbeddedTexture(aiPath.C_Str());
  14.         if (texture)
  15.             img = LoadEmbeddedTexture(texture);
  16.         else
  17.             img = LoadTextureFromFile(aiPath);
  18.  
  19.         textureID = CreateTexture(img);
  20.  
  21.         m_textureCache[pathKey] = textureID;
  22.     }
  23.  
  24.     return textureID;
  25. }

纹理是可以共用的。此处使用 map 表记录和维护。

两种纹理的获取方式,如代码清单 3.6 所示,内嵌纹理可以通过 aiScene::GetEmbeddedTexture 获取,具体的纹理数据可以通过 aiTexture.pcData 得到。

外部纹理,直接通过读取纹理路径指定的文件即可。需要注意,纹理路径是基于模型文件路径的相对路径。

代码清单 3.6 内嵌纹理和外部纹理
  1. TImage TModel::LoadEmbeddedTexture(const aiTexture* texture)
  2. {
  3.     uint32_t size = 0;
  4.     if (texture->mHeight == 0)
  5.         size = texture->mWidth;
  6.     else
  7.         size = texture->mWidth * texture->mHeight;
  8.  
  9.     return TImage::LoadFromMemoryBuffer((unsigned char*)texture->pcData, size);
  10. }
  11.  
  12. TImage TModel::LoadTextureFromFile(const aiString& aiPath)
  13. {
  14.     std::filesystem::path texturePath = std::filesystem::path(m_modelDirectory).append(aiPath.C_Str());
  15.     return TImage::LoadFromFile(texturePath.string().c_str());
  16. }

接着,如代码清单 3.7 所示,我们可以根据获取的图像数据,来生成纹理对象。

代码清单 3.7 生成纹理对象
  1. uint32_t TModel::CreateTexture(const TImage& img)
  2. {
  3.     uint32_t textureID;
  4.     m_sr.GenTextures(1, &textureID);
  5.     m_sr.BindTexture(textureID);
  6.  
  7.     m_sr.TexParameter(TTextureParam::WrapS, (int)TTextureWrapMode::Repeat);
  8.     m_sr.TexParameter(TTextureParam::WrapT, (int)TTextureWrapMode::Repeat);
  9.     m_sr.TexParameter(TTextureParam::Filter, (int)TTextureFilterMode::Linear);
  10.  
  11.     m_sr.TexImage2D(img.GetWidth(), img.GetHeight(), img.GetData());
  12.  
  13.     return textureID;
  14. }

最后,我们看到模型的绘制接口。如代码清单 3.8 所示,模型绘制其实就是根 Node 的绘制。传入的变换矩阵参数也很灵活,可以“最高层”控制模型的变换。如果不想改变原始模型,传入单位矩阵即可。

代码清单 3.8 模型绘制
  1. void TModel::Draw(const tmath::Mat4f& transform, TLambertianShader* shader)
  2. {
  3.     m_rootNode->Draw(transform, shader);
  4. }

4. 测试用例

在实现了模型类之后,我们来编写测试用例。因为模型数据的构造都放到模型类里面了,所以如代码清单 4 所示,相比之前的测试用例,现在的代码长度短了不少。模型类的构造在代码清单 4 的第 3 行,通过传入模型路径指定。

代码清单 4 测试
  1. TModelRenderTask::TModelRenderTask(TBasicWindow& win)
  2.     : m_camera(tmath::Vec3f(0.0f, 0.0f, -5.0f), tmath::Vec3f(0.0f, 0.0f, 0.0f)),
  3.       m_model(win.GetRenderer(), "model/Rampaging T-Rex.glb"),
  4.       m_angle(0.0f)
  5. {
  6.     TSoftRenderer& sr = win.GetRenderer();
  7.     ////
  8.     int width = win.GetWindowWidth();
  9.     int height = win.GetWindowHeight();
  10.  
  11.     float aspect = (float)width / height;
  12.  
  13.     m_shader.projectionMatrix = tmath::PerspectiveMatrix(tmath::degToRad(60.0f), aspect, 0.1f, 100.0f);
  14.     m_shader.viewMatrix = tmath::TranslationMatrix(0.0f, 0.0f, 4.0f);
  15.     m_shader.modelMatrix.ToIdentity();
  16.  
  17.     m_shader.lightDirection = { -1.0f, -0.5f, 0.7f };
  18.     m_shader.lightDirection.Normalize();
  19.     m_shader.lightColor = { 1.0f, 1.0f, 1.0f, 1.0f };
  20.     m_shader.ambientColor = { 0.5f, 0.5f, 0.5f, 1.0f };
  21.  
  22.     sr.UseProgram(&m_shader);
  23.  
  24.     ////
  25.     sr.Enable(TEnableCap::DepthTest);
  26.     sr.DepthFunc(TDepthFunc::Less);
  27.     ////
  28.     sr.Enable(TEnableCap::CullFace);
  29.     sr.CullFace(TCullFace::Back);
  30.     sr.FrontFace(TFrontFace::CounterClockwise);
  31.     ////
  32.     win.AddInputHandler(&m_camera);
  33. }
  34.  
  35. void TModelRenderTask::Render(TSoftRenderer& sr)
  36. {
  37.     m_shader.viewMatrix = m_camera.GetViewMatrix();
  38.     sr.ClearColor({ 255,255,255 });
  39.     sr.ClearDepth(1.0f);
  40.  
  41.     tmath::Mat4f rotateMatrix = tmath::RotationMatrix({ 0.0f, 1.0f, 0.0f }, m_angle);
  42.     tmath::Mat4f translateMatrix = tmath::TranslationMatrix(0.0f, -3.5f, 4.0f);
  43.     //tmath::Mat4f scaleMatrix = tmath::ScaleMatrix(0.1f, 0.1f, 0.1f);
  44.     m_angle -= 0.1f;
  45.  
  46.     m_model.Draw(translateMatrix * rotateMatrix, &m_shader);
  47. }

此处指定了一个恐龙模型,效果如视频 1 所示,纹理、透视显示、裁剪看着都没什么问题。

视频 1 测试结果

5. 一处性能问题排查

视频 1 中展示的渲染效率,看着是可以接受的。但是我换了一个更大的模型之后,发现卡顿严重。卡顿并不是由软光栅引起的,因为尝试在参照的学习案例工程中,加载相同的模型,效率也很高。

但是自己的代码结构和参照工程的不一样,无法通过简单的代码对比来进行排查。所以只能正面分析这个差异问题了。

搜了一下 Windows 平台有什么性能统计工具。Visual Studio 不愧是宇宙第一 IDE,内部已经集成了性能分析工具。入口在 调试 / 性能探查器

通过报告结果,定位到耗时都在 Sutherland-Hodgman 这个裁剪算法上。删去相关函数调用进行验证,的确是卡顿的点。

进一步核查实现的 Sutherland-Hodgman 算法流程,和参照工程中的实现是差不多。同时对参照工程的裁剪流程进行性能统计,虽然也是耗时项,但是占比远没有自己工程上的高。

进一步查看报告的统计结果,发现耗时是参与的顶点数据结构频繁析构导致的,Sutherland-Hodgman 需要多轮 input 和 output 数据交换,并 clear 上一轮的 output。目前工程中的顶点结构如代码清单 5.1 所示,可见包含了 unordered_map 和 variant 等“复杂”结构后,数组的 clear 操作都是 O(n) 了,而且析构的开销也不小。参照工程中的结构是“写死”的多个具体向量(颜色、法线、uv),release 模式下应该和数组的开销一样,不会有析构的负担。可以看到增加灵活性,还是要付出代价的。

代码清单 5.1 顶点结构
  1. using TShaderVariable = std::variant<tmath::Vec2f, tmath::Vec3f, tmath::Vec4f>;
  2. struct TVertexShaderOutput
  3. {
  4.     tmath::Vec4f builtin_position;
  5.     std::unordered_map<std::string, TShaderVariable> variables;
  6.  
  7.     TVertexShaderOutput();
  8.     virtual ~TVertexShaderOutput();
  9. };
  10.  
  11. struct TVertexShaderOutputPrivate : public TVertexShaderOutput
  12. {
  13.     float invW;
  14.  
  15.     TVertexShaderOutputPrivate();
  16.     TVertexShaderOutputPrivate Lerp(const TVertexShaderOutputPrivate& other, float t) const;
  17. };

定位到真正原因后,思考优化方案。参与计算的顶点结构肯定是不能修改了,因为裁剪算法中的插值计算也要作用到自定义的变量上,所以必须都随同顶点结构附带着。同时暂时也不想了解和尝试新的裁剪算法。所以一开始想着是减少 clear 的频率,如果某个边界不需要裁剪的话,就不清空下一次的 output 了。之后往这个方向思考,发现不如一开始就判断是否需要裁剪,如果不需要裁剪,就不调用 Sutherland-Hodgman 算法了,如代码清单 5.2 所示。

代码清单 5.2 裁剪判断
  1. bool TSoftRenderer::ShouldClipping(
  2.     const TVertexShaderOutputPrivate vertexOutputs[3])
  3. {
  4.     for (int i = 0; i < 3; i++)
  5.     {
  6.         const tmath::Vec4f& pos = vertexOutputs[i].builtin_position;
  7.         float w = pos.w();
  8.         if (pos.x() < -w || pos.x() > w ||
  9.             pos.y() < -w || pos.y() > w ||
  10.             pos.z() < -w || pos.z() > w)
  11.             return true;
  12.     }
  13.     return false;
  14. }

本章的完整代码见 tag/model_loading