Technical note

从拓扑到显示数据

记录 CAD/CAE Viewer 从业务拓扑直接绑定 OSG 节点,演进到 DisplayData 中间层的过程:为什么显示层不能直接依赖业务对象,也不能只保留三角面数据。


背景:拓扑对象不是显示对象

做 CAD/CAE Viewer 时,一个很容易混在一起的概念是:模型对象和显示对象。

在业务层,一个模型通常会被理解为 object、body、face、edge、vertex 这些拓扑对象。它们服务于建模、选择、工程树、边界条件、修复、测量和后续计算。

在显示层,OSG 关心的是 Group、Geode、Geometry、Drawable、StateSet。它们服务于场景遍历、剔除、渲染状态、GPU 数据和绘制提交。

早期实现里,这两套结构很容易被直接绑定:一个 face 对应一个显示节点,一条 edge 对应一个显示节点,节点上再挂业务 tag。

这样做很直观,也很容易把鼠标拾取结果映射回业务对象。

但随着模型变大,这种绑定会越来越重。

显示层既要承担渲染,又要承担业务对象管理;业务层也会因为显示节点的组织方式,被迫理解 OSG 的节点、状态和生命周期。

上一篇文章讲的是:为什么 CAD 显示引擎不能每个面一个节点。

这篇文章接着往下讲:为什么在拓扑对象和 OSG 显示对象之间,需要一层 DisplayData。

旧做法:业务对象直接生成显示节点

旧 Viewer 的做法可以概括成:

CAD object / face / edge

直接创建 OSG node / geometry

节点上绑定 objectId / faceTag / edgeTag

挂到 face switch / edge switch / vertex switch

这个结构的优势很明显。

工程树选择某个 face 时,可以找到这个 face 对应的 Geode。鼠标点击命中 Geode 时,也可以从节点 user data 里读回 face tag。显隐、颜色、透明度、高亮,都能围绕这批节点完成。

所以在早期阶段,这样写是合理的。

它能快速打通几个基础闭环:

模型能显示;
面、边、点能分别控制;
鼠标能拾取;
工程树和三维视图能联动;
局部颜色、透明度可以直接改节点状态。

问题是,这个结构把业务语义、导入流程、OSG 节点和交互显示绑得太紧。

当后续要做合批、空间分桶、选择层、局部重建时,就会发现旧结构很难演进:每个功能都在直接操作节点,每个节点又都带着业务含义。

小模型阶段的直观设计,到了大模型阶段会变成结构负担。

显示层不应该依赖业务结构

CAD/CAE 工程软件里,业务对象往往不只是几何。

一个 face 可能属于某个 body,也可能参与工程树、材料、边界条件、修复记录、测量结果或求解设置。主工程里还可能有文档、撤销、属性、树节点、数据库或任务状态等结构。

如果 Viewer 显示层直接依赖这些业务结构,就会带来几个问题。

第一,显示引擎很难独立测试。

要显示一个模型,必须把一整套业务系统都带进来。这样显示问题就很难单独验证,也不利于在 Sandbox 或小工程里快速迭代。

第二,导入和显示耦合。

如果导入流程直接创建 OSG 节点,后续想替换渲染组织方式,就会影响导入代码。显示架构一变,导入路径也跟着变,改动范围会被放大。

第三,优化边界不清楚。

一个渲染问题可能牵涉业务对象、数据库、工程树、UI 状态和 OSG 节点,很难判断应该在哪一层修改。

第四,迁移成本高。

如果显示层依赖太多业务对象,后续想把新 Viewer 迁回主工程,或者在不同平台、不同模块里复用,就会很困难。

所以新的显示路径里,一个重要判断是:

导入或业务层不要直接创建 OSG 节点,
而是输出一份 Viewer 能理解的中间显示数据。

这份中间数据,就是 DisplayData。

DisplayData:中间显示描述

DisplayData 可以理解为业务拓扑和渲染对象之间的中间层。

它不是完整业务对象,也不是 OSG 节点。

它只描述 Viewer 构建显示批次所需要的信息:

DisplayData
  ├─ objects
  ├─ bodies
  ├─ faces
  │   ├─ positions
  │   ├─ normals
  │   ├─ indices
  │   ├─ color / material
  │   └─ objectId / bodyId / faceTag
  ├─ edges
  │   ├─ polyline
  │   ├─ objectId / bodyId / edgeTag
  │   └─ ownerFaceTags
  ├─ vertices
  └─ materials

