Technical note

CAD 显示引擎为什么不能每个面一个节点

记录一次 CAD/CAE 三维显示引擎优化的起点:为什么按拓扑对象直接创建 OSG 节点在小模型上可行,但在大模型上会导致节点数量、Drawable 数量和状态切换失控,以及为什么显示引擎需要从对象树转向显示批次。


背景:小模型能跑,不代表大模型能交互

CAD/CAE Viewer 的显示优化,不一定一开始就落在 shader、GPU 或复杂渲染技巧上。

很多时候,第一个真正暴露出来的问题反而很朴素:

显示对象到底应该怎么组织?

如果只看小模型,最自然的做法是让显示结构跟 CAD 拓扑结构保持一致。

模型里有对象、实体、面、边、点,Viewer 里也对应创建对象节点、面节点、边节点和点节点。工程树里点一个面,就找到这个面的显示节点;鼠标拾取命中一个节点,就读出它绑定的拓扑标识;隐藏一个面,就关掉这个面对应的节点。

这种做法非常直观。

早期功能验证阶段,它也确实容易跑通。导入、显示、选择、隐藏、高亮这些功能,都能沿着“拓扑对象对应显示节点”这条线往下写。

但问题在于,CAD/CAE 模型的规模一旦上来,这个结构会迅速暴露瓶颈。

小模型里的“清晰映射”,在大模型里会变成大量节点、Drawable 和状态更新。

最后卡住的不是某一个函数,而是整个显示组织方式。

老做法:拓扑对象直接映射场景节点

旧 Viewer 的典型结构,可以概括成这样:

ModelRoot
  └─ ObjectRoot
       ├─ FaceSwitch
       │    ├─ FaceNode 1
       │    ├─ FaceNode 2
       │    └─ ...
       ├─ EdgeSwitch
       │    ├─ EdgeNode 1
       │    ├─ EdgeNode 2
       │    └─ ...
       └─ VertexSwitch
            ├─ VertexNode 1
            ├─ VertexNode 2
            └─ ...

每个 face、edge 或 vertex 都可以有自己的显示节点。节点上再绑定一些业务信息,例如对象 ID、拓扑类型和拓扑 tag。

这种设计不是一开始就错了。

它有几个明显优点:

拓扑对象和显示对象一一对应,容易理解;
显隐可以直接切换节点;
颜色和透明度可以直接改对应节点;
拾取命中后容易反查业务对象;
工程树和三维视图之间的联动实现简单。

对于小模型来说,这种结构甚至是很高效的开发方式。

因为它把 CAD 拓扑和 Viewer 显示之间的关系做得非常直接。调试时也很容易判断:这个面有没有显示,这条边有没有高亮,这个节点有没有被隐藏。

但这个结构隐含了一个问题:

它把 CAD 拓扑管理和 OSG 渲染组织绑定在了一起。

在模型规模不大的时候,这个问题不明显。

一旦面、边、点的数量变多,拓扑数量就会直接传导成 OSG 场景树的节点数量、Drawable 数量和状态修改数量。

问题一:节点数量会失控

CAD 拓扑对象的数量增长很快。

一个零件可能有很多面和边,一个装配体又可能包含大量零件。如果每个面、每条边都变成独立 OSG 节点,场景树规模就会随着拓扑数量一起增长。

OSG 每帧都需要遍历场景树。

节点越多,CPU 侧需要处理的节点遍历、包围盒、状态、回调和剔除逻辑就越多。哪怕每个节点本身很简单,数量堆起来以后,也会明显影响交互。

大模型下常见的体验不是“完全不能显示”,而是各种操作都开始变慢:

初次挂载场景变慢;
旋转、缩放时 CPU 遍历压力上升;
拾取和框选需要面对更多候选节点;
工程树操作同步到三维视图时,更新路径变长;
清理、重建、恢复显示状态都更容易卡顿。

这类问题很容易被误判成“模型太大”或者“OSG 性能不够”。

