9.2 同一个特性在不同引擎里的实现路径
上一节把「同一件事各家怎么做」拆成三条单一坐标轴。这一节换个切法:不再问「某个维度上各家站哪」,而是挑几个真实特性,看它们在四家引擎里分别走了一条什么样的实现路径,以及每条路径各自把成本记在了谁头上。
「坐标轴」和「实现路径」的区别在于:坐标轴是静态的,问的是某维度上的取值;路径是动态的,问的是从需求到落地经过哪几站。同一个「资源句柄」特性,helio 走的是「整数句柄 + 自己管生命周期」,Bevy 走的是「Arc 引用计数 + 资源集合」,Godot 走的是「引用计数对象 + 路径加载」,Defold 走的是「引擎托管 + URL 寻址」。四条路都能用,但代价落点完全不同。
这也解释了一件事:为什么「A 引擎有这个功能、B 引擎没有」这种比较经常没意义。大多数基础功能两家都有,差别只在实现路径与代价落点。真正该问的不是「有没有」,而是「它走哪条路、代价落在谁头上」。
本节挑四条最典型的路径:资源句柄、帧图、脚本绑定与热重载、资产导入与序列化。前三条配了本机可运行的工具,第四条只做原理对照。helio 那一侧照例贴本机真跑的代码,其余三家只据官方文档做原理级标注——不贴它们的源码片段。
为什么要用「路径」这个视角?因为坐标轴回答的是「取值」,而工程决策真正关心的往往是「经过哪几站、每站要付什么」。同一句「支持热重载」,helio 要自己写 include 展开器,Godot 靠编辑器重载脚本,Bevy 靠一个 cargo feature,Defold 靠一个菜单项——它们给出的结果看起来一样,背后要维护的代码量却差一个数量级。把路径摊开,这类差别才看得见。
路径一:资源句柄 —— 谁负责「不失效的引用」
第一个特性是资源句柄。它要回答一个很朴素的问题:游戏里反复引用的同一张贴图、同一个网格,在内存里到底用什么东西指代它,删掉之后旧引用会怎样。
四家的分歧点只有一个:谁来负责检测「这个引用已经死了」。helio 与 Bevy 把答案交给句柄本身(世代 / 引用计数),Godot 交给 RefCounted 的计数,Defold 交给引擎的地址校验。检测者不同,你能拿到的保证也不同。
| 引擎 | 句柄 / 引用是什么 | 资源数据存在哪 | 生命周期谁管 | 主要代价 |
|---|---|---|---|---|
| helio | 世代句柄(32 位整数) | 自己的槽位数组 / GPU 缓冲池 | 引擎手写 | 要自己管,但可断言 |
| Bevy | Handle<A>(Strong / Uuid 两个变体) | Assets<A> 资源 | Arc 引用计数 | 原子操作开销;弱句柄不保活 |
| Godot | RefCounted 对象引用 / res:// 路径 | 引擎资源缓存 | 引用计数 | 循环引用要手动断 |
| Defold | URL(字符串地址) | 引擎资源系统(atlas 等) | 引擎托管 | 控制权不在开发者手上 |
Bevy 的 Handle<A> 官方文档写明它是一个枚举:Strong(Arc<StrongHandle>) 会被引用计数保活、Uuid 变体不保活(见 bevy_asset::Handle
)。Defold 用 URL 字符串寻址 game object 与 component,往不存在的地址发消息会在控制台报错(见 Message passing
)。Godot 的资源引用计数属于 Object / RefCounted 体系,本节不展开。
helio 这一格是把「失效」直接编进整数。一个句柄 = 20 位槽号 + 12 位世代,删除时世代自增,让所有旧句柄立刻对不上号:
// snippet: src/core/slotmap.ts —— 世代句柄的打包与拆包
/** 句柄:高位是世代,低位是槽位索引。0 是「空句柄」。 */
export type Handle = number;
export function makeHandle(index: number, generation: number): Handle {
return (((generation & GEN_MASK) << INDEX_BITS) | (index & INDEX_MASK)) >>> 0;
}
export function handleIndex(h: Handle): number { return h & INDEX_MASK; }
export function handleGeneration(h: Handle): number { return (h >>> INDEX_BITS) & GEN_MASK; }
helio 的资源层还多守一道:三缓冲的 uniform 池在「复用一个仍在飞的槽位」时直接抛错,把「GPU 还在读、CPU 已经写」这种竞态从线上黑屏变成一条确定性异常(src/render/resource/uniform-ring.ts)。这条路线的总体思路是:把能检测的错误尽量提前到赋值或写入那一刻,代价是引擎代码里到处要写世代校验。世代句柄的完整机制见 6.2 世代句柄
。
四条路径里,资源句柄是代价最容易被低估的一条。引用计数看着「自动化」,但它把成本换成了运行时的原子操作与一次无法静态证明的释放时机;世代句柄看着「土」,但它把一次查找变成一次整数比较,且失效是可断言的。如果你的项目里「资源提前释放」是一类高发 bug,helio 这条路线的性价比会很高;如果你更在意「代码少写一点」,Bevy 或 Godot 的引用计数更省心。
路径二:帧图 —— 声明式 IR 还是命令式录制
第二个特性是一帧的渲染顺序怎么表达。这条路径上,四家分成明显两派:一派让引擎替开发者推依赖,一派要开发者自己按顺序录命令。
| 引擎 | 渲染顺序怎么表达 | 依赖谁来推 | 资源别名 / 屏障 |
|---|---|---|---|
| helio | Pass 只声明读写的资源,帧图推依赖 | 引擎按 RAW 自动拓扑排序 | 由资源生命周期推出可别名区间 |
| Godot | RenderingDevice 手动录命令 + 手动 barrier | 开发者 | 开发者手动 |
| Bevy | render graph(节点 + 边) | 引擎按边排序 | 引擎管理 |
| Defold | render script(Lua)按序调 render predicate | 开发者在脚本里写死 | 基本手动 |
Godot 的 RenderingDevice 官方文档称它是「对现代低层图形 API 的抽象」,比 RenderingServer 更底层、要自己管屏障(见 RenderingDevice
);Defold 的渲染管线由一个 Lua render script 控制,含 init() / update() / on_message(),用 render predicate 决定「渲染什么、何时渲染、渲染到哪」(见 Render
)。这两家都是命令式,顺序写在代码里。
helio 走的是声明式:Pass 只声明读写,依赖与顺序全由帧图推。上一节的工具已经跑过它的拓扑序,这里看它的核心——真依赖(RAW)与声明顺序无关:
// snippet: src/render/framegraph/framegraph.ts —— 只按真依赖排序
topologicalOrder(): PassId[] {
const edges = this.dependencies(); // 只含 RAW:写者 → 读者
// ……Kahn 算法;WAR/WAW 由声明序兜底,不参与排序
if (order.length !== this.#passes.length) {
throw new FrameGraphError('帧图存在真依赖环,无法拓扑排序');
}
return order;
}
这条路径的代价是多了一层 IR:写一个 Pass 不能直接发命令,得先声明它读什么写什么,帧图才能替你排。换来的是顺序可自动推导、资源生命周期可算、别名可自动。这套机制在 14.1 Pass 依赖与生命周期
里展开过,站内 /graphics-render-graph/
也从方法论角度讲过——本节只把它当成「四条路径中的一条」来对照。
命令式与声明式的分界,其实和团队规模有关。一个人写渲染、Pass 不超过十个,命令式更直观,改哪行看哪行;一旦 Pass 上到几十个、跨多人协作,声明式 IR 才开始回本——因为「谁读了谁的输出」不再靠人脑记,而是帧图算出来的。helio 选声明式,是因为这本书要演示「自动屏障」「资源别名」这类机制,命令式演示不了。
还有一层:声明式 IR 让资源别名成为可能。两个生命周期不重叠的临时纹理可以共用同一块显存,这个判断靠人做容易出错,靠帧图算却只是一次区间比较。helio 在 lifetimes() 里把每个资源的 [firstUse, lastUse] 算出来,别名推理就有据可依——这也是本节工具里那行 首次写@2 末次用@3 的实际用途。
路径三:脚本绑定与热重载 —— 绑定成本记在谁头上
第三个特性是脚本绑定,顺带它的孪生问题:热重载。这两件事总是一起出现——一旦引擎支持脚本,就必然要回答「改一行脚本能不能不重启就生效」。
| 引擎 | 脚本语言 | 绑定方式 | 热重载手段 | 绑定成本记在谁头上 |
|---|---|---|---|---|
| helio | TypeScript(就是引擎语言) | 无绑定层 | WGSL include 展开 + 换模块 | 无 marshalling,但策划不能写脚本 |
| Godot | GDScript(+ 原生扩展) | GDExtension(共享库) | 编辑器重载脚本 | 跨语言边界 + 版本兼容 |
| Bevy | 纯 Rust | 无脚本层 | file_watcher 特性 | 迭代要重编译 |
| Defold | Lua | 脚本组件 + 原生扩展 | File ▸ Hot Reload | 性能与类型安全较弱 |
Godot 的 GDExtension 官方文档称它让引擎在运行时与原生共享库交互,核心是三件东西:gdextension_interface.h、extension_api.json、*.gdextension 文件(见 What is GDExtension?
);Defold 允许在游戏运行时热重载代码、内容与着色器(见 Hot reloading
);Bevy 的资产热重载靠 file_watcher 特性开关,并可用 AssetPlugin 的 watch_for_changes_override 在运行时切换(见 AssetPlugin
)。
helio 这一格最特殊:它没有绑定层,因为引擎本身就是 TypeScript,游戏逻辑与引擎代码是同一门语言、同一个运行时。代价是失去了「让策划用轻量脚本改数值」的能力,换来的是零 marshalling、零跨语言边界。它的热重载也不是重载脚本,而是着色器的文本级 include 展开:WGSL 本身没有 include,引擎在 createShaderModule() 之前把 #include 递归展开,并留下「展开后行号 → 原文件行号」的映射(src/render/shader/include-resolver.ts,机制见 11.1 WGSL 的组织与 include
)。着色器语言本身的取舍,站内 /shaders-glsl-wgsl/
有更细的对照。
把四家的绑定方式并排看,会出现一个规律:绑定层越厚,迭代越快,可控性越低。Godot 的 GDExtension 让 C++ 也能写游戏逻辑,代价是每次引擎升级都要重新核对版本兼容;Defold 的 Lua 让策划也能改数值,代价是脚本跑在解释器里、类型错误要到运行时才炸;Bevy 的纯 Rust 最安全,代价是改一行要等编译。helio 索性把绑定层砍掉,代价是只有会写 TypeScript 的人能参与。这不是优点也不是缺点,而是把「谁能改这个游戏」这个问题一并回答了。
路径四:资产导入与序列化 —— 数据在谁手里
第四个特性是资产从「美术导出的源文件」到「运行时能直接读的字节」这一段。它决定了改一张图要不要重跑一整套导入流程,也决定了存档能不能跨版本读回来。
| 引擎 | 资产怎么进引擎 | 运行时格式 | 版本化 / 迁移 |
|---|---|---|---|
| helio | 自写工具烘焙 + 二进制序列化 | 自定义二进制(binary-serde) | 显式版本号 + 迁移链 |
| Godot | 编辑器导入 + .import 旁文件 | .tres / .res / 打包 | 引擎管 |
| Bevy | 实现 AssetLoader trait | 由加载器决定 | serde |
| Defold | 编辑器导入 + atlas 打包 | 引擎打包格式 | 引擎管 |
helio 这一格最能体现「路径即代价」:它没有编辑器,所以资产导入得自己写工具;它要保证存档能跨版本读回来,所以序列化里必须带版本号并留一条迁移链(src/core/binary-serde.ts、src/core/migration.ts)。这两件事在 Godot 与 Defold 里都由编辑器与引擎代劳,开发者通常感觉不到;而在 helio 里,它们是要一行行写、一个个测的模块。序列化与版本迁移的细节在 17.1 反射式序列化
与 17.3 版本化与迁移
里;资产管线的一般方法论,站内 /graphics-gltf-asset-pipeline/
讲得更细。
这条路径还有一个容易被忽视的成本:格式一旦发出去就改不动了。存档一旦带到线上,任何字段的增删都要靠迁移链兜住;这也是 helio 把版本号写进序列化头、并单独维护一个迁移模块的原因。现成引擎把这件事藏在引擎内部,开发者省心,但当你要做「跨大版本读旧档」时,也得看引擎是否愿意为你保留那条兼容路径。
这条路径没有配可运行工具,因为它更适合用「改一个字段、看旧存档能不能读」的方式手工验证——而这属于 L4:真机、真实数据,命令行不可复现。
为什么只给三条路径配工具
四条路径里,前三条都配了本机可运行的代码,第四条(资产与序列化)只在正文里做了原理对照。这不是偷懒,而是由可复现性决定的:
- 资源句柄、帧图、include 展开都是纯逻辑:输入确定、输出确定,能在 Node 里一口气跑完并断言,属于 L1。
- 资产导入与版本迁移的核心价值在「旧数据」上:要验证一条迁移链,得有一份真实的旧存档,还得在真机、真项目里读回来。这属于 L4——命令行不可复现,只能手工验证。
换句话说,配不配工具不取决于这条路径重不重要,而取决于它能不能被命令行复现。凡是能复现的,本书就给它一个 // verify:;不能复现的,就老实标注「需在真实设备 / 真实数据中手动验证」。这条纪律贯穿全卷,不只是这一节。
可运行产物:把三条路径的代价量出来
前三条路径如果只停在文字上,读者还是没法核对。所以配一个只测 helio 一侧的工具:它跑真实的世代句柄、帧图和 WGSL include 展开,把三条路径的关键行为打印出来并断言。它不跑别的引擎——别的引擎那一列只做原理标注。
工具分三段,每段只碰一条路径:第一段 SlotMap 插入、删除、再插入,看旧句柄会不会失效;第二段乱序声明五个 Pass,看帧图能不能把顺序排对、hdr 的生命周期算对;第三段喂进一个小 include 图,看展开后行数与被展开文件列表。
// file: tools/feature-paths.mjs
// tier: L1
// verify: node tools/feature-paths.mjs
//
// 第 9 章对照工具:把「同一个特性、不同实现路径」的代价在本机量出来。
// 只跑 helio 一侧;其它引擎的路径在正文里以官方文档为准做原理对照。
import { SlotMap, handleIndex, handleGeneration } from '../src/core/slotmap.ts';
import { FrameGraph } from '../src/render/framegraph/framegraph.ts';
import { resolveIncludes } from '../src/render/shader/include-resolver.ts';
// ---- 路径一:资源句柄(世代句柄 vs 引用计数) ----
const handles = new SlotMap();
const h = handles.insert({ kind: 'texture', w: 512, h: 512 });
handles.remove(h); // 回收槽位,世代自增
const resurrected = handles.insert({ kind: 'texture', w: 256, h: 256 }); // 复用同一槽位
console.log('--- 路径一:世代句柄 ---');
console.log(`旧句柄 slot=${handleIndex(h)} gen=${handleGeneration(h)}`);
console.log(`新句柄 slot=${handleIndex(resurrected)} gen=${handleGeneration(resurrected)}`);
console.log(`用旧句柄取: ${handles.get(h) === undefined ? 'undefined(失效被检测)' : '拿到新资源(别名 bug)'}`);
console.log(`用新句柄取: ${handles.get(resurrected)?.w}px`);
// ---- 路径二:帧图(声明式 IR vs 命令式录制) ----
const fg = new FrameGraph();
// 故意乱序声明:读者先写,看拓扑排序能不能把写者提到前面
fg.addPass({ name: 'present', reads: ['ldr'], writes: [] });
fg.addPass({ name: 'tonemap', reads: ['hdr'], writes: ['ldr'] });
fg.addPass({ name: 'lighting', reads: ['shadowMap', 'gNormal', 'gAlbedo'], writes: ['hdr'] });
fg.addPass({ name: 'gbuffer', reads: [], writes: ['gNormal', 'gAlbedo'] });
fg.addPass({ name: 'shadow', reads: [], writes: ['shadowMap'] });
const order = fg.topologicalOrder();
const pos = new Map(order.map((n, i) => [n, i]));
console.log('--- 路径二:帧图拓扑序 ---');
console.log(`声明序: present, tonemap, lighting, gbuffer, shadow`);
console.log(`拓扑序: ${order.join(' -> ')}`);
const life = fg.lifetimes().find((l) => l.resource === 'hdr');
console.log(`资源 hdr 生命周期: 首次写@${life.firstWrite} 末次用@${life.lastUse}(拓扑序下标)`);
// ---- 路径三:热重载(文本级 include 展开 + 行号映射) ----
const files = new Map([
['shaders/lib/color.wgsl', 'fn gamma(c: vec3<f32>) -> vec3<f32> {\n return pow(c, vec3<f32>(2.2));\n}\n'],
['shaders/lib/math.wgsl', 'const PI: f32 = 3.14159265;\n'],
]);
const root = { path: 'shaders/main.wgsl', text: '#include "lib/math.wgsl"\n#include "lib/color.wgsl"\n@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }\n' };
const resolved = resolveIncludes(root, files);
console.log('--- 路径三:热重载(include 展开) ---');
console.log(`展开后行数: ${resolved.lines.length}`);
console.log(`被展开的文件: ${resolved.included.join(', ')}`);
const m = resolved.lines[2]; // 展开后的第 3 行来自哪
console.log(`第 3 行来源: ${m.file}:${m.inLine}`);
const ok =
handles.get(h) === undefined &&
order.indexOf('gbuffer') < order.indexOf('lighting') &&
order.indexOf('shadow') < order.indexOf('lighting') &&
order.indexOf('lighting') < order.indexOf('tonemap') &&
order.indexOf('tonemap') < order.indexOf('present') &&
resolved.included.length === 2 &&
m.file === 'shaders/lib/color.wgsl';
console.log(ok ? 'ASSERT OK' : 'ASSERT FAIL');
process.exit(ok ? 0 : 1);
本机实跑(node tools/feature-paths.mjs):
--- 路径一:世代句柄 ---
旧句柄 slot=0 gen=1
新句柄 slot=0 gen=2
用旧句柄取: undefined(失效被检测)
用新句柄取: 256px
--- 路径二:帧图拓扑序 ---
声明序: present, tonemap, lighting, gbuffer, shadow
拓扑序: gbuffer -> shadow -> lighting -> tonemap -> present
资源 hdr 生命周期: 首次写@2 末次用@3(拓扑序下标)
--- 路径三:热重载(include 展开) ---
展开后行数: 8
被展开的文件: shaders/lib/math.wgsl, shaders/lib/color.wgsl
第 3 行来源: shaders/lib/color.wgsl:1
ASSERT OK
三行输出各对应一条路径的代价落点:
- 路径一:旧句柄是
slot=0 gen=1,新句柄是slot=0 gen=2——同一个槽位,世代从 1 走到 2,于是旧句柄取到undefined。这就是「把失效编进整数」的实证:不靠运行时扫描,不靠 GC,靠一个自增的世代。 - 路径二:声明序是乱的(
present最先声明),拓扑序却把gbuffer、shadow提到了lighting前面。hdr的生命周期是[2,3],意味着它只在拓扑序第 2 到第 3 个 Pass 之间活着——别名机制据此复用内存。声明式 IR 的价值就在这两行里。 - 路径三:8 行输出里第 3 行来自
lib/color.wgsl第 1 行。热重载时着色器报错的行号,能靠这张映射表还原回原文件,不用面对「展开后第 37 行」这种无从下手的信息。
这三段输出还有一个共同特征:每一段都能被断言。工具末尾把三处预期写成 ok 表达式,任何一处不成立就返回退出码 1。这不是为了好看,而是为了让「路径的代价」从一段叙述变成一条能进 CI 的检查——就像 5.1 模块边界与依赖方向
里那个扫描器一样,能返回退出码的结论才拦得住人。
路径之外:还有哪些特性值得这样拆
本节只拆了四条路径,但「实现路径」这个视角可以套到任何特性上。下面这些同样值得单独摊开:
- 输入:helio 用动作映射把物理按键翻译成语义动作(6.2 动作映射与重绑定
);Godot 用
InputMap;Defold 靠消息;Bevy 用资源加 system。 - 动画:helio 走剪辑轨道求值(3.1 剪辑轨道求值器
);Godot 用
AnimationPlayer节点;Bevy 用插件;Defold 用 flipbook 与 model 动画。 - 反射:helio 用显式类型注册表(16.2 类型注册表
);Godot 内建反射;Bevy 用
Reflect;Defold 的 Lua 表天然动态。
注意上面这些格子里,helio 那一侧都有可点的章节链接,另外三家仍只有原理级描述。这个不对称是刻意的——本书能保证的只有 helio 一侧的准确性,别家只提供对照的坐标。
四条路径的共同点
四条路径表面无关,其实都在回答同一个问题:成本记在谁头上。helio 的选择是「记在引擎作者头上」——句柄失效要自己校验、帧图 IR 要自己写、include 要自己展开、导入工具要自己写;换来的是每一处都可断言、可追踪、可打印。Bevy 把一部分成本记在 Rust 类型系统头上(Arc 保活、编译期检查),Godot 与 Defold 把更多记在引擎与工具链头上,开发者少写代码但少一层可控性。
把四条路径的代价落点收成一张表:
| 路径 | helio 把成本记在哪 | 换回来的能力 |
|---|---|---|
| 资源句柄 | 手写世代校验 | 失效可断言、无原子操作开销 |
| 帧图 | 多维护一层声明式 IR | 顺序自动推导、资源别名自动 |
| 脚本绑定与热重载 | 无脚本层 + 自写 include 展开 | 零 marshalling、行号可映射 |
| 资产与序列化 | 自写导入工具 + 版本迁移链 | 存档跨版本可读 |
这张表还能反着读:如果你不愿意在某一环付账,那就别选走那条路的引擎。反过来,下面三种情形往往说明「该考虑自研了」:
- 引擎在某个环节的实现路径与你的需求根本冲突,而且没有扩展点绕开;
- 你要的不是「用起来」,而是「能改」——比如要做一套自己的资源热更新格式;
- 长期维护里你反复在同一个环节打补丁,补丁已经比引擎原实现还多。
这三条会在 9.3 里变成可打分的判断标准。
没有一条路径是免费的。选路径的本质不是选「哪个更好」,而是选「你愿意在哪个环节付账」——付在写引擎时、付在编译时,还是付在运行时。这四条路径恰好把几种付账方式各演示了一遍。认清这一点,下一节「选型与自研」才有落脚处:自研的本质,就是主动把成本揽到自己头上,换取对某个环节的完全掌控。
阅读导航:上一节:9.1 单一坐标轴对照 · 下一节:9.3 选型与自研的判断标准 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。