Technical note

从内部 Viewer 到商业化 SDK:三维显示引擎产品化中的几个工程问题

记录一个 CAD/CAE Viewer 从内部功能模块走向独立 SDK 的过程:如何重新划分显示、交互、拾取、场景管理与业务层边界,以及为什么能显示模型并不等于具备产品化能力。

背景:能把模型显示出来,只是 Viewer 的起点

最开始做三维 Viewer 时,我对“显示引擎”的理解比较直接。

只要能够把 CAD 模型导入、离散、挂到场景树,再补上旋转、缩放、拾取和高亮,基本就已经可以支撑一个内部软件继续开发。

这种判断在功能验证阶段没有太大问题。

当时真正关心的是:

STEP 能不能打开;
大模型会不会卡;
面和边能不能正确显示;
用户能不能点选、框选;
修改颜色以后能不能立即刷新;
编辑过程中能不能做预览。

这些问题一个个解决以后,Viewer 的功能会越来越完整。

但后来真正开始把它整理成一个可以独立交付的 SDK,我才越来越明显地感觉到:

“功能已经能用”
和
“这个模块可以作为产品长期维护”
是两件完全不同的事情。

一个内部 Viewer 可以依赖宿主程序的各种前提。

一个 SDK 不行。

它需要面对未知的调用顺序、不同的业务数据、不同的生命周期,以及一个并不了解内部实现的使用者。

这次整理显示引擎,真正花时间最多的部分也不是再增加几个渲染效果,而是重新确定边界。

内部模块最容易积累隐式依赖

内部项目里,一个功能为了尽快打通,常常会直接拿到它需要的对象。

例如显示模块需要模型数据,就直接访问 CAD 文档。

拾取模块需要业务对象,就直接访问模型树。

编辑预览需要更新颜色,就直接拿场景节点。

这样的代码短期很方便。

但时间一长,就会形成一种很难察觉的耦合:

Viewer 能运行,
不是因为 Viewer 自己完整,
而是因为整个宿主软件恰好给它提供了很多默认条件。

一旦把 Viewer 单独拿出来,这些问题会集中暴露。

例如:

谁负责创建和销毁 Viewer;
模型对象的生命周期由谁管理;
显示数据能不能脱离 CAD 文档存在;
拾取返回渲染对象还是业务拓扑对象;
预览节点是否属于正式场景;
外部应用能否直接访问 OSG 节点;
设置项是全局状态还是每个 Viewer 独立;
UI 线程和场景更新线程之间谁负责同步。

这些都不是“显示效果”问题,但它们决定了 SDK 是否真的可用。

第一件事不是加接口,而是删接口

一开始整理 SDK 时,很容易陷入一个思路:

内部有什么能力,
就在公开头文件里暴露什么接口。

后来我觉得这是最危险的一种方式。

公开 API 一旦被外部使用,它就不再只是一个函数。

它实际上承诺了:

参数语义;
数据生命周期;
线程模型;
错误行为;
兼容方式;
未来版本中的稳定性。

所以产品化阶段真正重要的不是“让外部什么都能调”,而是尽量缩小稳定边界。

我最后更倾向于把 Viewer 对外能力收敛到几个明确方向:

模型显示;
视图控制;
显示状态;
拾取;
交互;
预览与 Overlay;
少量必要的屏幕/世界坐标转换。

而把下面这些继续留在业务层:

CAD 建模;
拓扑修改;
模型修复;
数据库;
材料;
边界条件;
网格;
求解业务。

这条边界看起来很普通,但实际很重要。

如果 Viewer 同时理解 CAD 业务、网格业务和求解业务,那么它最终不会成为显示 SDK,只会变成原软件的一部分代码被包装起来。

渲染数据和业务数据必须分开

CAD/CAE Viewer 和普通 Mesh Viewer 有一个明显区别:

用户真正操作的往往不是 Triangle。

用户看到的是一张 Face、一条 Edge、一个 Body,甚至是更高层的业务对象。

渲染管线真正认识的却通常是:

Vertex;
Triangle;
Primitive;
Drawable。

如果直接把业务对象塞进渲染节点,短期很方便,但会让两层重新耦合。

所以这次整理时,一个重要原则是:

业务语义可以映射到显示数据,
但显示层不应该拥有业务语义本身。

例如一份显示描述可以知道:

这批三角形对应哪个显示对象;
这一段 Primitive 对应哪个拓扑引用;
当前对象属于哪个显示分组;
需要什么颜色、透明度和可见状态。

但真正的材料属性、物理边界条件、修复状态仍然由宿主管理。

这样做以后,Viewer 才能同时服务不同类型的软件,而不是只能服务原来的 CAD 客户端。

场景树也需要成为稳定边界

早期使用 OSG 时,我很自然地把很多业务状态直接放进场景树。

后来场景越来越复杂:

主体模型;
CAD 边;
网格;
选中高亮;
悬停高亮;
编辑预览;
辅助标记;
坐标轴;
HUD。

如果没有明确分层,任何一个功能都有可能修改其他功能正在使用的节点。

于是会出现一些很典型的问题:

框选以后边线消失;
隔离以后显示全部不能完全恢复;
透明模型和高亮状态互相影响;
预览节点被正式刷新逻辑清掉;
一次属性修改导致整个场景重建。

这些 bug 单独看像渲染细节。

但它们背后经常是同一个原因:

不同语义的显示对象共享了同一套状态和生命周期。