但更准确地说,是显示结构把 CAD 拓扑数量直接变成了 OSG 场景树数量。

这时继续在原结构里做小修小补,收益会越来越有限。

问题二:Drawable 和 StateSet 也会膨胀

节点只是第一层问题。

每个显示节点后面通常还会带 Drawable 和渲染状态。

Drawable 是实际绘制对象。Drawable 数量过多时,调度和提交成本会上升。即使每个 Drawable 只画很少的三角形,CPU 仍然要组织它们、遍历它们、处理它们的状态。

StateSet 也是类似问题。

CAD/CAE Viewer 中常见状态包括:

基础颜色;
透明度;
光照和深度状态;
面显示和边显示;
选择高亮;
hover 高亮;
隐藏、隔离、恢复显示。

如果每个小节点都可能持有或修改自己的状态,状态管理就会变得很碎。

小模型上这不是问题,大模型上就会变成大量小状态更新。

更麻烦的是,不同状态之间还会互相影响。

比如用户先改透明度,再选择高亮,再隐藏对象,最后恢复显示。如果基础显示状态和交互高亮状态都直接写在同一批节点上,就很容易出现恢复不干净、颜色被覆盖、透明度残留之类的问题。

这时显示引擎的问题就不再只是“画得慢”,而是状态边界变得不清楚。

基础显示、选择高亮、临时预览、隐藏隔离,如果都挤在同一套细粒度节点上,后续维护会越来越困难。

问题三:显隐和高亮会变成大量小更新

旧结构里,显隐通常围绕节点开关做。

隐藏一个面,就是关闭这个面节点;隐藏一个对象,可能就是关闭它下面一批面节点和边节点。

颜色、透明度和高亮也是类似路径:先收集一组节点,再逐个修改状态。

这在交互上会带来几个问题。

第一,操作规模不可控。

用户框选一大片区域,可能命中很多面和边。如果每个面都对应一次节点或状态更新,前台交互就容易被拖住。

第二,批量操作很难做轻。

比如“隐藏选中”“隔离选中”“清除选择”“恢复全部显示”,它们本质上应该是少量批次级操作。但在 one face one node 结构下,很容易退化成大量小节点操作。

第三,高亮层和基础显示层容易混在一起。

hover、selection、material override 如果都直接改基础节点,后续恢复成本会变高,也更容易出现状态污染。

这也是后来显示引擎需要把 selection layer 和 base display 分开的原因之一。

选择和高亮应该更像覆盖层或临时状态,而不是每次都破坏基础显示数据。

不能简单合成一个大 Geometry

看到节点爆炸后,一个自然想法是:既然每个面一个节点太碎,那就把所有三角面合成一个 Geometry。

这确实能减少节点和 Drawable 数量,但会带来新的问题。

CAD/CAE Viewer 不是只把三角形画出来。

它还必须回答这些问题:

鼠标点到一个三角形,它属于哪个 CAD face?
点到一条线,它对应哪条拓扑 edge?
工程树选中一个 body,三维视图里应该高亮哪些 face 和 edge?
隐藏某个对象时,哪些 primitive 应该被影响?
做测量、修复、边界条件设置时,如何从显示命中回到拓扑语义?

如果所有数据只剩一个大 Geometry,而没有额外索引,显示层就丢掉了 CAD 语义。

它可能还能渲染,但已经不像一个 CAD/CAE Viewer,更像一个普通 mesh viewer。

所以问题不是在两个极端里选一个:

每个面一个节点:
  语义清楚,但节点和状态数量容易爆炸。

所有面一个 Geometry:
  节点少,但语义容易丢失。

真正需要的是第三种结构:

渲染按批次组织,语义用映射表保留。

这也是 CAD/CAE 显示引擎和普通三维模型查看器很不一样的地方。

普通查看器可以更关注 mesh 渲染效率,而 CAD/CAE Viewer 必须同时关心拓扑语义、选择、工程树联动、局部修复和属性绑定。