这层数据有几个特点。

第一,它保留 CAD/CAE 必要语义。

例如 objectId、bodyId、faceTag、edgeTag。这样后续拾取、高亮、显隐还能回到拓扑对象,而不是只停留在三角形层面。

第二,它保留显示构建需要的几何数据。

例如顶点、法向、索引、边折线、材质和颜色。这样显示层可以独立构建 Geometry、Batch、RangeTable 和 bucket。

第三,它不要求业务对象和 OSG 节点一一对应。

一个 face 是语义单元,但它不一定是一个 Geode。多个 face 可以进入同一个 DisplayBatch,只要 RangeTable 记录它们在 Geometry 中的位置。

这就是 DisplayData 的核心价值:它把“业务语义”和“渲染组织”分开了。

从 Topology 到 DisplayData

在新的路径里,导入或拓扑提取阶段的职责变成:

CAD topology

tessellation / edge sampling

DisplayData

而不是:

CAD topology

OSG Geode / Geometry

这一步看起来只是多了一层数据结构,但实际影响很大。

导入层只需要关心如何从 CAD 拓扑里提取显示数据。

例如:

face 如何三角化;
face 的法向和索引如何生成;
edge 如何采样成 polyline;
body 和 face / edge 的归属关系如何记录;
材质、颜色、透明度如何变成显示属性。

至于这些 face 后续要合成几个 Geometry、是否按材质分桶、是否启用空间 bucket、是否为 edge 建独立显示层,都交给显示层处理。

这样职责边界会清楚很多:

导入 / 拓扑层:
  负责把 CAD 数据变成可显示的 CPU 数据。

显示层:
  负责把 DisplayData 变成 OSG 批次、RangeTable 和交互结构。

交互层:
  负责把 pick / selection / hover 结果解释成选择目标。

这也是后续优化能继续推进的前提。

如果导入层直接吐出 OSG 节点,那么显示层想合批、分桶、局部重建都会被旧结构限制住。

显示不只有 Surface

CAD/CAE Viewer 里的“显示”不是一层。

至少有几类显示对象需要分开考虑。

Surface display 是模型的基础面显示,通常包括三角面、法向、材质、透明度和基础颜色。

Edge display 是 CAD 拓扑边显示。它不是三角网格线,也不应该简单等同于 surface 的 wireframe。CAD edge 通常来自拓扑边采样,需要独立控制显示密度、拾取和高亮。

Selection display 是选择和高亮层。它不应该总是直接修改基础 surface 的颜色。否则多选、hover、透明度、隐藏、恢复显示之间很容易互相污染。

Overlay display 是标注、预览、修复辅助、面片提示等覆盖层。它们有自己的生命周期,不应该写入基础模型 Geometry,也不应该污染基础语义。

所以 DisplayData 不只是“把三角面拿出来”。

它更像一个分层显示协议:告诉 Viewer 哪些数据是基础面,哪些是拓扑边,哪些用于选择,哪些应该作为临时覆盖层处理。

这也是 CAD/CAE Viewer 和普通 mesh viewer 的差异之一。

普通 mesh viewer 可能只需要把网格画出来;CAD/CAE Viewer 还需要围绕这些几何数据完成选择、边界条件、修复、测量和工程语义联动。

DisplayBatch:按渲染组织数据

有了 DisplayData 之后,DisplayManager 可以按渲染需要重新组织数据。

例如,多个 face 可以按材质、透明度或 bucket 合并成一个 DisplayBatch:

DisplayData.faces

group by material / opacity / bucket

DisplayBatch
    ├─ one Geometry
    └─ many primitive ranges

这个结构和旧的 one face one Geode 不同。

旧结构中,节点数量主要由拓扑数量决定。

新结构中,节点数量更多由显示策略决定:材质怎么分、透明对象怎么分、bucket 粒度怎么设、是否启用空间分桶。

这让显示引擎有了调节空间。

小模型可以简单一些,大模型可以分 bucket;普通不透明面可以合批,透明对象可以进入独立批次;CAD edge 可以进入 edge layer,而不是混进 surface Geometry。

同时,DisplayBatch 也不是单纯为了渲染。

它还给后续局部高亮、临时 subset、局部重建留下了挂载点。

换句话说,DisplayBatch 解决的是“怎么画得更可控”,而 DisplayData 解决的是“显示层从哪里拿数据”。

