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 语义怎么找回来。