工程判断:显示对象和拓扑对象要解耦

这次显示引擎优化里,最关键的判断是:

拓扑对象不应该直接等于 OSG 显示节点。

更合理的结构可以理解成:

CAD 拓扑对象

DisplayData

DisplayBatch

OSG Geometry / Drawable

同时维护:

object / face / edge

primitive range

DisplayData 负责保存 Viewer 需要的中间显示数据,例如面三角形、边折线、对象归属、材质信息和拓扑 tag。

DisplayBatch 负责把多个 face 或 edge 组织成一个或少量 OSG Geometry。它面向渲染效率,不要求一个 face 对应一个 Geode。

RangeTable 负责保留语义映射。每个 face 或 edge 在批量 Geometry 中对应一段 primitive range。鼠标拾取命中 Drawable 和 primitive index 后,可以通过 RangeTable 反查 object、face 或 edge。工程树反向定位显示范围时,也可以走同一套映射。

这样拆开之后,显示引擎才有继续优化的空间:

OSG 节点数量不再等于 CAD face 数量;
Drawable 数量可以按批次控制;
材质和透明度可以按显示批次组织;
拾取、高亮、显隐仍然能找回 CAD 语义;
后续可以做局部重建,而不是动整棵对象树。

这里的重点不是多加几层类,而是把两种不同目标分开:

拓扑层负责表达 CAD 语义;
显示层负责组织高效渲染;
映射层负责把两者重新接起来。

如果这三件事混在一起,小模型阶段会很方便,但大模型阶段一定会被结构反噬。

后续方向:合批、RangeTable 和空间分桶

从对象树转向显示批次之后,优化并没有结束,只是进入了下一阶段。

第一步是合批。

把大量 face 或 edge 合并成较少 Geometry,减少场景树节点、Drawable 调度和状态切换。但合批不能只按数量做,还要考虑材质、透明度、显示状态和边显示策略。

第二步是 RangeTable。

合批之后,Drawable 不再等于 CAD 对象。没有 RangeTable,拾取和工程树联动都会失去拓扑语义。RangeTable 是 CAD/CAE Viewer 区别于普通模型查看器的关键结构之一。

第三步是空间分桶。

一个超级大的 Geometry 也不理想。局部隐藏、局部高亮、局部替换时,如果每次都要处理整块数据,交互仍然会慢。

DisplayBucket 的作用是让批次数量受控,同时保留一定空间局部性。这样局部变化可以落到少量 bucket 上,而不是动整份模型。

这几步连起来,才是显示引擎从“能显示”走向“能交互”的核心路径。

拓扑对象

显示数据

批量 Geometry

RangeTable 语义映射

空间分桶

局部更新和交互优化

从这个角度看,合批不是终点。

它只是让大模型显示回到可控范围里的第一步。

真正要支撑 CAD/CAE 交互,还需要继续处理拾取、选择、高亮、显隐、局部重建这些后续问题。

小结

每个面一个节点,是 CAD Viewer 早期很自然的实现。

它直观、好调试,也方便把拾取结果映射回业务对象。

但它的问题也很明确:模型规模上来之后,拓扑数量会直接变成 OSG 节点数量、Drawable 数量和状态更新数量。显隐、选择、高亮、颜色和透明度修改都会随之变重。

单纯合成一个大 Geometry 也不够,因为 CAD/CAE Viewer 不能丢掉 face、edge、body 这些拓扑语义。

所以最终的工程判断是:

显示对象和拓扑对象要解耦。

拓扑语义放在 DisplayData 和 RangeTable 中,渲染对象按 DisplayBatch 和 DisplayBucket 组织。

这也是后续合批显示、选择层、RangeTable 反查、空间分桶和局部重建的起点。

这篇文章只是显示引擎优化专题的第一步。后面继续展开时,真正要讲的是:合批之后,CAD 语义怎么找回来;语义找回来之后,选择、高亮、显隐和局部重建又怎么保持可控。