《游戏引擎实现原理》9.1 单一坐标轴对照

跨引擎对照最容易滑成「谁更好」的口水战。本节把比较拆成一条条单一坐标轴:实体与组件怎么排、一帧的顺序由谁定、句柄失效能不能被检测。每条轴只让 helio、Godot、Bevy、Defold 各占一行,helio 一侧用本机实跑的数字撑腰,其它引擎只做原理级标注并附官方文档链接。

9.1 单一坐标轴对照

前三卷一路把 helio 搭到今天,读者的脑子里已经装满了「helio 是这么做的」。到了卷四最后一章,很自然会冒出另一个问题:别的引擎是不是也这么做? 这个问题一旦问成「Godot 和 Bevy 谁更好」,就变成了没有答案的口水战——因为「更好」取决于项目,而不取决于引擎。

本节换一种问法:不问谁更好,只问同一件事,各家分别怎么做。为了让它可回答,我们一次只沿一条坐标轴比较,每条轴只让四家各占一行,做成一张表。所谓单一坐标轴,就是这张表里除了「引擎」这一列,其余列都围绕同一个维度展开,不掺第二件事。这么做的好处是每张表都能被独立核对:helio 那一格用本机代码实跑,其余三格只做原理级标注。

本节只立方法论和第一组坐标轴;下一节再把「同一个特性」的四条实现路径逐一摊开。

为什么拿 helio 当尺子?因为它是这本书里唯一从零搭到能跑的引擎:它的每一行都躺在本机 src/ 里,能被 tsc 检查、能被 vitest 断言。用一把能跑的尺子去量别人,比拿四把都跑不起来的尺子互相量要诚实得多。Godot、Bevy、Defold 在本机都没装,它们出现在表里只为了提供对照的坐标,不参与任何「实测」。

为什么是单一坐标轴,而不是综合评分

跨引擎对照最常见的两种失败方式,一种是「谁更好」的口水战,另一种是「综合评分表」——给每个引擎打一堆 1 到 5 分再加权求和,最后排个名。评分表看着客观,其实是把几十个互相纠缠的维度压成一个数,压的过程本身就是主观的:权重谁定?某个维度缺数据时填几分?一旦有人对结论不服,你没法指出到底是哪一格有分歧,只能吵总分。

单一坐标轴走的是相反的路:一次只让一个维度变。实体怎么排是一张表,顺序由谁定是另一张表,句柄失效怎么暴露又是第三张。每张表里除了「引擎」这一列,其余列都围绕同一个问题,所以每一格都能被单独核对、单独推翻。某张表你不认同,我们只争那一张表,不用把全部结论推倒重来。

这条方法还有一条硬边界:能被本机实跑验证的维度,才放进表里当刻度。helio 那一列全部来自真实源码与本机运行结果;另外三家没有在本机跑,就只能停在「据官方文档」的原理级描述,绝不贴它们的源码片段。没有核实过的代码比没有更危险——读者会照着一个可能不存在的 API 去写代码。

举个例子。假设有人做一张「引擎综合评分表」,维度有「渲染能力」「上手难度」「社区规模」「跨端支持」,各占 25 分。一个两人小团队做 2D 休闲游戏,把这张表填完,结论很可能是「某某引擎渲染最强、所以分最高」——但这支团队根本用不到那份渲染能力,真正卡他们的是「上手难度」和「包体大小」。评分表之所以误导,不是因为它算错了,而是因为它把「对谁而言」这个前提抹掉了。单一坐标轴不抹掉这个前提:它只说「Godot 的组件挂在树结构上」,至于这对你是不是好事,由你结合自己的项目判断。

本章与站内既有内容的分工

站内关于引擎的文章已经不少,先把分工划清楚,避免重复劳动:

既有文章它负责什么本节只做什么
/posts/game/engine/ 引擎选型综述面向新手与小团队回答「Unity、Unreal、Defold 从哪个开始」不做入门选型,只把同一件事拆成单一坐标轴做横向对照
/game-engine-architecture-ecs/一篇讲清 ECS 引擎的骨架与数据导向设计不重推 ECS 原理,把 helio 真实的存储实现摆到坐标轴上量
/graphics-render-graph/渲染图与帧调度的方法论:Pass 依赖、自动屏障不重讲帧图原理,只对照「帧图这件事各家放在哪一层」
/graphics-api-modern-comparison/WebGPU、Vulkan、Metal 等图形 API 的选型不比较图形 API,比较的是引擎层的组织方式