这两层配合起来,才让显示引擎有机会从旧对象树里脱离出来。

RangeTable:合批之后找回拓扑语义

合批之后,一个 Drawable 不再等于一个 CAD face。

这会带来一个新问题:

鼠标拾取命中了某个 Drawable 和 primitive index,
怎么知道它对应哪个 face?

这就是 RangeTable 的作用。

可以把它理解成一张映射表:

objectId + faceTag

batchId

drawable

firstPrimitive / primitiveCount

drawable + primitiveIndex

objectId / faceTag

edge 也是类似逻辑:

objectId + edgeTag

edge batch

line segment range

这样,显示层可以合批,交互层仍然能找回 CAD 语义。

鼠标拾取时,OSG 给出命中的 Drawable 和 primitiveIndex;Viewer 再查 RangeTable,得到 objectId、faceTag 或 edgeTag。

工程树选中某个 face 时,也可以反过来查它所在的显示范围,用于高亮、显隐或局部更新。

这一步是 CAD/CAE Viewer 和普通 mesh viewer 的关键差别之一。

普通查看器可能只关心三角形命中;CAD/CAE Viewer 必须把命中结果还原到拓扑语义。

这套结构解决了什么

引入 DisplayData、DisplayBatch 和 RangeTable 后,显示引擎解决的不是一个单点性能问题,而是一组边界问题。

第一,导入层和显示层解耦。

导入不再直接创建 OSG 节点,而是输出中间显示数据。

第二,业务对象和显示批次解耦。

face 仍然是语义对象,但不再必须是一个节点。

第三,节点数量可以控制。

显示层可以按材质、透明度、空间 bucket 等策略组织批次,而不是被拓扑数量牵着走。

第四,拾取语义仍然保留。

RangeTable 让合批后的 Drawable 仍然能反查 object、face 和 edge。

第五,后续优化有了空间。

选择层、空间分桶、局部重建、边显示策略,都可以围绕 DisplayData 和 DisplayBatch 继续演进。

这里最重要的不是多加了一套结构,而是显示管线终于有了清晰边界。

业务层提供拓扑语义;
DisplayData 承接显示所需数据;
DisplayBatch 组织渲染批次;
RangeTable 保留反查关系。

只要这几层分清楚,后续很多优化才有地方落。

边界和代价

这套结构也不是没有代价。

首先,中间层会增加一套数据转换流程。

以前是拓扑对象直接创建 OSG 节点,现在需要先生成 DisplayData,再由 DisplayManager 构建批次和 RangeTable。

其次,调试方式会变化。

以前可以直接看某个 face 节点是否存在。现在需要同时看 DisplayData 是否正确、Batch 是否构建、RangeTable 是否记录、拾取是否反查成功。

再次,RangeTable 的一致性很重要。

局部重建、隐藏、替换、恢复显示时,如果显示批次变了,range 映射也必须同步更新。否则就会出现“画出来了但点不到”或者“点到了但语义不对”的问题。

最后,DisplayData 也不能无限膨胀。

大模型下,中间数据本身也有内存成本。后续仍然需要紧凑索引、缓存策略、分桶构建和必要的内存预算控制。

所以它不是银弹。

它只是把问题从“到处直接操作 OSG 节点”,收敛到一条更可控的显示数据管线里。

这已经是很重要的变化。

小结

旧 Viewer 直接把 CAD 拓扑对象映射成 OSG 节点,是一个合理的起点。

它直观、容易实现,也方便早期拾取和显隐。

但大模型会放大这种结构的问题:节点数量、Drawable 数量、状态更新和交互路径都会跟着拓扑数量增长。

更重要的是,显示层和业务层会越绑越紧,后续优化很难展开。

新的工程判断是:

拓扑对象、显示数据和渲染批次要分开。

DisplayData 负责承接 CAD/CAE 语义和基础几何数据;DisplayBatch 负责按渲染效率组织 OSG Geometry;RangeTable 负责在合批之后找回 object、face、edge 的拓扑语义。

这层解耦完成后,显示引擎才有可能继续做合批、选择层、空间分桶和局部重建。

这也是显示引擎优化专题里承上启下的一篇:上一篇讲为什么不能每个面一个节点,这一篇讲数据管线为什么要拆开。下一篇就可以继续讲,真正进入 OSG 合批时,Geometry、Drawable 和 StateSet 该怎么重新组织。