Technical note
OSG 合批显示实践
记录 CAD/CAE Viewer 从大量细粒度 Geode / Drawable 走向批量 Geometry 的工程过程:合批解决了什么,为什么不能简单做成一个巨大 Geometry,以及它给拾取、高亮、显隐和空间分桶留下了哪些新问题。
背景:节点少一点,交互才有余地
CAD/CAE Viewer 的性能问题,很多时候不是 GPU 画不动三角形,而是 CPU 侧被太多显示对象拖住了。
旧 Viewer 中,一个 face、edge 或 vertex 可以直接对应一个 OSG 节点。小模型下这很直观:节点就是业务对象,拾取、显隐、颜色修改都容易写。
但模型变大后,问题会一起出现:
场景树里的 Geode 数量变多;
Drawable 数量变多;
StateSet 或状态修改路径变多;
拾取、框选、显隐和高亮要处理更多小对象;
Viewer 每帧遍历、剔除和状态组织的压力变大。
这类卡顿不一定表现为“完全画不出来”。
更多时候,它表现为交互变钝:旋转缩放还能动,但选择、隐藏、恢复显示、高亮一大片对象时明显拖慢。
合批显示的目标,就是先把显示引擎从“大量细粒度节点管理”里解放出来。
这一步不是为了追求某个漂亮的指标,而是为了让后续选择、高亮、显隐和局部更新还有优化空间。
旧做法:一个拓扑对象对应一个显示节点
旧结构通常类似这样:
ObjectRoot
├─ FaceSwitch
│ ├─ FaceGeode 1
│ ├─ FaceGeode 2
│ └─ ...
└─ EdgeSwitch
├─ EdgeGeode 1
├─ EdgeGeode 2
└─ ...
这个结构一开始很合理。
CAD 拓扑里有 face,Viewer 里就有 face node;拓扑里有 edge,Viewer 里就有 edge node。节点上绑定 objectId、faceTag、edgeTag。拾取命中节点后,可以直接读回业务语义。工程树点一个面,也能很容易找到对应节点。
所以旧做法的优势是清晰:
拓扑和显示一一对应;
显隐直接切节点;
高亮直接改节点颜色;
拾取命中后反查简单;
早期功能验证速度快。
但它的问题也来自同一个地方:拓扑数量会直接变成显示节点数量。
在 CAD/CAE 大模型里,face 和 edge 的数量很容易上来。此时 one face one Geode、one edge one Geode 会把业务复杂度完整传导给 OSG 场景树。
小模型阶段的“清晰”,到了大模型阶段会变成“太碎”。
Geode、Drawable 和 StateSet 会一起膨胀
OSG 的场景树不是一个纯数据容器。
每个节点、Drawable 和状态对象都会参与遍历、剔除、排序或绘制准备。
当 face / edge 被拆得很细时,成本不只来自 Geode 数量。
Drawable 也会变多。即使每个 Drawable 只画少量三角形或线段,OSG 仍然要管理它们。大量小 Drawable 会增加 CPU 调度成本,也更难形成稳定的批量提交。
StateSet 或状态修改路径也会变多。
CAD/CAE Viewer 里经常要处理:
颜色;
透明度;
边显示;
选择高亮;
hover 高亮;
隐藏;
隔离;
恢复显示。
如果这些状态散落在大量小节点上,批量操作就很容易变成逐节点更新。
这也是旧路径在小模型上可用、大模型上吃力的原因。
小模型下节点数量不多,逐个节点操作没什么压力;大模型下,同样的操作会被拓扑数量放大。
交互操作会退化成大量小更新
渲染慢只是其中一部分。更麻烦的是交互。
例如隐藏一个对象。
旧结构里可能要找到它下面的很多 face node 和 edge node,再逐个设置可见性。
例如框选一片区域。
拾取结果可能是一大组 face / edge,后续高亮、颜色修改或选择状态同步都要围绕这些节点做。
例如清除选择。
Viewer 不只要把选中集合清掉,还要把之前改过颜色、透明度或高亮状态的节点恢复。
这些操作在用户看来都是一个动作,但在底层可能变成大量小对象更新。
节点越细,交互越容易被这些小更新拖住。
所以合批不是单纯为了“少画几次”。
它首先是为了让显示引擎拥有更粗、更可控的操作单元。
新方案:把多个 face 合成 DisplayBatch
新的显示路径里,导入或拓扑提取阶段不直接创建 OSG 节点,而是先生成 DisplayData。
DisplayData 里保存 face 的顶点、法向、索引、颜色、材质和拓扑标识。
然后显示层再把这些 face 组织成 DisplayBatch:
DisplayData.faces
↓
group by material / opacity / bucket
↓
DisplayBatch
├─ Geometry
├─ Drawable
└─ RangeTable records
在这个结构里,一个 DisplayBatch 可以包含多个 face。每个 face 不再必须拥有自己的 Geode。多个 face 的顶点和索引会合并到一个 Geometry 里,形成更少的 Drawable。
edge 也类似。
CAD 拓扑边可以先变成 polyline 数据,再按策略合并成较少的 edge Geometry,而不是 one edge one Geode。
合批之后,OSG 场景树的组织方式就不再被拓扑数量直接决定。它可以按显示策略来组织,例如:
相同材质进入同一批次;
透明和不透明分开;
surface 和 edge 分开;
大模型按 DisplayBucket 分块;
选择和高亮走单独的 Selection Layer。
这一步解决的是显示结构的主动权问题:由 Viewer 按渲染和交互需要组织批次,而不是被 CAD 拓扑树牵着走。
合批不是一个巨大 Geometry
合批很容易被误解成:既然节点多会慢,那就把所有三角形塞进一个超级大的 Geometry。
这在 CAD/CAE Viewer 里通常不够。
首先,材质和透明度会打断批次。
透明对象和不透明对象的渲染状态不同,不能简单混在一起;不同材质、不同显示风格,也可能需要进入不同批次。
其次,CAD edge 和 surface 不是一类显示。
三角面负责实体着色,CAD edge 负责拓扑边界可读性。edge 可能有自己的显示开关、拾取策略、简化策略和高亮路径。
再次,一个巨大 Geometry 会让局部更新变重。
隐藏一个小对象、高亮一个局部区域、替换一小块显示数据,如果都要动一个超级 Geometry,就会把局部操作变成全局操作。
最后,合批之后还必须保留 CAD 语义。
普通 mesh viewer 可以只关心三角形是否画出来;CAD/CAE Viewer 还要知道每个 primitive 对应哪个 face、哪个 edge、哪个 body。
所以工程上更合适的合批,不是“全部合成一个”,而是“按渲染状态和交互边界合成较少批次”。
可以理解为:
错误理解:
all faces -> one huge Geometry
更实际的做法:
faces
-> material buckets
-> opacity buckets
-> spatial buckets
-> DisplayBatch list
-> RangeTable
合批的重点不是把所有数据压成一块,而是在减少节点数量的同时,保留后续交互需要的边界。
RangeTable:合批后的语义入口
合批会打破旧结构里的一个便利条件:以前命中一个 Geode,基本就知道命中了哪个 face。
合批之后,一个 Drawable 里可能有很多 face。
因此,合批必须配套 RangeTable。
RangeTable 记录每个 face 或 edge 在批量 Geometry 中的位置:
face:
objectId
bodyId
faceTag
drawable
firstPrimitive
primitiveCount
edge:
objectId
bodyId
edgeTag
drawable
firstPrimitive
primitiveCount
鼠标拾取时,OSG 返回命中的 Drawable 和 primitiveIndex。Viewer 再通过 RangeTable 反查 objectId、faceTag 或 edgeTag。
工程树选择时,也可以反向查询某个 face 或 edge 所在的显示范围,用于高亮、显隐或局部更新。
这就是 CAD/CAE 合批和普通三角网格合批最大的不同:
不能只减少 Drawable,
还要保留拓扑语义入口。
如果没有 RangeTable,合批之后显示可能变快了,但 CAD 交互能力会丢失。
高亮和显隐也要重新设计
合批之后,旧的高亮和显隐方式不能原样照搬。
如果一个 Geometry 里包含很多 face,那么隐藏其中一个 face 就不能简单关掉整个 Geode。高亮其中一个 face,也不能粗暴修改整个 Geometry 的颜色。
这带来新的工程问题:
对完整 bucket,可以直接切 node mask;
对局部 face,可能需要更新颜色数组、索引、临时高亮 Geometry 或局部重建;
对大范围选择,需要分批处理,避免一次性阻塞前台;
对边高亮,需要独立 edge layer 或小的 highlight batch;
对 ShowAll / ResetStyle,需要恢复基础显示状态,而不是重建所有 Geometry。
这些问题说明,合批不是终点。
它只是把显示结构从“海量小节点”变成“可控批次”。
之后还要围绕批次设计 Selection Layer、visibility fast path、partial rebuild 和状态恢复。
旧结构里的单个节点操作很直观,但大模型下太碎。
新结构里的批次操作更高效,但需要 RangeTable 和局部更新策略补回语义和灵活性。
空间分桶:在合批和局部更新之间折中
如果只按材质合批,某些模型可能会形成很大的 Geometry。
它的节点数量少了,但局部操作仍然不够轻。
空间分桶就是为了解决这个折中问题。
DisplayBucket 的思路是:让一个批次包含一组空间上相对接近的 face,而不是任意聚合。
这样做有几个好处:
OSG 剔除更容易发挥作用;
局部隐藏或高亮时,影响的 bucket 更少;
局部重建时,不必总是处理整份模型;
框选、hover、拾取候选范围也可以利用可见 bucket 缩小。
但 bucket 也不能切得太碎。
太碎会重新回到节点数量过多的问题。太粗又会让局部更新变重。
所以 bucket 粒度本质上是一个工程平衡:
既要控制 Geode / Drawable 数量,
又要保留空间局部性和交互局部性。
这也是为什么空间分桶会成为合批之后的下一步优化。
合批解决的是“不要太碎”。
分桶解决的是“不要太大”。
新方案解决了什么
合批显示解决的第一个问题,是让 OSG 场景树规模不再直接等于 CAD 拓扑规模。
它让显示层可以按渲染需要组织批次,而不是按 face / edge 的数量创建节点。大量小 Geode 和小 Drawable 被合并成较少的 Geometry,CPU 侧遍历、调度和状态维护压力会下降。
第二个问题,是给后续优化提供基础。
RangeTable 让合批后仍然能拾取 face / edge;Selection Layer 让高亮不必总是污染基础显示;DisplayBucket 让大模型局部操作有更小的影响范围。
第三个问题,是让显示引擎职责更清楚。
导入层输出 DisplayData,显示层构建 DisplayBatch 和 RangeTable,交互层通过 RangeTable 解释选择目标。
这样后续要优化某一层时,不必把业务拓扑、OSG 节点和 UI 操作全部搅在一起。
边界和代价
合批也有代价。
首先,实现复杂度会上升。
旧结构里“一个 face 一个节点”虽然慢,但逻辑直接。合批之后,需要维护 DisplayData、Batch、RangeTable、bucket、Selection Layer 等结构的一致性。
其次,局部操作更难。
隐藏一个 face、高亮一条 edge、恢复一组对象,都不能简单地关节点或改节点颜色。必须判断它所在的批次、range、bucket 和当前显示状态。
再次,RangeTable 的正确性变得非常重要。
只要显示批次发生局部替换或重建,primitive range 就必须同步更新。否则就会出现画面正确但拾取错误,或者拾取命中却返回旧语义的问题。
最后,合批粒度需要调。
过粗会影响局部更新,过细会重新带来节点和 Drawable 数量问题。
这也是空间 bucket、dirty bucket 和局部重建继续存在的原因。
所以合批不是一个简单开关,而是一组显示数据结构的重建。
小结
OSG 合批显示的核心,不只是减少 draw call,也不是把所有三角形塞进一个大 Geometry。
在 CAD/CAE Viewer 里,合批要同时满足几件事:
控制 Geode、Drawable 和 StateSet 数量;
保留 object、face、edge 的拓扑语义;
支持拾取、工程树选择、高亮和显隐;
给空间分桶和局部重建留下余地;
避免选择层和基础显示状态互相污染。
旧做法一开始合理,因为它直接、清晰、容易打通功能。但大模型会把它的细粒度节点结构放大成性能问题。
新的工程判断是:
拓扑对象不再直接等于显示节点。
拓扑语义进入 DisplayData 和 RangeTable,渲染对象进入 DisplayBatch 和 DisplayBucket。
这一步完成后,Viewer 才能从“能显示一个 CAD 模型”,继续走向“能流畅交互一个大 CAD/CAE 模型”。
下一篇继续讲 RangeTable,因为合批之后最关键的问题就是:显示批次变少了,CAD 的 face、edge 和 object 语义怎么找回来。