一句话:别人讲「选哪个引擎」「引擎骨架长什么样」,本节只做一件更小的事——把 helio 当成一把尺子,在每个坐标轴上量一下四家引擎各自站在哪。尺子本身能跑,量出来的刻度才可信。

还要说明的是,上面这几篇文章与本节不是竞争关系,而是上下游关系:它们负责「帮你决定用哪个引擎」,本节负责「在你已经选定一个引擎(helio)之后,看清各家在同一件事上的分岔」。前者是选型,后者是理解。

坐标轴一:实体与组件在内存里怎么排

第一个维度是数据布局:一个「实体」在内存里到底是什么,它挂的组件又存在哪。这是 ECS 引擎最核心的差异点,站内已有文章从设计角度讲过,这里只做实现级对照。

引擎实体怎么表示组件怎么排布查询怎么做
helio世代句柄(20 位槽号 + 12 位世代)或裸下标两套并存:稀疏集(每组件一个 SparseSet)与 archetype(同组件集合成组、组内 SoA)稀疏集跨组件求交,或挑出匹配 archetype 整列遍历
Godot节点对象,引用语义组件即子节点或节点属性,挂在场景树上沿树遍历或 get_node() 按路径取
Bevy一个含唯一整数的实体类型按 archetype 分块存放组件用 Query<&Component> 按组件类型筛选
Defoldgame object,用 URL 寻址component 挂在 game object 上通过消息传递或 go.get 访问

Godot 的节点树模型见官方文档 Using SceneTree ;Bevy 的 Entities / Components / Systems 三分法见官方入门 ECS ;Defold 的 game object 与 component 寻址见官方手册 Message passing 。三处都只作原理级标注——本节不贴它们的源码片段。

helio 这一格能落到代码上。archetype 的核心是「同组件集合的实体共用一组连续列」,删实体时用 swap-remove 把末行搬进空位:

// snippet: src/ecs/archetype-world.ts 的 swap-remove
/** swap-remove 第 row 行;返回被搬进 row 的实体下标,row 本就是末行时返回 -1。 */
swapRemove(row: number): number {
  const last = this.rows.length - 1;
  for (const id of this.ids) {
    const c = this.cols.get(id)!;
    c[row] = c[last];
    c.pop();
  }
  const moved = this.rows[last];
  this.rows[row] = moved;
  this.rows.pop();
  return row === last ? -1 : moved;
}

代价也很直白:给实体增删组件要在 archetype 之间「搬迁」,逐列拷贝,比稀疏集重。helio 之所以两套都留着,是因为两种访问模式都真实存在——稀疏集适合组件频繁增删,archetype 适合每帧整列扫。这个取舍站内 游戏引擎架构与 ECS 从设计层讲过,这里只补上它落到源码后的样子。

本节的可运行产物只演示 archetype 这一套,因为它的「分组」行为最容易观察成数字(组数、被搬迁的实体下标)。稀疏集那一套的行为更接近「每个组件一张表、增删 O(1)」,一句话能说清,没必要再占一个代码块。

坐标轴二:一帧里各步的顺序由谁定

第二个维度是控制流:一帧里「更新动画、算物理、提交渲染」这些步骤,顺序是谁说了算。这条轴只问一件事——顺序的决定者是谁,不问它跑得快不快。

引擎一帧的驱动者顺序怎么确定并行
helio主循环调用子系统注册表子系统的依赖拓扑序,被依赖者先启动另交给任务图 / job system
GodotSceneTree(一个 MainLoop)tree order,父先于子;idle 与 physics 两路回调渲染/物理可选多线程
BevyApp 的 Schedule(如 Update)system 按数据依赖排序,能并行的自动并行系统级并行是默认行为
Defold引擎主循环 + 每帧派发消息队列消息队列顺序,加上 render script 的 update()有限,脚本基本单线程

Godot 的 SceneTree 自带 main loop,节点按 tree order 处理,见 Using SceneTree ;Bevy 把顺序交给 Schedule,见 ECS ;Defold 的消息每帧走一遍队列投递,render script 有 init() / update() / on_message(),见 Render 。

helio 这一格是显式的拓扑序。子系统注册时声明依赖,注册表在启动前一次性解出顺序:

