在这边文章中,我们将使用 assimp 读取模型文件,并使用自己开发的软件渲染流程,来显示模型。
如果你和我一样,第一次接触 assimp 库,可以参照文章 《assimp 介绍》。它介绍了 assimp 的编译和使用,以及基本的模型概念。
显示模型的整体思路是,我们内部也仿照 assimp 的模型组织结构,维护一组 mesh 和 node 数据。然后采用本地渲染接口进行数据初始化和渲染操作。
1. Mesh
我们由下至上实现,首先实现最基础的 Mesh 结构。如代码清单 1.1 所示,Mesh 中包含顶点、顶点索引、法线和纹理。
如代码清单 1.2 所示,我们看到 Mesh 的构造函数。我们根据输入的顶点数据(位置、法线和 uv),生成渲染管线中维护的 vao 和 vbo。根据输入的索引数组,生成 ebo。
纹理我们直接使用处理好的纹理 id。因为纹理对象可以多个 mesh 共用,放在上层更便于管理。
我们再看到 Mesh 的绘制操作。如代码清单 1.3 所示,绑定创建的 vao、vbo、ebo 和纹理对象后,调用 DrawElements 进行绘制。注意,此处需要设置上层传递下来的变换矩阵。
2. Node
接着我们看到 Node 结构。在文章 《assimp 介绍》 中,我们已经了解到,Node 是类似“组”的概念,有一个本地变换矩阵作用于其下的所有对象。一个 Node 下可能会有多个 Mesh 和 Node。
所以如代码清单 2.1 所示,我们在 Node 结构中记录变换矩阵、Mesh 数组和 Node 数组。并增加 Mesh 和 Node 的 Add 接口。
Node 的绘制流程如代码清单 2.2 所示,我们首先计算最终的变换矩阵,然后依次绘制各个子 Mesh 和子 Node。
3. Model
最后,如代码清单 3.1 所示,我们看到模型的结构。模型由一个根 Node 构成,它是我们处理和绘制的起点。
根 Node 的获取流程,如代码清单 3.2 所示,我们通过 Assimp::Importer::ReadFile 导入模型,可以得到 aiScene 变量,aiScene.mRootNode 是 assimp 内部的 node 结构,我们使用 ProcessNode 函数将其转换成我们自己维护的 Node 结构。
此处还记录了模型文件的路径。assimp 中记录的纹理贴图是相对路径,基于模型文件路径。
ProcessNode 的具体实现如代码清单 3.3 所示,它的作用可以理解成将 assimp 的 aiNode 转换成我们自己的 TNode 结构。
aiNode 下的变换矩阵,我们使用 ConvertToMatrix 函数,将其转换成我们自己的矩阵类型;aiNode 下的子 node,我们同样使用 ProcessNode 处理;aiNode 下的子 mesh,我们调用 ProcessMesh 进行转换。
ProcessMesh 的具体实现如代码清单 3.4 所示,它的作用可以理解成将 assimp 的 aiMesh 转换成我们自己的 TMesh 结构。
代码清单 3.4 的第 10 至 14 行,得到顶点位置;第 15 至 19 行,得到顶点法线;第 21 至 31 行,得到 uv 坐标。第 36 至 43 行,得到顶点索引。第 45 至 49 行,得到纹理对象。和顶点信息的获取方式相比,纹理的获取比较繁琐,具体在 LoadMaterialTexture 函数中实现。
LoadMaterialTexture 的具体实现如代码清单 3.5 所示,我们通过 aiMaterial 结构获取到纹理贴图路径。纹理贴图可能内嵌在模型文件中,也可能直接由外部独立文件指定,所以这两种情况我们都需要处理。
纹理是可以共用的。此处使用 map 表记录和维护。
两种纹理的获取方式,如代码清单 3.6 所示,内嵌纹理可以通过 aiScene::GetEmbeddedTexture 获取,具体的纹理数据可以通过 aiTexture.pcData 得到。
外部纹理,直接通过读取纹理路径指定的文件即可。需要注意,纹理路径是基于模型文件路径的相对路径。
接着,如代码清单 3.7 所示,我们可以根据获取的图像数据,来生成纹理对象。
最后,我们看到模型的绘制接口。如代码清单 3.8 所示,模型绘制其实就是根 Node 的绘制。传入的变换矩阵参数也很灵活,可以“最高层”控制模型的变换。如果不想改变原始模型,传入单位矩阵即可。
4. 测试用例
在实现了模型类之后,我们来编写测试用例。因为模型数据的构造都放到模型类里面了,所以如代码清单 4 所示,相比之前的测试用例,现在的代码长度短了不少。模型类的构造在代码清单 4 的第 3 行,通过传入模型路径指定。
此处指定了一个恐龙模型,效果如视频 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 模式下应该和数组的开销一样,不会有析构的负担。可以看到增加灵活性,还是要付出代价的。
定位到真正原因后,思考优化方案。参与计算的顶点结构肯定是不能修改了,因为裁剪算法中的插值计算也要作用到自定义的变量上,所以必须都随同顶点结构附带着。同时暂时也不想了解和尝试新的裁剪算法。所以一开始想着是减少 clear 的频率,如果某个边界不需要裁剪的话,就不清空下一次的 output 了。之后往这个方向思考,发现不如一开始就判断是否需要裁剪,如果不需要裁剪,就不调用 Sutherland-Hodgman 算法了,如代码清单 5.2 所示。
本章的完整代码见 tag/model_loading。