第 25 章 性能优化指南
摘要:本章系统介绍了 VSG 性能优化的核心策略,包括视锥剔除、细节层次(LOD/PagedLOD)、Draw Call 优化、流式加载与多线程、数据格式选择等关键技术。通过组合应用这些手段,可有效解决大场景卡顿、显存占用激增、启动慢等常见性能问题。优化前应先测量定位瓶颈,再针对性实施。
本章定位:VSG 已经很快,但大场景仍会卡。本章汇总「视锥剔除 / LOD / 批处理 / 流式加载 / 多线程」的组合拳,以及常见问题。
25.1 本章目标
- 理解 VSG 自动做的视锥剔除与包围球;
- 用
LOD/PagedLOD做细节层次; - 减少 Draw Call 与状态切换(批处理、共享状态);
- 用
DatabasePager限量流式加载、多线程录制。
25.2 准备工作
- 第 6 章(LOD / Switch)、第 12 章(命令图)、第 18 章(DatabasePager)已读。
25.3 视锥剔除(自动 + 包围球)
VSG 在RecordTraversal(第 12 章)中自动按包围球做视锥剔除——节点若不在视锥内则不被记录。前提:节点/几何体的包围球正确。
// 若加载的模型没有包围体,或你手动修改了几何体,记得重新计算:vsg::computeBounds(node);// 为子树计算/刷新包围球另外可用CullNode/CullGroup(第 17 章提过,用于文字等)显式包裹,强制先做包围球裁剪再记录子树。
25.4 细节层次:LOD与PagedLOD
LOD(第 6 章):按屏幕占比在「同精度子节点」间切换,适合单文件多精度模型;PagedLOD(第 18 章):子节点按需从磁盘加载,适合大世界/地形。
autolod=vsg::LOD::create();lod->bound=vsg::dsphere(vsg::dvec3(0.0),1.0);// 包围球lod->addChild({0.0,highRes});// 近处高精度lod->addChild({0.2,lowRes});// 远处低精度25.5 减少 Draw Call 与状态切换
| 手段 | 说明 |
|---|---|
| 合并几何体 | 把多个小Geometry合并成一个(共享顶点布局时),减少 Draw Call |
| 实例化 | 同一网格多次出现用DrawIndexed的instanceCount+ per-instance 数据,而非复制节点 |
| 共享 StateGroup | 相同管线的子图复用同一个StateGroup,减少BindGraphicsPipeline/BindDescriptorSet |
| 批处理排序 | VSG 用Bin对绘制命令排序以减少状态切换;复杂场景可使用自定义Bin策略 |
| 纹理图集/Array | 多张小纹理合成图集或纹理数组,减少描述符切换 |
经验:Draw Call 数量的下降,往往比单网格优化更立竿见影。
下面是一个将两个简单立方体几何体合并为一个Geometry对象的 C++ 示例:
// 假设我们有两个独立的立方体几何体(简化示例,顶点/索引数据已略去细节)autocube1=createCubeGeometry(vsg::vec3(-1.0f,0.0f,0.0f),0.5f);// 左边立方体autocube2=createCubeGeometry(vsg::vec3(1.0f,0.0f,0.0f),0.5f);// 右边立方体// 合并前:两个独立的 Geometry 节点,至少需要 2 次 Draw CallautosceneBeforeMerge=vsg::Group::create();sceneBeforeMerge->addChild(cube1);sceneBeforeMerge->addChild(cube2);// 合并步骤:将两个几何体的顶点、索引等数据合并到同一个 Geometry 中// 1. 收集所有顶点属性(位置、法线、纹理坐标等)automergedVertices=vsg::vec3Array::create();automergedIndices=vsg::ushortArray::create();// 假设 cube1 和 cube2 各自有独立的顶点/索引数据// 这里仅示意合并逻辑,实际需要根据具体数据结构编写appendGeometryData(cube1,mergedVertices,mergedIndices);appendGeometryData(cube2,mergedVertices,mergedIndices,mergedVertices->size());// 偏移索引值// 2. 创建合并后的单一 Geometry 对象automergedGeometry=vsg::Geometry::create();mergedGeometry->arrays=vsg::DataList{mergedVertices};mergedGeometry->indices=mergedIndices;mergedGeometry->commands.push_back(vsg::DrawIndexed::create(mergedIndices->size(),1,0,0,0));// 3. 包装成 StateGroup 或直接加入场景automergedNode=vsg::StateGroup::create();mergedNode->add(mergedGeometry);// 合并后:只有一个 Geometry 节点,只需 1 次 Draw CallautosceneAfterMerge=vsg::Group::create();sceneAfterMerge->addChild(mergedNode);// 说明:// - 合并前:2 个 Geometry → 至少 2 次 Draw Call(可能更多,取决于状态切换)// - 合并后:1 个 Geometry → 1 次 Draw Call// 注意:合并要求几何体共享相同的顶点布局、材质和管线状态,否则仍需分开绘制。// 补充:完整的 createCubeGeometry 和 appendGeometryData 实现// 创建一个简单的立方体几何体(中心位置为 center,边长为 size)vsg::ref_ptr<vsg::Geometry>createCubeGeometry(constvsg::vec3¢er,floatsize){// 立方体有 8 个顶点autovertices=vsg::vec3Array::create(8);floathalf=size*0.5f;// 定义立方体的 8 个顶点(局部坐标)(*vertices)[0]=vsg::vec3(-half,-half,-half)+center;(*vertices)[1]=vsg::vec3(half,-half,-half)+center;(*vertices)[2]=vsg::vec3(half,half,-half)+center;(*vertices)[3]=vsg::vec3(-half,half,-half)+center;(*vertices)[4]=vsg::vec3(-half,-half,half)+center;(*vertices)[5]=vsg::vec3(half,-half,half)+center;(*vertices)[6]=vsg::vec3(half,half,half)+center;(*vertices)[7]=vsg::vec3(-half,half,half)+center;// 立方体有 6 个面,每个面 2 个三角形,共 12 个三角形,36 个索引autoindices=vsg::ushortArray::create(36);// 前、后、左、右、上、下 6 个面的三角形索引// 前(*indices)[0]=0;(*indices)[1]=1;(*indices)[2]=2;(*indices)[3]=2;(*indices)[4]=3;(*indices)[5]=0;// 后(*indices)[6]=4;(*indices)[7]=5;(*indices)[8]=6;(*indices)[9]=6;(*indices)[10]=7;(*indices)[11]=4;// 左(*indices)[12]=0;(*indices)[13]=3;(*indices)[14]=7;(*indices)[15]=7;(*indices)[16]=4;(*indices)[17]=0;// 右(*indices)[18]=1;(*indices)[19]=5;(*indices)[20]=6;(*indices)[21]=6;(*indices)[22]=2;(*indices)[23]=1;// 上(*indices)[24]=3;(*indices)[25]=2;(*indices)[26]=6;(*indices)[27]=6;(*indices)[28]=7;(*indices)[29]=3;// 下(*indices)[30]=0;(*indices)[31]=4;(*indices)[32]=5;(*indices)[33]=5;(*indices)[34]=1;(*indices)[35]=0;// 创建几何体对象autogeometry=vsg::Geometry::create();geometry->arrays=vsg::DataList{vertices};geometry->indices=indices;geometry->commands.push_back(vsg::DrawIndexed::create(indices->size(),1,0,0,0));returngeometry;}// 将单个几何体的顶点和索引数据追加到合并的数组中voidappendGeometryData(vsg::ref_ptr<vsg::Geometry>geometry,vsg::ref_ptr<vsg::vec3Array>mergedVertices,vsg::ref_ptr<vsg::ushortArray>mergedIndices,uint32_tvertexOffset=0){// 获取几何体的顶点数据(假设只有位置属性)autovertices=geometry->arrays[0].cast<vsg::vec3Array>();if(!vertices)return;// 获取几何体的索引数据autoindices=geometry->indices.cast<vsg::ushortArray>();if(!indices)return;// 1. 追加顶点数据size_t originalVertexCount=mergedVertices->size();mergedVertices->reserve(originalVertexCount+vertices->size());for(size_t i=0;i<vertices->size();++i){mergedVertices->push_back((*vertices)[i]);}// 2. 追加索引数据,并应用顶点偏移size_t originalIndexCount=mergedIndices->size();mergedIndices->reserve(originalIndexCount+indices->size());for(size_t i=0;i<indices->size();++i){// 注意:这里假设原始索引是从 0 开始的局部索引// 合并时需要加上之前已合并的顶点总数作为偏移mergedIndices->push_back((*indices)[i]+static_cast<uint16_t>(vertexOffset));}}// 数据变化说明:// 合并前:// - cube1: 8 个顶点,36 个索引(索引范围 0-7)// - cube2: 8 个顶点,36 个索引(索引范围 0-7)// 两个几何体独立,共 16 个顶点,72 个索引,需要 2 次 Draw Call//// 合并后:// - mergedGeometry: 16 个顶点,72 个索引// - 顶点 0-7: cube1 的顶点// - 顶点 8-15: cube2 的顶点(索引已偏移 +8)// - 索引 0-35: cube1 的原始索引(0-7)// - 索引 36-71: cube2 的原始索引(8-15,已偏移)// 合并为一个几何体,只需 1 次 Draw Call25.6 流式加载与多线程
DatabasePager(第 18 章):后台线程读/编译PagedLOD,限定targetMaxNumPagedLODWithHighResSubgraphs控制显存占用;- 多线程录制(第 18 章):多窗口/多
CommandGraph并行recordAndSubmit; - 避免每帧分配:复用
vsg::Value/BufferInfo,依赖vsg::Allocator内存池(第 23 章),少 new/delete。
25.7 加载与格式
- 发布/加载用
.vsgb(二进制)而非.vsgt,启动更快(第 21 章); - 大纹理用KTX2/Basis等 GPU 压缩格式(
vsgXchange支持,第 15 章),节省带宽与显存占用; - 用
Options::fileCache共享已加载对象,避免重复读盘。
25.8 测量,而不是猜
- 用
vsg::Instrumentation给Viewer与AnimationManager做性能剖析(第 12/14 章提过assignInstrumentation); - 用 RenderDoc(第 26 章)看每帧 Draw Call 数、绑定切换、纹理内存;
- 先量化瓶颈(CPU 录制?GPU 填充?带宽?),再针对性优化。
25.9 优化清单速查
25.9 优化手段前后性能对比
| 优化手段 | 优化前指标(示例) | 优化后指标(示例) | 适用场景 |
|---|---|---|---|
| 合并几何体 | 100 个独立小几何体 → 约 100 次 Draw Call,显存占用分散 | 合并为 1 个几何体 → 1 次 Draw Call,显存连续 | 大量相同材质/顶点布局的小物体(如草丛、碎石、建筑部件) |
| 实例化 | 1000 个相同树木,每棵独立 Geometry → 1000 次 Draw Call | 1 个 Geometry + 实例化数据 → 1 次 Draw Call | 大量重复的物体(树木、路灯、士兵等) |
| 共享 StateGroup | 10 个不同材质球,每个独立 StateGroup → 10 次管线/描述符绑定 | 共享 1 个 StateGroup → 1 次绑定 | 多个子图使用相同管线、纹理、Uniform 时 |
| 纹理图集/Array | 10 张小纹理各自绑定 → 10 次纹理切换 | 合并为 1 张图集 → 1 次纹理绑定 | UI 图标、字体图集、地形贴图等小纹理集合 |
| LOD/PagedLOD | 远处高模仍渲染 → 每帧 50 万三角形,显存占用 500 MB | 远处切低模 → 每帧 10 万三角形,显存 200 MB | 大场景、地形、复杂角色模型 |
| 视锥剔除 | 全场景 1000 个节点全部录制 → CPU 录制负担重 | 仅视锥内 200 个节点录制 → CPU 负担降低 80% | 开放世界、室内外大场景 |
| 纹理压缩(KTX2) | 4096×4096 RGBA8 未压缩 → 64 MB 纹理内存 | KTX2 ASTC 8×8 压缩 → 8 MB 纹理内存 | 大尺寸纹理(地形、天空盒、角色贴图) |
| 二进制格式(.vsgb) | 加载 …vsgt 文本格式模型 → 解析耗时 2 秒 | 加载 .vsgb 二进制 → 解析耗时 0.3 秒 | 任何需要快速启动的场景 |
| 症状 | 优先尝试 |
|---|---|
| 远处渲染卡顿 | 加LOD/PagedLOD,确认包围球正确 |
| Draw Call 过多 | 合并几何、实例化、共享StateGroup |
| 显存占用激增 | DatabasePager限量、纹理压缩、复用缓冲 |
| 主线程阻塞 | 后台分页、避免主线程vsg::read大文件 |
| 启动慢 | 用.vsgb、开fileCache |
25.10 小结
- 视锥剔除自动发生,但依赖正确的包围球(
computeBounds); LOD/PagedLOD做层次,DatabasePager做流式与限量;- 减少 Draw Call(合并/实例化/共享状态)通常收益最大;多线程录制 + 内存池兜底;
- 先测量再优化。
25.11 延伸阅读与下一章预告
- 第 18 章《多线程与数据加载》:分页与线程细节;
- 第 26 章《常见问题与调试技巧》:使用工具定位性能瓶颈;
- 第 23 章《内存管理》:分配器与泄漏排查。
25.12 实战练习
练习 1:路灯场景优化
场景描述:一个城市街道场景包含 100 个相同的路灯模型,每个路灯都是一个独立的Geometry节点,使用相同的材质和纹理。
问题:当前渲染每帧需要约 100 次 Draw Call,CPU 录制开销较大。
优化任务:请设计一个优化方案,将 Draw Call 降低到最低,并说明具体实现步骤。
参考答案思路:
- 使用实例化(Instancing):将路灯几何体改为一个
Geometry,配合DrawIndexed的instanceCount参数(设为 100),并通过 per-instance 数据(如vsg::mat4Array)传递每个路灯的位置、旋转等变换矩阵。 - 共享 StateGroup:所有路灯共享同一个
StateGroup,包含相同的管线、描述符集(材质、纹理)。 - 合并几何体(备选):如果路灯结构简单且顶点布局一致,也可考虑将 100 个路灯的顶点数据合并到一个
Geometry中,但实例化更灵活(支持独立变换、剔除)。 - 效果预估:优化后 Draw Call 从 100 次降为 1 次,CPU 录制开销显著降低,GPU 可通过一次绘制调用渲染全部实例。
// 练习1:路灯场景优化 - 实例化关键代码示例// 1. 创建单个路灯几何体(简化立方体)autolampGeometry=createCubeGeometry(vsg::vec3(0.0f),1.0f);// 中心在原点,边长1// 2. 创建实例变换矩阵数组(100个路灯的位置)autoinstanceTransforms=vsg::mat4Array::create(100);for(inti=0;i<100;++i){floatx=(i%10)*5.0f;// 每行10个,间距5米floatz=(i/10)*5.0f;(*instanceTransforms)[i]=vsg::translate(x,0.0f,z);// 平移到街道位置}// 3. 创建描述符集,将实例数据传递给着色器autoinstanceDescriptor=vsg::DescriptorBuffer::create(instanceTransforms,0,0);autodescriptorSet=vsg::DescriptorSet::create(descriptorSetLayout,vsg::Descriptors{instanceDescriptor});// 4. 配置 DrawIndexed 使用实例化autodrawCommand=vsg::DrawIndexed::create(lampGeometry->indices->size(),// 索引数量100,// instanceCount:实例数量,关键参数!0,0,0// 其他参数);// 5. 创建 StateGroup 并添加几何体与描述符集autolampStateGroup=vsg::StateGroup::create();lampStateGroup->add(lampGeometry);lampStateGroup->add(descriptorSet);// 效果:从100次Draw Call降为1次,CPU录制开销大幅降低// 注意:着色器中需使用 gl_InstanceIndex 访问 per-instance 数据练习 2:地形场景卡顿分析
场景描述:一个开放世界地形场景,在视角拉远时(能看到大片地形)帧率明显下降,但近处特写时流畅。
问题:分析可能的原因,并提出解决步骤。
参考答案思路:
可能原因分析:
- 缺少 LOD:远处仍使用高精度网格,三角形数量过多。
- 视锥剔除失效:地形节点包围球过大或未正确计算,导致大量不可见面片仍被录制。
- 纹理内存过大:远处地形仍加载高分辨率纹理,显存带宽压力大。
- 分页加载未生效:整个地形一次性加载,而非按需流式加载。
解决步骤:
- 步骤 1:测量定位:使用
vsg::Instrumentation或 RenderDoc 确认瓶颈是 GPU 填充(三角形过多)还是 CPU 录制(节点过多)。 - 步骤 2:应用 LOD/PagedLOD:为地形创建多个细节层次,远处切换为低模。使用
PagedLOD实现按需分块加载。 - 步骤 3:验证包围球:对地形节点调用
vsg::computeBounds(),确保视锥剔除能正确剔除不可见区域。 - 步骤 4:纹理优化:将地形纹理转换为 KTX2/Basis 压缩格式,并考虑使用纹理池或虚拟纹理技术。
- 步骤 5:启用 DatabasePager:配置
DatabasePager的targetMaxNumPagedLODWithHighResSubgraphs限制同时加载的高精度块数量,避免显存占用激增。 - 步骤 6:测试验证:优化后再次测量帧率与显存占用,确认卡顿缓解。
- 步骤 1:测量定位:使用
// 练习2:地形场景卡顿分析 - PagedLOD 配置代码示例// 1. 创建 PagedLOD 节点用于地形分块autoterrainPagedLOD=vsg::PagedLOD::create();// 2. 设置包围球(重要:确保视锥剔除正确工作)terrainPagedLOD->bound=vsg::dsphere(vsg::dvec3(0.0,0.0,0.0),500.0);// 半径500米// 3. 添加不同细节层次的子节点// 高精度模型(近处加载)terrainPagedLOD->addChild(vsg::PagedLOD::Child{0.0,highResTile},// 屏幕占比0-20%时显示"high_res_tile.vsgb"// 高精度模型文件路径);// 中精度模型(中距离加载)terrainPagedLOD->addChild(vsg::PagedLOD::Child{0.2,mediumResTile},// 屏幕占比20-50%时显示"medium_res_tile.vsgb"// 中精度模型文件路径);// 低精度模型(远处加载,或作为占位符)terrainPagedLOD->addChild(vsg::PagedLOD::Child{0.5,lowResTile},// 屏幕占比>50%时显示"low_res_tile.vsgb"// 低精度模型文件路径);// 4. 配置 DatabasePager 控制流式加载autodatabasePager=vsg::DatabasePager::create();databasePager->targetMaxNumPagedLODWithHighResSubgraphs=10;// 关键参数:限制同时加载的高精度块数量databasePager->minimumTimeAvailableForDatabasePager=0.001;// 每帧分配给分页线程的最小时间(秒)// 5. 将 DatabasePager 附加到 Viewerviewer->addUpdateOperation(databasePager);// 效果:远处自动切换为低模,按需加载地形块,显存占用可控// 注意:需要预先准备不同精度的地形分块文件(.vsgb格式)