// snippet: src/core/subsystem.ts 的顺序解析(节选)
/** 只读地取出拓扑序,不触发任何生命周期钩子——方便测试与工具。 */
resolveOrder(): ISubsystem[] {
  const out: ISubsystem[] = [];
  const visit = (name: string): void => {
    // ……三色 DFS:遇灰即环,遇黑即已入序
    for (const dep of sub.dependencies) visit(dep);
    out.push(sub); // 依赖先入序 → 被依赖者先启动
  };
  for (const name of this.#subsystems.keys()) visit(name);
  return out;
}

boot() 按这个顺序 onInit / onStart,shutdown() 则严格逆序 onStop / onShutdown。这一点在 3.1 子系统生命周期 里已经定死,本节只是把它和另外三家的「顺序决定者」放在同一张表里。

值得强调的是,这条轴问的是「谁决定顺序」,不是「顺序好不好」。helio 选择把顺序显式写进依赖声明,代价是注册一个子系统要多写一个 dependencies 数组;换来的是启动顺序可以打印、可以断言、可以在 CI 里比对。Godot 选择让顺序隐含在树结构里,代价是得先理解 tree order;换来的是不用手写依赖。两者都是取舍,没有高下——只有「你的项目更需要哪一种」。

还有一层别混淆:子系统之间的顺序(本节这张表)和帧内任务的并行调度是两件事。helio 把后者交给了另一套东西——任务图与 job system(14.1 为什么要 job system )。子系统定的是「谁先谁后」的骨架顺序,job system 定的是「同一批活怎么分到多个核」。Bevy 把这两层揉在 Schedule 里,Godot 交给引擎内部,Defold 基本不暴露——但它们回答的仍是同一个问题:一帧的顺序由谁定。

坐标轴三:句柄失效能不能被检测

第三个维度只问一件事——删除一个实体或资源之后,旧的引用会发生什么。这条轴与布局轴正交:布局管「活着的数据怎么排」,句柄轴管「死掉的引用怎么被认出来」。它之所以重要,是因为悬空引用是所有引擎里最难查的一类 bug:不报错、不崩溃,只是安静地改错了一块内存。

引擎引用是什么删除后旧引用会怎样失效怎么暴露
helio世代句柄(槽号 + 世代)世代对不上,get 返回 undefined访问瞬间失效,可断言
Godot节点路径或 RefCounted 引用引用计数归零即释放悬空引用表现为错误或 null
BevyEntity(含世代)或 Handle<A>旧 Entity 失效;Handle 分强弱查询取不到;弱句柄不保活
DefoldURL(字符串地址)目标不存在派发消息时报错

Bevy 的 Handle<A> 官方文档说明它是一个枚举:Strong(Arc<StrongHandle>) 会被引用计数保活、Uuid 变体不保活(见 bevy_asset::Handle );Entity 带世代、旧值会失效,见 ECS 。Defold 往不存在的地址发消息会在控制台报错,见 Message passing 。

helio 这一格是 20 位槽号加 12 位世代打包进一个整数,删除时世代自增,让所有旧句柄立刻对不上号:

// snippet: src/core/slotmap.ts 的世代校验
/** 句柄有效则返回其值,否则 undefined(世代不符即失效)。 */
get(h: Handle): T | undefined {
  if (h === NULL_HANDLE) return undefined;
  const i = handleIndex(h);
  if (i >= this.#slots.length) return undefined;
  if (this.#generations[i] !== handleGeneration(h)) return undefined;
  return this.#slots[i];
}

三个轴合起来看,规律很清楚:helio 的每一条轴都能在源码里指到具体几行,而另外三家的每一格都只停在「原理级」。这正是本节的边界——能本机复现的才写成刻度,不能的只写成坐标。

这条轴之所以值得单列,是因为它把「错误」从「运行时随机崩溃」变成了「确定性的可断言结果」。helio 的 get 返回 undefined、expect 直接抛错,两种用法对应两种语义:允许缺的场景用 get,必须存在的场景用 expect。把悬空引用变成一条能写的断言,是引擎工程化里最便宜的一笔保险——它不需要额外的工具链,只需要在句柄里多留 12 个 bit。

顺带说一句,这三条轴服务的目标各不相同:布局轴直接决定性能,控制流轴决定顺序可不可控,句柄轴决定出错时好不好查。目标不同,就必须分开量——这也正是它们不能被压成一个「综合分」的原因。

可运行产物:把坐标轴钉成可跑的断言

三张表如果只靠文字描述,读者没法核对。所以本节配一个只测 helio 一侧的工具:它跑真实的 archetype、子系统注册表和世代句柄,把三个可观测坐标打印出来并断言。它不跑别的引擎——别的引擎那一列只做原理标注。

工具分三段,正好对应三条轴:第一段建一个 ArchetypeWorld,塞进三个实体(两个带位置加速度、一个只带位置),观察它分成了几个组、swap-remove 把哪个实体搬了位;第二段注册三个带依赖的子系统,观察启动顺序是不是拓扑序;第三段用 MiniEcs 建一个实体再销毁,观察旧句柄会不会失效。每段只碰一条轴,互不干扰。

// file: tools/axis-compare.mjs
// tier: L1
// verify: node tools/axis-compare.mjs
//
// 第 9 章对照工具:只测 helio 一侧「可观测的坐标」。
// 其它引擎一列只做原理级标注(据官方文档),不在这里跑——本工具
// 的存在只是为了让「helio 这一列」有本机实跑的数字撑腰,而不是背结论。

import { ArchetypeWorld } from '../src/ecs/archetype-world.ts';
import { MiniEcs } from '../src/ecs/mini-ecs.ts';
import { SubsystemRegistry } from '../src/core/subsystem.ts';

// ---- 坐标轴一:组件在内存里怎么排(archetype 分组) ----
const world = new ArchetypeWorld();
const POS = 1;
const VEL = 2;
const mk = (entries) => new Map(entries);
world.spawn(0, mk([[POS, { x: 0, y: 0 }], [VEL, { x: 1, y: 0 }]]));
world.spawn(1, mk([[POS, { x: 5, y: 0 }], [VEL, { x: 0, y: 1 }]]));
world.spawn(2, mk([[POS, { x: 9, y: 9 }]])); // 只有位置,落进另一个组

console.log('--- 坐标轴一:archetype 分组 ---');
console.log(`entities:   ${world.entityCount}`);
console.log(`archetypes: ${world.archetypeCount}`); // 期望 2:{POS,VEL} 与 {POS}
console.log(`query(POS,VEL) -> ${world.matching([POS, VEL]).reduce((n, a) => n + a.count, 0)} 行`);

// swap-remove:删掉 {POS,VEL} 组里的实体 0,末行实体 1 被搬进它的位置
const moved = world.matching([POS, VEL])[0].swapRemove(0);
console.log(`swapRemove 后被搬迁的实体下标: ${moved}`); // 期望 1

// ---- 坐标轴二:一帧里各步的顺序由谁定(子系统拓扑序) ----
const reg = new SubsystemRegistry();
const mkSub = (name, deps) => ({
  name,
  dependencies: deps,
  onInit() {},
  onStart() {},
  onStop() {},
  onShutdown() {},
});
reg.register(mkSub('render', ['assets']));
reg.register(mkSub('assets', []));
reg.register(mkSub('gameplay', ['render']));
reg.boot({ frameCount: 0, log() {} });

console.log('--- 坐标轴二:子系统启动顺序 ---');
console.log(`boot order: ${reg.startOrder.map((s) => s.name).join(' -> ')}`);

// ---- 坐标轴三:实体句柄失效能不能被检测(世代句柄) ----
const ecs = new MiniEcs();
const h0 = ecs.spawn();
ecs.add(h0, POS, { x: 1, y: 2 });
const aliveBefore = ecs.alive(h0);
ecs.destroy(h0);
const aliveAfter = ecs.alive(h0);

console.log('--- 坐标轴三:句柄失效检测 ---');
console.log(`销毁前 alive=${aliveBefore}  销毁后 alive=${aliveAfter}`);

const ok =
  world.archetypeCount === 2 &&
  moved === 1 &&
  reg.startOrder.map((s) => s.name).join(',') === 'assets,render,gameplay' &&
  aliveBefore === true &&
  aliveAfter === false;

console.log(ok ? 'ASSERT OK' : 'ASSERT FAIL');
process.exit(ok ? 0 : 1);

本机实跑(node tools/axis-compare.mjs):

--- 坐标轴一:archetype 分组 ---
entities:   3
archetypes: 2
query(POS,VEL) -> 2 行
swapRemove 后被搬迁的实体下标: 1
--- 坐标轴二:子系统启动顺序 ---
boot order: assets -> render -> gameplay
--- 坐标轴三:句柄失效检测 ---
销毁前 alive=true  销毁后 alive=false
ASSERT OK

三个数字各自对应一条轴:archetypes: 2 说明 3 个实体被按组件集合分成了两组(布局轴);assets -> render -> gameplay 说明启动顺序由依赖拓扑序而非注册顺序决定(控制流轴);销毁后 alive=false 说明销毁一个实体后,旧句柄立刻失效、不会误指向被复用的槽位(句柄轴,机制见 6.2 世代句柄 )。这三格就是 helio 在两张表里的刻度。

工具里有一处刻意的设计:它不接收任何「引擎参数」。如果想顺手让它也给 Godot 或 Bevy 打一行,就必须把它们的运行时拉进来,而本机既没装 Godot 也没跑 Bevy——那样打出来的数字只能靠编。所以这个工具的接口被限制成「只喂 helio」,从源头上堵死编数字的可能。这也是整章的纪律:helio 一侧可以贴真实代码、给真实数字,其余三家的每一个断言都必须附得上一条官方文档链接。

这个工具的退出码也是可用的:三处断言只要有一处不成立就返回 1。它现在只服务于写作时的自检,但同样的写法可以长成一条真正的对照回归——哪天 helio 的 archetype 分组行为被改坏了,这条命令会先红。

三张表读出来的共同结论

把三条轴横着看,会出现一个反复出现的模式:helio 的每个选择都偏「显式」,另外三家偏「隐含」。

  • 布局轴上,helio 让「同组件集合的实体」显式聚成一个 archetype,代价是增删组件要搬迁;Godot 把布局隐含在树结构里,代价是缓存局部性由树遍历决定。
  • 控制流轴上,helio 让顺序显式写在依赖声明里,代价是每注册一个子系统多写一个数组;Godot 让顺序隐含在 tree order 里,代价是要先理解那套遍历规则。
  • 句柄轴上,helio 把世代显式编进整数,代价是句柄只占 32 位、槽号上限 2²⁰;Bevy 同样用了世代(Entity 带代数),方向一致,只是实现语言不同。

这不是「helio 更先进」,而是这本书的立场:一本要教「实现原理」的书,选一个把机制摊开在源码里的引擎当主角,比选一个把机制藏进黑箱的引擎更好讲。换句话说,helio 的「显式」是教学需要,不是性能结论——真要论性能,还得回到 3.1 那套帧预算去量。

把三条轴压成一张总表,方便一次看完:

坐标轴helioGodotBevyDefold
实体与组件布局世代句柄 + 稀疏集/archetype 两套节点树按 archetype 分块game object + component
一帧顺序决定者子系统依赖拓扑序SceneTree / tree orderApp 的 Schedule主循环 + 每帧消息队列
句柄失效检测世代句柄,访问即失效、可断言引用计数归零即释放Entity 带世代 / Handle 分强弱往 URL 发消息时报错

这张表是本节全部结论的浓缩,每一格都能追回上面某张单轴表。注意它刻意没有「结论」列——不给排名,不给推荐,因为推荐依赖项目,而项目不在这张表里。

本节不覆盖的维度

单一坐标轴的好处是每张表都能核对,代价是它天然不给你结论。本节故意不回答下面这些问题,因为它们要么需要在本机跑另外三家引擎,要么根本不是「一条轴」能装下的:

  • 谁性能更快:这需要同一场景、同一硬件、同一次计时才有意义。helio 自己的帧预算在 3.1 帧预算与剖析 里量过,但那只是 helio 的数,不能外推成「比谁快」。
  • 谁上手更容易:这取决于文档、社区与你的既有经验,属于选型问题,站内 /posts/game/engine/ 更合适。
  • 谁的生态与资产多:这是商业与社区指标,随年份剧烈变化,写进书里会立刻过期。
  • 谁的编辑器体验好:这需要人真正在编辑器里工作,命令行不可复现,本书不覆盖。

把它们列出来,是为了让读者清楚本节的结论边界:三张表能告诉你「各家在同一件事上分别站在哪」,但不能替你回答「我该选哪个」。后一个问题的判据在 9.3 里,而且它的答案依赖你的项目,不依赖本节任何一张表。

换句话说,本节交付的是一套可复用的提问方式,而不是一份结论清单。你换成自己关心的维度——比如「资产导入要不要烘焙」「着色器怎么组织」「存档怎么版本化」——照样能拉出一张单轴表,照样能拿 helio 那一格去核。这套提问方式的迁移成本几乎为零,这才是它比「综合评分表」更值钱的地方。

最后交代一句体例:本节所有 helio 代码块都标了 // file: 或 // snippet: 头,工具块带 // tier: L1 与 // verify:,都是本机真跑过的;三张表里凡是来自别的引擎的格子,都在表下附了官方文档链接。整章延续这条规矩——helio 可以贴代码,别家只引文档。

下一节沿「同一个特性」继续对照:资源句柄、帧图、脚本绑定与热重载、资产导入与序列化,各家的实现路径与代价。

阅读导航:上一节:回到目录 · 下一节:9.2 同一个特性在不同引擎里的实现路径 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径