因此我后来更倾向于按用途划分场景层,而不是按“能不能一起画”来划分。

例如:

Model Layer
Edge Layer
Selection Layer
Preview Layer
Overlay Layer
Helper Layer

每一层分别定义自己的:

生命周期;
可见性;
深度策略;
更新入口;
清理方式。

这种结构未必能让单帧渲染更快,但会显著降低后续功能互相污染的概率。

拾取接口不能泄漏底层实现

产品化过程中还有一个很明显的问题:

内部代码很容易返回 osg::Drawable*、NodePath 或 Primitive Index。

对于引擎内部来说,这些信息很有用。

但如果公开 API 直接返回这些对象,就等于告诉使用者:

你需要理解我的场景树;
你需要理解 OSG;
你甚至可能开始修改我的内部节点。

这样 SDK 边界实际上已经失效。

更合理的方式是把底层拾取结果转成稳定的显示语义,例如:

对象标识;
拓扑类型;
拓扑引用;
拾取位置;
法向;
必要的 primitive 信息。

底层仍然可以使用 OSG 完成求交。

但 OSG 应该尽量成为实现细节。

我现在越来越倾向于把这类判断作为 SDK 设计的一个基本原则:

依赖可以存在于实现层,
但不应该因为实现方便就自动升级成公共协议。

大多数“刷新问题”其实是状态所有权问题

显示引擎后期有一类问题出现得很频繁:

属性已经修改,
数据也已经更新,
但画面没有立即变化。

最开始我会把这类问题理解成:

是不是少调用了一次 dirty;
是不是 VBO 没有更新;
是不是节点没有重新绑定。

这些当然可能是直接原因。

但反复遇到以后,我发现更应该问的是:

到底谁拥有这份显示状态?

如果颜色同时存在于:

业务对象;
显示描述;
Drawable StateSet;
编辑缓存;
高亮层;
局部重建快照;

那么修改其中一份,并不能证明最后画面一定正确。

产品化以后,我更希望形成清楚的单向链路:

业务状态变化
    ↓
Display API
    ↓
定位受影响的显示数据
    ↓
更新必要的 GPU / Scene 资源

而不是业务代码直接跨层操作场景对象。

这也是为什么后期很多优化最后都落到了“减少修改入口”上。

SDK Demo 和真实工程一样重要

以前我会把 Demo 看成一个展示程序。

这次整理以后,我觉得一个 SDK Demo 至少承担三个任务:

验证公开 API 是否真的完整;
验证 SDK 是否可以脱离原宿主运行;
给未来使用者提供最小正确用法。

如果 Demo 必须:

偷偷访问内部头文件;
依赖原工程路径;
手工修改源码才能配置 Qt;
直接访问 OSG 节点才能完成某个功能;

那么说明 SDK 边界还没有真正收敛。

所以 Demo 是否“干净”,其实是很好的架构测试。

一个内部功能在主程序里能跑通,只能证明它在当前环境里能工作。

同一个能力如果能通过公开 SDK,在独立 Sample 中稳定跑通,才更接近真正完成产品化。

商业化标准不只是性能指标

这段时间我对“商业化”的理解也发生了一些变化。

以前更容易关注:

加载速度;
帧率;
大模型容量;
拾取速度;
框选速度。

这些当然重要。

但一个工业软件 SDK 的商业化标准其实还包括:

接口是否稳定;
行为是否可预测;
错误是否可定位;
资源是否能正确释放;
跨版本是否容易兼容;
Sample 是否能独立构建;
外部用户是否需要理解内部架构;
不同功能是否存在隐式状态污染。

性能差的问题通常很明显。

架构边界差的问题反而更危险,因为它往往在功能越来越多以后才集中出现。

当前仍然没有完全解决的问题

这次重构以后,显示引擎已经比最早的内部 Viewer 独立很多,但我并不觉得它已经“完成”。

还有一些长期问题需要继续处理。

第一,大模型内存控制。

CAD 显示不仅有三角网格,还包括拓扑映射、边线、选择数据、编辑缓存和各种索引。模型规模继续增长以后,不能只关注显存。

第二,异步导入和资源提交。

几何读取、离散、显示数据构建和 GPU 资源创建并不是同一种工作,后面还需要继续拆开。

第三,更稳定的局部更新协议。

目前已经可以避免很多全场景刷新,但复杂拓扑修改以后,哪些显示分组必须重建、哪些可以原地更新,还需要持续完善。

第四,后端隔离。

现在可以减少 OSG 在公共接口中的暴露,但距离真正做到渲染后端完全可替换还有差距。

这些问题不影响当前使用,但它们决定了这个 SDK 后面还能走多远。

小结

从内部 Viewer 到独立 SDK,这次最大的变化并不是增加了更多显示功能。

真正变化的是我开始重新看待这些问题:

谁拥有数据;
谁拥有状态;
谁可以修改场景;
哪些接口值得长期承诺;
底层实现应该暴露到什么程度;
业务语义怎样穿过渲染层但不污染渲染层。

早期 Viewer 更关心:

模型能不能正确显示。

产品化以后需要继续回答:

这个 Viewer 能不能被一个不了解内部工程的人稳定使用。

这两个问题之间,还有很长一段工程距离。

对我来说,这次 SDK 化真正有价值的地方,也不是把原来的代码打了一个包,而是逼着整个显示模块重新建立了自己的边界。