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 化真正有价值的地方,也不是把原来的代码打了一个包,而是逼着整个显示模块重新建立了自己的边界。