一套代码跑在车端、笔记本和服务器上,这件事该不该做、做到哪一层、什么时候不该做。 这一页的顺序是先质疑前提,再给分层。
如果做的是通用可视化底座,要跑在车、笔记本、服务器上,还要允许插件定制, 那确实需要一套分层。
如果做的是专用标注工具,功能上根本用不着跨端抽象。 为一个不存在的需求设计抽象,是纯成本。
所以第一句话应该是问前提,而不是直接给架构。
业务 数据模型 · 算法 · 传播 · 渲染逻辑 统一
──────────────────────────────────────────────
图形中间层 Skia · Dawn · WebGPU 实现 统一
──────────────────────────────────────────────
图形后端 Vulkan · Metal · D3D11 · GL 各端不同
──────────────────────────────────────────────
shell 窗口 · 事件 · 打包 · 离屏目标 各端不同
看一个真实的例子,浏览器内核。
| 层 | 跨平台吗 | 例子 |
|---|---|---|
| 引擎 | ✅ 统一 | 渲染引擎、JS 引擎 |
| 图形中间层 | ✅ 统一 | 二维绘图库、WebGPU 实现 |
| 图形后端 | ❌ 必须不同 | Vulkan、Metal、D3D |
| 平台 shell | ❌ 必须不同 | 窗口、事件循环、打包、权限 |
上面全是统一的。所以"最终只剩下 shell"这句话要修一个字: 剩下来的不是一层,是两层。
一层是"语义相同但实现必须不同"的后端,一层是"语义本来就不同"的 shell。
这个区分很关键,而且很容易做错。
| 插件 | 基础库 | |
|---|---|---|
| 什么时候结合 | 运行时装载 | 编译期链接 |
| 目的 | 可替换 | 同一份实现 |
| 图形栈要哪个 | ❌ 不需要替换 | ✅ 要同一份 |
插件化的默认假设是"每端都会有自己的实现"。而你要的恰恰是"只有一份"。
这条路的一个现成做法是:同一套底层封装,一份编成 wasm 给浏览器,
一份编成动态库给原生应用。底下还是 Vulkan、Metal、D3D 各不相同,但上面是同一份代码。
| 部分 | 一致性 |
|---|---|
| CPU 逻辑,解码、传播、插值、几何计算 | 可以逐位一致 |
| GPU 渲染,后端不同 | ⚠️ 只能容差内一致 |
因为把同一份确定性代码编成 wasm 和编成原生,浮点语义是确定的,
不依赖编译器的优化选择。而 GPU 不行:缓存策略、时序、精度都可能不同。
不是"代码一样",是"输出可复现地相同"。
CPU 那部分可以断言相等。GPU 那部分只能在容差内一致,而且要有回归测试守着。
"业务上提、环境下沉"这句话没有判据,不好用。换一个说法会好用得多。
这一层的差异,会不会让结果不一样?
不会 → 还能往下压,继续统一
会,语义相同 → 是后端层
会,语义就不同 → 是 shell 层
"压不动"这个说法比"下沉上提"好用,因为它有方向、有终点,而且每一步都能问一句"结果会不会变"。
| 允许各端不同 | 不允许不同 |
|---|---|
| 分辨率、采样率、帧率 | 坐标系约定 |
| 细节层级、抗锯齿、阴影 | 投影公式、单位 |
| 缓存大小 | 数据模型、算法 |
判据是一句话:降配之后,人从画面上能得出的结论会不会变?
从"精确在这儿"变成"精度低一点",可以接受。从"在这儿"变成"在那儿",不行,那是 bug。
| # | 失效情形 | 具体长什么样 |
|---|---|---|
| ① | 被最弱的端拖累 | 为了跑车端把管线压到极简,笔记本上白白浪费算力 |
| ② | 统一的代价大于分裂 | 只有一个端需要的功能塞进公共层,另外两端背了用不上的依赖 |
| ③ | 差异吃不掉 | 服务器上根本没有"窗口"这个概念,适配器只能返回空 |
| ④ | 公共层改动半径太大 | 一个端的小需求,要动所有端的核心代码 |
| ⑤ | 跨端不是业务需求 | 只是一句技术口号,那整套抽象都是纯成本 |
第一条的解法不是分裂,是统一算法、分立预算。 算法必须一样,否则结果不同;预算可以各端不同,否则被最弱端拖死。
不看"抽象设计得好不好",看调用方有没有在写 workaround。
绕行是有因,公共层里冒出一堆平台判断才是果。等你在代码里看见那些判断,说明已经烂了一阵了。
| 信号 | 说明什么 |
|---|---|
| 绕行次数多 | 抽象没覆盖到 |
| 绕行活过了两个迭代 | 它不是临时方案,是缺失的接口 |
| 三个端都绕了同一处 | 那一处抽象一定错了 |
| 绕完之后结果不一致 | 最致命。统一的目的一开始就是结果一致 |
第一版 if (platform === 'car') { 特殊处理 } 明显,评审会拦住
第二版 renderDetailed: boolean 藏起来,像个正常参数
第三版 RenderOptions { detailed, lowLatency } 已经没人记得它为什么存在
区分好配置和洗白的开关,有个简单判据:这个参数名能不能直接拿去跟产品讨论。
能问"你要多高分辨率",那它是好配置。只能问"你要不要走 carPath",那它已经烂了。
跨端统一是产品形态决定的,不是技术美德。专用工具不需要它。
需要的时候,差异压到最下面两层就够了:图形后端和 shell。上面全是统一的, 而且图形栈应该是基础库,不是插件。
而抽象的成本是永久的,收益是当时的。所以判断它有没有烂,别看设计, 看调用方有没有在绕。