Technical note
大模型显示为什么不只是 GPU 性能问题
结合 CAD/CAE Viewer 的实际优化过程,记录大模型显示中比帧率更容易被忽略的问题:对象数量、批次组织、拓扑语义、拾取索引、局部刷新和内存结构如何共同决定显示引擎的真实性能。
背景:大模型卡,不一定是三角形太多
刚开始优化 CAD Viewer 性能时,我也很容易把问题理解成 GPU 问题。
模型一大,画面卡顿,第一反应通常是:
三角形是不是太多;
VBO 有没有正确使用;
DrawCall 能不能继续减少;
LOD 能不能降低负载。
这些都是合理方向。
但真正开始处理大型装配体以后,我越来越发现,工业 CAD Viewer 的性能问题很少只由三角形数量决定。
一个模型可能只有几百万三角形,但同时包含:
几千个 Body;
几十万张 Face;
更多 Edge;
独立颜色和透明度;
拓扑拾取语义;
隐藏/隔离状态;
编辑和高亮状态。
如果仍然按照“一个业务对象对应一个场景节点”的方式组织,即使 GPU 很快,CPU 和场景管理也会先撑不住。
后来我逐渐形成一个判断:
大模型显示首先是数据组织问题,
其次才是单纯的绘制问题。
DrawCall 不是唯一指标
减少 DrawCall 是最常见的优化方式之一。
把很多小对象合并成较少的几何批次,通常能够明显降低场景遍历和驱动调用开销。
但 CAD Viewer 不能只考虑“怎么合”。
因为合并以后,用户仍然需要:
点选某一张 Face;
框选一批零件;
修改其中一个对象颜色;
隐藏一个 Body;
高亮一条 Edge;
编辑后只更新局部区域。
如果合批只是简单把所有 Triangle 拼到一个大 VBO,那么渲染可能快了,但所有交互都会变得麻烦。
因此真正需要解决的是:
如何减少绘制批次,
同时不丢失原来的业务拓扑语义。
这比普通 Mesh Viewer 的批处理更复杂。
合批以后必须保留反向映射
假设一个显示批次里合并了很多 Face:
Face A → triangle 0 ~ 120
Face B → triangle 121 ~ 380
Face C → triangle 381 ~ 510
...
渲染阶段看到的是一份连续 Primitive。
但是拾取以后,系统必须从:
Drawable + Primitive Index
重新找到:
原始 Face;
所属 Body;
业务对象。
这就需要额外的映射结构。
同样,当业务层要求:
把 Face B 变成红色
系统也需要从 Face B 反向找到它属于哪个批次,以及对应哪一段显示范围。
所以大模型 Viewer 里经常需要两种方向的索引:
Render Range → Topology
Topology → Render Range
前者服务拾取。
后者服务更新。
这两份索引本身就会占用内存,也会影响构建时间,但如果没有它们,合批以后就很难继续保持 CAD 交互能力。
不能为了选择而拆回小对象
有一段时间,我在性能和选择之间反复摇摆。
如果每张 Face 都保留独立 Drawable:
选择简单;
修改简单;
隐藏简单;
但大模型场景树会非常重。
如果全部合成极少的 Drawable:
绘制很快;
但每次选择都需要重新生成几何或者修改巨大缓冲区。
后来更合适的方式是把正式模型和交互高亮分开。
主体模型继续保持批处理。
用户选中某些对象时,不去拆主体模型,而是根据已有索引把目标范围复制或引用到独立 Selection Layer。
于是:
Model Layer
负责稳定显示。
Selection Layer
负责临时高亮。
两者生命周期不同。
这解决的不只是性能问题,也减少了很多渲染状态互相污染的问题。
“修改一个对象”为什么会拖慢整个场景
大模型里还有一个很典型的问题:
用户只是修改一个对象颜色,结果界面停顿明显。
如果继续往下看,经常会发现并不是颜色计算慢,而是刷新路径太重。
例如:
修改一个 Face
↓
重新生成整个 Body
↓
重新生成整个显示批次
↓
重新构建所有映射
↓
重新提交 GPU
这种做法在小模型上完全可以接受。
模型一大以后,真正需要优化的是 invalidation 范围。
理想状态应该更接近:
业务对象发生变化
↓
找到受影响的 Display Bucket
↓
判断是状态更新还是几何更新
↓
只更新必要资源
如果只是颜色变化,甚至不应该重新离散几何。
如果只是隐藏一个对象,也不应该重新构造整个场景。
后来我越来越重视的一件事就是:
先减少“需要更新什么”,
再优化“更新得有多快”。
这比单纯让一个重建函数并行化更重要。
框选性能暴露的是 CPU 端问题
点选一个对象通常很快。
真正容易暴露问题的是:
框选;
全选;
批量改色;
批量隐藏。
这些操作会同时触发:
大量拾取结果整理;
拓扑去重;
状态更新;
高亮几何构建;
场景提交。
所以即使静止画面能够保持很高帧率,也不能说明 Viewer 对大装配体的交互性能足够好。
这也是为什么后面测试显示引擎时,我不再只看:
FPS。
而会同时关注:
单选延迟;
千级对象框选;
全选;
批量属性修改;
隔离与恢复;
模型导入;
内存。
工业 Viewer 的性能更像是一整条交互链路,而不是一个单独渲染指标。
CAD Edge 也是一个独立成本
很多通用三维 Viewer 只显示三角面。
CAD 软件通常还需要显示拓扑边。
对于复杂模型来说,Edge 数量可能并不少。
而且边线不能简单当作面三角形的一部分,因为它们可能有独立的:
显示开关;
颜色;
线宽;
深度策略;
拾取语义;
高亮状态。
早期如果把面和边放在同一个更新路径里,会导致一个很常见的问题:
只改面颜色,
边也被迫跟着重建。
所以后来边线逐渐被拆成独立显示层。
这类结构调整通常不会直接体现在“帧率提升多少”上。
但它能明显减少很多无意义刷新。
大模型优化里,这种看起来不显眼的边界调整其实非常重要。
内存往往比 GPU 更早成为限制
大模型显示还有一个现实问题:
模型文件大小
≠
运行时内存大小。
STEP 读取以后会有 B-Rep。
离散以后会有 Triangle Mesh。
为了 CAD 交互还会额外存在:
拓扑索引;
显示索引;
Edge 数据;
BVH;
选择数据;
缓存;
场景节点;
GPU Buffer。
如果为了编辑性能继续保留多份中间数据,内存会迅速增长。
所以后面做 Viewer 时,我开始越来越谨慎地对待 Cache。
缓存不是越多越好。
它实际上是在:
重新计算时间
和
长期内存占用
之间做交换。
对于小模型,可以保留更多编辑缓存。
对于大型装配体,更适合限制缓存预算,并且允许旧数据淘汰。
导入性能也属于显示架构
以前我会把 STEP 导入理解成 CAD 模块的问题。
后来 Viewer 独立以后发现,用户感知到的“打开模型时间”其实跨越了多阶段:
文件读取;
拓扑 Transfer;
曲面离散;
CAD Edge 构建;
显示数据组织;
索引生成;
场景提交。
其中任何一步太慢,用户看到的都是“Viewer 打开很慢”。
所以显示引擎产品化以后,必须知道自己在整条链路里消耗了多少时间。
尤其是大型装配体。
很多时候继续优化渲染一帧的时间,带来的用户体验提升,可能还不如少做一次导入阶段的重复遍历。
一个比较有效的优化顺序
现在再遇到大模型显示问题,我通常不会先从 shader 或 GPU 开始。
更愿意先按下面的顺序排查。
第一步,看对象组织。
场景节点数量;
Drawable 数量;
Bucket 数量;
Face / Edge 数量;
Triangle 数量。
第二步,看更新范围。
一次操作到底 dirty 了多少对象;
有没有因为一处修改重建整个模型。
第三步,看交互数据结构。
拾取是不是需要线性遍历;
Topology → Render 映射是否稳定;
批量选择是否反复去重。
第四步,再看 GPU。
DrawCall;
Buffer 上传;
状态切换;
透明排序;
Shader。
这个顺序并不一定适合所有图形程序。
但对 CAD/CAE Viewer 来说,我觉得通常更有效。
当前仍然存在的边界
即使做了这些调整,大模型 Viewer 仍然有一些无法回避的问题。
例如超大规模网格数据。
当数据规模进入上亿单元以后,继续按照 CAD 模型的完整拓扑语义管理方式去显示,成本会非常高。
这类数据更适合:
分块;
流式;
按需加载;
LOD;
GPU 端过滤;
独立的网格显示路径。
也就是说:
大型 CAD
和
超大网格
最终可能需要共享一部分渲染基础设施,但不应该强行使用完全相同的数据组织方式。
这一点后面还需要继续验证。
小结
这段时间做显示引擎优化以后,我对“大模型性能”最大的认识是:
不要太早把所有问题都归结到 GPU。
真正影响工业 CAD Viewer 使用体验的,往往同时包括:
场景组织;
合批粒度;
拓扑映射;
拾取结构;
局部刷新;
内存;
导入链路;
高亮与 Overlay 的独立性。
GPU 决定一批已经组织好的数据能多快画出来。
但在它之前,还有一个更重要的问题:
这些数据到底应该怎样组织。
对 CAD/CAE 显示引擎来说,我现在越来越觉得,后者才是很多性能问题真正的起点。