模型显示
在这边文章中,我们将使用 assimp 读取模型文件,并使用自己开发的软件渲染流程,来显示模型。
如果你和我一样,第一次接触 assimp 库,可以参照文章 《assimp 介绍》。它介绍了 assimp 的编译和使用,以及基本的模型概念。
显示模型的整体思路是,我们内部也仿照 assimp 的模型组织结构,维护一组 mesh 和 node 数据。然后采用本地渲染接口进行数据初始化和渲染操作。
1. Mesh
我们由下至上实现,首先实现最基础的 Mesh 结构。如代码清单 1.1 所示,Mesh 中包含顶点、顶点索引、法线和纹理。
- class TMesh
- {
- public:
- struct Vertex
- {
- tmath::Vec3f position;
- tmath::Vec3f normal;
- tmath::Vec2f texCoords;
- };
- TMesh(
- TSoftRenderer& sr,
- const std::vector<Vertex>& vertices,
- const std::vector<uint32_t>& indices,
- uint32_t m_textureID);
- void Draw(const tmath::Mat4f& transform, TLambertianShader* shader);
- private:
- uint32_t m_vao;
- uint32_t m_vbo;
- uint32_t m_ebo;
- uint32_t m_tex;
- uint32_t m_indicesCount;
- TSoftRenderer& m_sr;
- };
如代码清单 1.2 所示,我们看到 Mesh 的构造函数。我们根据输入的顶点数据(位置、法线和 uv),生成渲染管线中维护的 vao 和 vbo。根据输入的索引数组,生成 ebo。
纹理我们直接使用处理好的纹理 id。因为纹理对象可以多个 mesh 共用,放在上层更便于管理。
- TMesh::TMesh(
- TSoftRenderer& sr,
- const std::vector<Vertex>& vertices,
- const std::vector<uint32_t>& indices,
- uint32_t m_textureID)
- : m_sr(sr), m_tex(m_textureID)
- {
- m_sr.GenVertexArrays(1, &m_vao);
- m_sr.GenBuffers(1, &m_vbo);
- m_sr.GenBuffers(1, &m_ebo);
- m_sr.BindVertexArray(m_vao);
- m_sr.BindBuffer(TBufferType::ArrayBuffer, m_vbo);
- m_sr.BufferData(TBufferType::ArrayBuffer, vertices.size() * sizeof(Vertex), (void*)vertices.data());
- m_sr.VertexAttribPointer(0, 3, sizeof(Vertex), offsetof(Vertex, position));
- m_sr.VertexAttribPointer(1, 3, sizeof(Vertex), offsetof(Vertex, normal));
- m_sr.VertexAttribPointer(2, 2, sizeof(Vertex), offsetof(Vertex, texCoords));
- m_sr.BindBuffer(TBufferType::ElementArrayBuffer, m_ebo);
- m_sr.BufferData(TBufferType::ElementArrayBuffer, indices.size() * sizeof(uint32_t), (void*)indices.data());
- m_indicesCount = indices.size();
- }
我们再看到 Mesh 的绘制操作。如代码清单 1.3 所示,绑定创建的 vao、vbo、ebo 和纹理对象后,调用 DrawElements 进行绘制。注意,此处需要设置上层传递下来的变换矩阵。
- void TMesh::Draw(const tmath::Mat4f& transform, TLambertianShader* shader)
- {
- shader->modelMatrix = transform;
- m_sr.BindVertexArray(m_vao);
- m_sr.BindBuffer(TBufferType::ArrayBuffer, m_vbo);
- m_sr.BindBuffer(TBufferType::ElementArrayBuffer, m_ebo);
- m_sr.BindTexture(m_tex);
- m_sr.DrawElements(TDrawMode::Triangles, m_indicesCount, 0);
- }
2. Node
接着我们看到 Node 结构。在文章 《assimp 介绍》 中,我们已经了解到,Node 是类似“组”的概念,有一个本地变换矩阵作用于其下的所有对象。一个 Node 下可能会有多个 Mesh 和 Node。
所以如代码清单 2.1 所示,我们在 Node 结构中记录变换矩阵、Mesh 数组和 Node 数组。并增加 Mesh 和 Node 的 Add 接口。
- class TNode
- {
- public:
- TNode(const tmath::Mat4f& transformMatrix);
- void AddMesh(std::unique_ptr<TMesh> mesh);
- void AddChild(std::unique_ptr<TNode> child);
- void Draw(const tmath::Mat4f& transform, TLambertianShader* shader);
- private:
- tmath::Mat4f m_localMatrix;
- std::vector<std::unique_ptr<TMesh>> m_meshes;
- std::vector<std::unique_ptr<TNode>> m_children;
- };
Node 的绘制流程如代码清单 2.2 所示,我们首先计算最终的变换矩阵,然后依次绘制各个子 Mesh 和子 Node。
- void TNode::Draw(const tmath::Mat4f& transform, TLambertianShader* shader)
- {
- tmath::Mat4f finalTransform = transform * m_localMatrix;
- for (auto& mesh : m_meshes)
- mesh->Draw(finalTransform, shader);
- for (auto& child : m_children)
- child->Draw(finalTransform, shader);
- }
3. Model
最后,如代码清单 3.1 所示,我们看到模型的结构。模型由一个根 Node 构成,它是我们处理和绘制的起点。
- class TModel
- {
- public:
- TModel(TSoftRenderer& sr, const std::string path);
- void Draw(const tmath::Mat4f& transform, TLambertianShader* shader);
- private:
- void LoadModel(const std::string& path);
- std::unique_ptr<TNode> ProcessNode(aiNode* node, const aiScene* scene);
- std::unique_ptr<TMesh> ProcessMesh(aiMesh* mesh, const aiScene* scene);
- uint32_t LoadMaterialTexture(aiMaterial* material, aiTextureType type, const aiScene* scene);
- TImage LoadEmbeddedTexture(const aiTexture* texture);
- TImage LoadTextureFromFile(const aiString& aiPath);
- private:
- tmath::Mat4f ConvertToMatrix(const aiMatrix4x4& m);
- uint32_t CreateTexture(const TImage& img);
- private:
- TSoftRenderer& m_sr;
- std::unique_ptr<TNode> m_rootNode;
- std::string m_modelDirectory;
- std::unordered_map<std::string, uint32_t> m_textureCache;
- };
根 Node 的获取流程,如代码清单 3.2 所示,我们通过 Assimp::Importer::ReadFile 导入模型,可以得到 aiScene 变量,aiScene.mRootNode 是 assimp 内部的 node 结构,我们使用 ProcessNode 函数将其转换成我们自己维护的 Node 结构。
- void TModel::LoadModel(const std::string& path)
- {
- std::filesystem::path filePath = std::filesystem::absolute(path);
- m_modelDirectory = filePath.parent_path().string();
- Assimp::Importer importer;
- const aiScene* scene = importer.ReadFile(
- path,
- aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_GenNormals);
- if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
- {
- std::cerr << "Error: " << importer.GetErrorString() << std::endl;
- assert(0);
- return;
- }
- m_rootNode = ProcessNode(scene->mRootNode, scene);
- }
此处还记录了模型文件的路径。assimp 中记录的纹理贴图是相对路径,基于模型文件路径。
ProcessNode 的具体实现如代码清单 3.3 所示,它的作用可以理解成将 assimp 的 aiNode 转换成我们自己的 TNode 结构。
aiNode 下的变换矩阵,我们使用 ConvertToMatrix 函数,将其转换成我们自己的矩阵类型;aiNode 下的子 node,我们同样使用 ProcessNode 处理;aiNode 下的子 mesh,我们调用 ProcessMesh 进行转换。
- std::unique_ptr<TNode> TModel::ProcessNode(aiNode* node, const aiScene* scene)
- {
- std::unique_ptr<TNode> tNode = std::make_unique<TNode>(ConvertToMatrix(node->mTransformation));
- for (uint32_t i = 0; i < node->mNumMeshes; i++)
- {
- aiMesh* mesh = scene->mMeshes[node->mMeshes[i]];
- tNode->AddMesh(ProcessMesh(mesh, scene));
- }
- for (uint32_t i = 0; i < node->mNumChildren; i++)
- {
- tNode->AddChild(ProcessNode(node->mChildren[i], scene));
- }
- return tNode;
- }
ProcessMesh 的具体实现如代码清单 3.4 所示,它的作用可以理解成将 assimp 的 aiMesh 转换成我们自己的 TMesh 结构。
代码清单 3.4 的第 10 至 14 行,得到顶点位置;第 15 至 19 行,得到顶点法线;第 21 至 31 行,得到 uv 坐标。第 36 至 43 行,得到顶点索引。第 45 至 49 行,得到纹理对象。和顶点信息的获取方式相比,纹理的获取比较繁琐,具体在 LoadMaterialTexture 函数中实现。
- std::unique_ptr<TMesh> TModel::ProcessMesh(aiMesh* mesh, const aiScene* scene)
- {
- std::vector<TMesh::Vertex> vertices;
- std::vector<uint32_t> indices;
- uint32_t textureID = 0;
- for (uint32_t i = 0; i < mesh->mNumVertices; i++)
- {
- TMesh::Vertex vertex;
- vertex.position = tmath::Vec3f(
- mesh->mVertices[i].x,
- mesh->mVertices[i].y,
- mesh->mVertices[i].z
- );
- vertex.normal = tmath::Vec3f(
- mesh->mNormals[i].x,
- mesh->mNormals[i].y,
- mesh->mNormals[i].z
- );
- if (mesh->mTextureCoords[0])
- {
- vertex.texCoords = tmath::Vec2f(
- mesh->mTextureCoords[0][i].x,
- mesh->mTextureCoords[0][i].y
- );
- }
- else
- {
- vertex.texCoords = tmath::Vec2f(0, 0);
- }
- vertices.push_back(vertex);
- }
- for (uint32_t i = 0; i < mesh->mNumFaces; i++)
- {
- aiFace face = mesh->mFaces[i];
- for (uint32_t j = 0; j < face.mNumIndices; j++)
- {
- indices.push_back(face.mIndices[j]);
- }
- }
- if (mesh->mMaterialIndex >= 0)
- {
- aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex];
- textureID = LoadMaterialTexture(material, aiTextureType_DIFFUSE, scene);
- }
- return std::make_unique<TMesh>(m_sr, vertices, indices, textureID);
- }
LoadMaterialTexture 的具体实现如代码清单 3.5 所示,我们通过 aiMaterial 结构获取到纹理贴图路径。纹理贴图可能内嵌在模型文件中,也可能直接由外部独立文件指定,所以这两种情况我们都需要处理。
- uint32_t TModel::LoadMaterialTexture(aiMaterial* material, aiTextureType type, const aiScene* scene)
- {
- uint32_t textureID = 0;
- aiString aiPath;
- if (material->Get(AI_MATKEY_TEXTURE(type, 0), aiPath) == AI_SUCCESS)
- {
- std::string pathKey = aiPath.C_Str();
- if (m_textureCache.find(pathKey) != m_textureCache.end())
- return m_textureCache[pathKey];
- TImage img;
- const aiTexture* texture = scene->GetEmbeddedTexture(aiPath.C_Str());
- if (texture)
- img = LoadEmbeddedTexture(texture);
- else
- img = LoadTextureFromFile(aiPath);
- textureID = CreateTexture(img);
- m_textureCache[pathKey] = textureID;
- }
- return textureID;
- }
纹理是可以共用的。此处使用 map 表记录和维护。
两种纹理的获取方式,如代码清单 3.6 所示,内嵌纹理可以通过 aiScene::GetEmbeddedTexture 获取,具体的纹理数据可以通过 aiTexture.pcData 得到。
外部纹理,直接通过读取纹理路径指定的文件即可。需要注意,纹理路径是基于模型文件路径的相对路径。
- TImage TModel::LoadEmbeddedTexture(const aiTexture* texture)
- {
- uint32_t size = 0;
- if (texture->mHeight == 0)
- size = texture->mWidth;
- else
- size = texture->mWidth * texture->mHeight;
- return TImage::LoadFromMemoryBuffer((unsigned char*)texture->pcData, size);
- }
- TImage TModel::LoadTextureFromFile(const aiString& aiPath)
- {
- std::filesystem::path texturePath = std::filesystem::path(m_modelDirectory).append(aiPath.C_Str());
- return TImage::LoadFromFile(texturePath.string().c_str());
- }
接着,如代码清单 3.7 所示,我们可以根据获取的图像数据,来生成纹理对象。
- uint32_t TModel::CreateTexture(const TImage& img)
- {
- uint32_t textureID;
- m_sr.GenTextures(1, &textureID);
- m_sr.BindTexture(textureID);
- m_sr.TexParameter(TTextureParam::WrapS, (int)TTextureWrapMode::Repeat);
- m_sr.TexParameter(TTextureParam::WrapT, (int)TTextureWrapMode::Repeat);
- m_sr.TexParameter(TTextureParam::Filter, (int)TTextureFilterMode::Linear);
- m_sr.TexImage2D(img.GetWidth(), img.GetHeight(), img.GetData());
- return textureID;
- }
最后,我们看到模型的绘制接口。如代码清单 3.8 所示,模型绘制其实就是根 Node 的绘制。传入的变换矩阵参数也很灵活,可以“最高层”控制模型的变换。如果不想改变原始模型,传入单位矩阵即可。
- void TModel::Draw(const tmath::Mat4f& transform, TLambertianShader* shader)
- {
- m_rootNode->Draw(transform, shader);
- }
4. 测试用例
在实现了模型类之后,我们来编写测试用例。因为模型数据的构造都放到模型类里面了,所以如代码清单 4 所示,相比之前的测试用例,现在的代码长度短了不少。模型类的构造在代码清单 4 的第 3 行,通过传入模型路径指定。
- TModelRenderTask::TModelRenderTask(TBasicWindow& win)
- : m_camera(tmath::Vec3f(0.0f, 0.0f, -5.0f), tmath::Vec3f(0.0f, 0.0f, 0.0f)),
- m_model(win.GetRenderer(), "model/Rampaging T-Rex.glb"),
- m_angle(0.0f)
- {
- TSoftRenderer& sr = win.GetRenderer();
- ////
- int width = win.GetWindowWidth();
- int height = win.GetWindowHeight();
- float aspect = (float)width / height;
- m_shader.projectionMatrix = tmath::PerspectiveMatrix(tmath::degToRad(60.0f), aspect, 0.1f, 100.0f);
- m_shader.viewMatrix = tmath::TranslationMatrix(0.0f, 0.0f, 4.0f);
- m_shader.modelMatrix.ToIdentity();
- m_shader.lightDirection = { -1.0f, -0.5f, 0.7f };
- m_shader.lightDirection.Normalize();
- m_shader.lightColor = { 1.0f, 1.0f, 1.0f, 1.0f };
- m_shader.ambientColor = { 0.5f, 0.5f, 0.5f, 1.0f };
- sr.UseProgram(&m_shader);
- ////
- sr.Enable(TEnableCap::DepthTest);
- sr.DepthFunc(TDepthFunc::Less);
- ////
- sr.Enable(TEnableCap::CullFace);
- sr.CullFace(TCullFace::Back);
- sr.FrontFace(TFrontFace::CounterClockwise);
- ////
- win.AddInputHandler(&m_camera);
- }
- void TModelRenderTask::Render(TSoftRenderer& sr)
- {
- m_shader.viewMatrix = m_camera.GetViewMatrix();
- sr.ClearColor({ 255,255,255 });
- sr.ClearDepth(1.0f);
- tmath::Mat4f rotateMatrix = tmath::RotationMatrix({ 0.0f, 1.0f, 0.0f }, m_angle);
- tmath::Mat4f translateMatrix = tmath::TranslationMatrix(0.0f, -3.5f, 4.0f);
- //tmath::Mat4f scaleMatrix = tmath::ScaleMatrix(0.1f, 0.1f, 0.1f);
- m_angle -= 0.1f;
- m_model.Draw(translateMatrix * rotateMatrix, &m_shader);
- }
此处指定了一个恐龙模型,效果如视频 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 模式下应该和数组的开销一样,不会有析构的负担。可以看到增加灵活性,还是要付出代价的。
- using TShaderVariable = std::variant<tmath::Vec2f, tmath::Vec3f, tmath::Vec4f>;
- struct TVertexShaderOutput
- {
- tmath::Vec4f builtin_position;
- std::unordered_map<std::string, TShaderVariable> variables;
- TVertexShaderOutput();
- virtual ~TVertexShaderOutput();
- };
- struct TVertexShaderOutputPrivate : public TVertexShaderOutput
- {
- float invW;
- TVertexShaderOutputPrivate();
- TVertexShaderOutputPrivate Lerp(const TVertexShaderOutputPrivate& other, float t) const;
- };
定位到真正原因后,思考优化方案。参与计算的顶点结构肯定是不能修改了,因为裁剪算法中的插值计算也要作用到自定义的变量上,所以必须都随同顶点结构附带着。同时暂时也不想了解和尝试新的裁剪算法。所以一开始想着是减少 clear 的频率,如果某个边界不需要裁剪的话,就不清空下一次的 output 了。之后往这个方向思考,发现不如一开始就判断是否需要裁剪,如果不需要裁剪,就不调用 Sutherland-Hodgman 算法了,如代码清单 5.2 所示。
- bool TSoftRenderer::ShouldClipping(
- const TVertexShaderOutputPrivate vertexOutputs[3])
- {
- for (int i = 0; i < 3; i++)
- {
- const tmath::Vec4f& pos = vertexOutputs[i].builtin_position;
- float w = pos.w();
- if (pos.x() < -w || pos.x() > w ||
- pos.y() < -w || pos.y() > w ||
- pos.z() < -w || pos.z() > w)
- return true;
- }
- return false;
- }
本章的完整代码见 tag/model_loading。