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 语义怎么找回来;语义找回来之后,选择、高亮、显隐和局部重建又怎么保持可控。