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 显示引擎来说,我现在越来越觉得,后者才是很多性能问题真正的起点。