18.3 回顾与路线:进入渲染卷
18 章写完了。从第 1 章那张「谁依赖谁」的分层图,到 18.2 里一个方块在《微光岛》的平面上走起来,helio 从一堆空文件长成了一台能接受输入、能推进世界、能存档复现的引擎内核。这一节做两件事:把 18 章各自解决的问题收拢成一张全景表,然后说清从这里往渲染卷走该怎么走。
先说结论:卷一交付的不是一个「完整的引擎」,而是一台内核——它不画任何一个像素,但它决定了引擎能跑多快、能撑多大、能不能热重载、能不能联机。渲染是把内核里的数据画出来,编辑器是把内核里的数据编辑出来。先有内核,才谈得上别的。现在内核有了。
十八个问题,十八个答案
每一章都在回答一个具体的问题。把它们并排放在一起,整本书的骨架就显出来了:
| 章 | 它回答的问题 | 核心产物 |
|---|---|---|
| 1 | 引擎该分几层、谁可以依赖谁? | 五层依赖图 + 编译期类型约束 |
| 2 | 一帧怎么开始、时间怎么走? | 帧预算统计、固定步长循环、时间缩放 |
| 3 | 子系统怎么活、平台差异怎么挡? | 拓扑排序生命周期、PAL、断言/日志/CVar |
| 4 | 内存为什么要自己管? | 线性 arena 与栈式分配器 |
| 5 | 频繁分配释放怎么不拖慢? | 对象池 + freelist、块分配器、内存标签 |
| 6 | 对象删了,旧引用为什么还能读到? | slot map 世代句柄、稀疏集 |
| 7 | 为什么连容器都要自己写? | 动态数组、开放寻址哈希、位集、环形缓冲、string intern |
| 8 | 向量矩阵怎么摆、怎么复合? | TypedArray 布局、主序/手性约定、变换复合与求逆 |
| 9 | 旋转为什么会翻车? | 四元数、最短弧 slerp、样条与缓动 |
| 10 | 同样的代码为什么换个设备结果就不同? | 浮点确定性分析、定点数、数值稳健 |
| 11 | 同样的算法为什么时快时慢? | 缓存层级、SoA/AoS/AoSoA、OOP 到批处理的重构 |
| 12 | 实体和组件该怎么组织? | 继承/组合/数据三种方式、稀疏集与 archetype 存储 |
| 13 | 系统怎么找数据、怎么并行? | 查询匹配与缓存、脏标记、系统依赖调度 |
| 14 | 单核跑满、其他核闲着怎么办? | Job System、线程池与任务队列、work stealing |
| 15 | 任务之间谁等谁、怎么排到帧里? | 依赖 DAG、帧内任务图、跨线程同步点 |
| 16 | 编辑器怎么拿到类型信息? | 类型注册表、属性描述符、显式注册表 + 代码生成 |
| 17 | 世界怎么存下来、怎么读回来? | 反射序列化、引用/句柄/循环、版本化迁移 |
| 18 | 这些零件怎么拼成一台引擎? | Kernel 装配 + 确定性 demo |
这张表有一个刻意留下的规律:左边的问题从「是什么」一路走向「怎么不崩」。前几章还在讨论概念(分层、时间、子系统),中段开始全是「为什么会出错、怎么不出错」(悬垂引用、浮点漂移、缓存失效),到 16–17 章干脆变成了「怎么把这一切保存下来」。这不是偶然——引擎工程的核心竞争力,从来不是「能实现」,而是「不崩、不慢、不丢数据」。
另一个规律在右边:产物从「概念」变成了「代码」。 第 1 章的产物是一张图,第 18 章的产物是一个能跑起来的 Kernel。这条从「想清楚」到「做出来」的路径,本身就是一本引擎书该有的样子——不讲空话,每一步都留下能编译、能运行的东西。
五个部分,五种能力
18 章按主题分成了五个部分,每个部分给引擎加了一种能力。理解这五种能力,比记住 54 个知识点更有用:
第一部分·地基(第 1–3 章):让引擎「转起来」的能力。 这部分回答的是「引擎的骨架长什么样」。分层决定了谁能看见谁,主循环决定了「一帧」是什么,子系统生命周期决定了「谁先启动」。这三章的代码量最少,但它们定下的约定会约束后面 15 章。地基打歪了,上面盖得越高越危险。
第二部分·内存与句柄(第 4–6 章):让引擎「不崩」的能力。 这是全卷技术密度最高的部分,也是最能体现「引擎工程」的部分。手动内存管理、对象池、世代句柄,解决的都不是「功能」,而是「稳定性」——为什么游戏跑久了会变慢、为什么删了对象还会读到幽灵。任何要长期运行的系统,都得面对这些问题。
第三部分·容器与数学(第 7–10 章):让引擎「会算账」的能力。 容器决定了数据怎么摆,数学决定了变换怎么算。这部分看起来最「基础」,却直接决定了性能上限:一次缓存友好的遍历和一次缓存失效的遍历,差距可以是数量级(第 11 章实测)。而确定性(第 10 章)则决定了引擎能不能做回放与联机。
第四部分·数据导向与 ECS(第 11–13 章):让引擎「跑得快」的能力。 前三部分是把零件做对,这一部分是把零件摆对。缓存层级、SoA 布局、ECS 存储、查询调度——全是「同样的算法,换个数据布局就快几倍」的学问。这是现代引擎与十年前引擎最大的分野。
第五部分·并发、反射与综合(第 14–18 章):让引擎「能扩展」的能力。 Job System 与任务图让引擎吃满多核;反射与序列化让引擎能被编辑器编辑、能存档;最后的装配把一切拼起来。这部分的主题是「规模化」——单机能跑不算数,能跑满、能被工具用、能保存下来才算。
五种能力层层递进:转得起来 → 不崩 → 会算账 → 跑得快 → 能扩展。跳过任何一层,上面的层都会塌。
值得强调的是,这五种能力的实现难度是倒着排的:第一、二部分看似最枯燥,却最难做对——内存与句柄的 bug 藏在运行几小时之后,单元测试很难覆盖。而第五部分的并发、反射,虽然概念复杂,但错了往往立刻崩、立刻能查。真正难的不是写出来,是让它在长时间运行下依然正确。 这也是本卷把最多篇幅给了内存与句柄的原因。
这些零件是怎么拼在一起的
18.1 的 Kernel 只 import 了四个模块,却串起了整本书。原因在于依赖方向:每一层只认识它下面的层,所以装配时不需要知道上面有什么。把最终的模块图摊开,就是第 1 章那张分层图的落地版:
src/
├── platform/ 平台抽象层:时间、文件、线程、日志 (第 3 章)
├── core/ 内核层:本卷的主战场,不依赖 GPU、不依赖玩法
│ ├── arena / object-pool / memory-tags (第 4–5 章)
│ ├── slotmap / sparse-set (第 6 章)
│ ├── dyn-array / hash-map / bitset / ring-buffer (第 7 章)
│ ├── vec3 / matrix / quaternion / curve (第 8–9 章)
│ ├── fixed-point / numeric (第 10 章)
│ ├── world / component-store / query (第 11–13 章)
│ ├── job-system / task-graph (第 14–15 章)
│ ├── type-registry / serializer (第 16–17 章)
│ └── kernel.ts ← 第 18 章的装配点
├── render/ 渲染层:卷二《渲染引擎》在这里展开
├── subsystem/ 子系统层:动画 / 物理 / 音频 / AI
└── game/ 游戏层:《微光岛》玩法
注意 core/ 下面这一长串模块,没有一个 import 了 render/。这不是巧合,是第 1 章那条「依赖只能向下」的铁律一路守下来的结果:内核层的每一块都能在 Node 里无窗口跑单测,这正是本卷每一节 L1 代码能真跑的原因。等到卷二往 render/ 里加 GPU 代码,内核一行都不用改。
装配的顺序也值得记一笔。Kernel 的构造函数里,零件是按依赖关系依次创建的:先 Arena(帧内存),再 World(用 SlotMap),再 SubsystemRegistry,最后 FixedStepLoop。谁用谁就后创建——这是一条比「按章节顺序」更本质的排序。
这张图还藏着一个可以自查的信号:如果你在自己的项目里发现 core/ 某个模块 import 了 render/ 或 game/,那一定是分层破了。 这条边界不靠自觉,靠第 1 章那个 AllowedImports 类型在编译期挡住。引擎越写越大,边界越容易松,能在编译期挡住就别靠人记住。
内核与渲染的接缝:三个具体例子
「内核与渲染的接缝」这句话听着抽象,落到代码上是三个很具体的场景。提前想清楚它们,卷二就不会迷路:
场景一:实体怎么变成绘制命令。 内核里的 World 只存 SlotMap<Entity>,实体身上挂着 Transform 等组件。渲染层每帧要做的事是:查询「带 Transform + Mesh + Material 的实体」,把它们的变换和资源句柄打包成一份绘制列表。注意渲染层不遍历 ECS 内部数组,它走的是第 13 章的查询接口——内核决定怎么存,渲染决定怎么画。
场景二:句柄怎么传到 GPU。 内核里网格、材质、纹理都用第 6 章的世代句柄引用。渲染层拿到句柄后,用它去自己的资源表里查 GPU 资源(buffer、texture view)。世代位在这里再次发挥作用:如果某个网格被卸载了,旧句柄会在渲染层查表时立即失效,而不是把已释放的 GPU buffer 绑上去导致花屏或崩溃。
场景三:帧内怎么同步。 第 15 章讲过「与主循环、渲染线程的同步」。内核每帧推进逻辑后,在帧边界把「本帧的世界快照 + 绘制列表」交给渲染线程;渲染线程拿着上一帧的数据画,主线程同时跑下一帧逻辑。这个双缓冲的交接点,就是内核与渲染之间唯一的强同步点——把它设计对了,逻辑和渲染就能各自跑满。
三个场景合起来是一句话:内核负责「世界是什么」,渲染负责「世界看起来怎样」。二者之间只传数据和句柄,不传对象引用、不共享可变状态。
一条从零到能跑的路
如果你打算照着这本书给自己的项目搭内核,最该带走的是施工顺序,而不是某一段代码。本卷 18 章的排序本身就是一份路线图,可以概括成四步:
- 先让它转起来(第 1–3 章)。主循环 + 子系统生命周期 + 平台抽象。此时引擎还不能做任何事,但它有了「一帧」这个单位。
- 再让它不崩(第 4–6 章)。内存分配、对象池、世代句柄。这三章解决的是「越跑越慢、删了还读到」这类最难查的 bug。
- 然后让它够用(第 7–13 章)。容器、数学、数据导向布局、ECS。到这里引擎才真正「会算账」:能组织世界、能批量更新、能查询。
- 最后让它能扩展(第 14–17 章)。并发、任务图、反射、序列化。这四章决定了引擎能不能跑满多核、能不能被编辑器编辑、能不能存档。
每一步都能独立跑通。你可以在任何一步停下,把当前成果接到自己的项目里——这正是第 1 章说的「先最小可跑,再逐章长大」。不要一上来就写渲染,那会让你在没有地基的情况下反复推倒重来。
每一步还应该有一个「验收标准」,否则你很容易以为自己走完了,其实没有:
| 阶段 | 验收标准 |
|---|---|
| 转起来 | 能稳定跑 60 帧,打印出每帧耗时,暂停/恢复不崩 |
| 不崩 | 长时间运行堆增量平稳,删除对象后旧引用读不到东西 |
| 够用 | 能组织上千个实体、批量更新、按组件查询 |
| 能扩展 | 能存一局、读回一局;任务能并行跑在多个核上 |
这些验收标准其实就是本卷各节的「本机实测」在演示的东西。照着自己的项目重跑一遍,你就知道自己的内核到哪一步了。
从内核到画面:进入卷二《渲染引擎》
内核跑通了,但它现在是个「盲人」——它知道方块在哪,却不知道方块长什么样。把这份数据变成像素,是卷二《渲染引擎》的任务。
这一卷和卷二的关系,不是「上半本 / 下半本」,而是「地基 / 楼」。卷二里的每一行渲染代码,几乎都能在内核里找到它依赖的那根桩:渲染图的拓扑排序靠第 3 章的思路、资源引用靠第 6 章的句柄、顶点上传靠第 4.3 章的布局、跨线程同步靠第 15 章的同步点。所以读卷二时,你会不断回头翻卷一——这不是负担,恰恰说明地基打对了。
卷二会在 src/render/ 里接出真正的画面,主要补上这几块:
| 卷二要建的 | 它接在内核的哪一处 |
|---|---|
| GPU 抽象与设备管理 | 平台层的 PAL 之上,新增 render/device |
| 渲染图与 Pass 编排 | 读取内核的 World,把实体转成绘制命令 |
| 相机、视口与变换 | 复用第 8–9 章的矩阵与四元数 |
| 网格、材质与光照 | 消费第 4.3 章的顶点布局、第 7 章的容器 |
| 资源加载与句柄化 | 复用第 6 章的世代句柄做资源引用 |
| 渲染线程同步 | 复用第 15 章的跨线程同步点 |
内核与渲染的接缝只有一句话:内核产出「数据 + 句柄」,渲染消费它们。渲染层不认识 ECS 内部怎么存,它只拿到一份「这个句柄、这个变换、这个网格」的清单;内核也不知道 GPU 长什么样,它只把数据准备好。这条缝越清晰,两边就越能各自演进。
卷二也会第一次用上 L2 层——需要 headless 浏览器才能验证的代码。本卷一节 L2 都没有,这不是缺陷,而是「内核不依赖 GPU」这条设计换来的红利:你可以在 Node 里把内核跑通、测透,再往上叠渲染。
想提前补渲染原理的读者,可以先去站内的图形学专题 把管线、着色器与 GPU 基础过一遍——那些文章讲「为什么这么画」,卷二讲「怎么把内核的数据画出来」。两者不重复,配合着读最省力。
卷二《渲染引擎》会讲什么
为了让你对接下来的路有个预期,把卷二的主线提前交个底。它大致会沿这条线展开:
- GPU 抽象与设备管理。在平台层之上包一层设备接口,把 WebGPU 的设备、队列、交换链管起来,对应内核里 PAL 的写法。
- 渲染图(render graph)。把一帧的绘制拆成若干 Pass(阴影、不透明、透明、后处理),声明它们的读写依赖,自动排布执行顺序——这套「依赖 + 拓扑」的思路,正是第 3 章子系统排序和第 15 章任务图的复用。
- 网格、材质与着色器。把第 4.3 节的顶点布局真正传上 GPU,用第 7 章的容器管理材质表。
- 相机、视口与剔除。复用第 8–9 章的矩阵与四元数,做视锥剔除、批量排序。
- 资源加载与热重载。用第 6 章的句柄做资源引用,用第 16–17 章的反射与序列化做资源清单。
- 渲染线程与同步。把第 15 章的跨线程同步点落到「逻辑线程 ↔ 渲染线程」的真实边界上。
你会注意到,卷二几乎每一块都在复用卷一已经打好桩的能力:拓扑排序、句柄、容器、矩阵、反射、同步。这就是为什么卷一要花 18 章打地基——卷二不是从零开始,而是站在地基上起楼。
还有一点要提前说:卷二会大量使用 L2 层代码——需要 headless 浏览器才能验证。本卷一节 L2 都没有,不是缺陷,是「内核不依赖 GPU」换来的红利:你能在 Node 里把内核跑通、测透,再往上叠画面。
一个容易被忽略的事实:内核决定上限
很多人以为引擎的「快」主要来自渲染优化——少画点、用更好的着色器。这个直觉只对了一半。渲染决定的是「这一帧画得够不够聪明」,而内核决定的是「这一帧有没有可能快」。两者的关系是上限与达成:
- 内存布局决定缓存命中率。同样遍历一万个物体,AoS 和 SoA 的差距来自内核怎么摆数据(第 11 章),渲染再优化也补不回来。
- 分配策略决定 GC 抖动。每帧
new一堆临时对象,帧时间就会有规律地出现尖刺(第 4、5 章)。渲染把每个 draw call 都优化到极致,也救不了每秒一次的 GC。 - 并发架构决定能吃到几个核。Job System 和任务图(第 14、15 章)决定了引擎能不能把物理、动画、剔除分摊到多个核上。渲染单线程再快,也只是一个核的上限。
- 句柄设计决定资源生命周期。句柄安全(第 6 章)决定了资源能不能被安全地卸载与热重载,进而决定了引擎能不能做热更新。
所以本卷花 18 章讲这些「看不见的东西」,不是舍近求远。画面是内核能力的可视化,不是内核能力的替代品。 一个渲染平庸但内核扎实的引擎,能通过换渲染器变强;一个渲染华丽但内核松散的引擎,遇到规模就会崩。
这也是为什么卷二《渲染引擎》会建立在卷一的内核之上,而不是另起炉灶。你在这 18 章里打的每一根桩,都是卷二起楼的地基。
你手上现在有什么
合上书之前,清点一下这一卷真正交到你手里的东西:
- 一台能跑的内核。
helio的src/core,纯 TypeScript、零运行时依赖,node直接跑。 - 一套可复现的工程判断。为什么 arena 比
new好、为什么句柄比指针安全、为什么 SoA 有时更快、为什么 Job System 要配 work stealing——每一条结论背后都有本机实测。 - 一个确定性的 demo。18.2 里那个方块,同机跑两遍逐位相同,它是回放、存档、联机、测试四条路的最小样板。
- 一张可复用的施工顺序表。18 章的排序,就是一份「从零搭引擎」的施工图。
如果你现在打开一个空目录,能照着自己的记忆把主循环、arena、slot map 这三样写个大概,那这一卷就没有白读。检验懂没懂的最好方式,是关掉书,凭记忆写一遍。 写不出来的地方,就是你还没真正吸收的地方。
最值钱的其实是第二项。代码可以重写,但那套「看到性能问题就知道往哪查、看到引用失效就知道该用句柄」的判断力,是换任何引擎、任何语言都带得走的。本卷所有性能数字都是同机相对量级(原始数据见附录 C),请把重点放在方向,而不是倍数。
三份可以带走的清单
如果你时间有限,读不透全部 54 节,下面三份清单能帮你抓重点。
清单一:时间不够时的必读节。 这六节是「内核最小可理解集」,读完它们你就抓住了引擎的主干:
| 节 | 为什么必读 |
|---|---|
| 2.2 固定步长、累加器与渲染插值 | 主循环是引擎的心脏,固定步长是它的节拍器 |
| 3.1 子系统注册、启动顺序与逆序关闭 | 引擎「由哪些部分组成」的组织方式 |
| 4.2 线性 arena 与栈式分配器 | 手动内存管理的入门,后面所有性能结论的地基 |
| 6.2 slot map 与世代句柄 | 「引用为什么会失效、怎么修」的完整答案 |
| 12.2 组件存储:稀疏集与 archetype | ECS 的核心:数据怎么存 |
| 18.1 内核装配 | 把前面所有零件拼起来的那一刻 |
清单二:把 src/core 搬进自己项目时,优先复用的模块。
arena.ts/object-pool.ts:任何需要「高频创建销毁」的场景都能直接换用。slotmap.ts/sparse-set.ts:任何「ID 引用对象、对象会被删除」的场景,用它们替代裸索引或对象引用。fixed-step-loop.ts:任何需要「逻辑与渲染解耦」的场景,直接拿走。subsystem.ts:任何有「多个子系统、要按依赖启动」的场景,拓扑排序现成可用。
清单三:全书反复出现的核心术语。 这几个词在不同章节反复出现,理解它们的准确含义是读懂全卷的钥匙:
- 句柄(handle):一个带世代的整数,用来安全地引用可能被删除的对象(第 6 章)。
- arena:一块只前进、不单独回收的内存,帧末一次性清空(第 4 章)。
- 固定步长(fixed step):物理永远以固定时间单位推进,与真实帧率解耦(第 2 章)。
- 数据导向(data-oriented):先想数据怎么摆,再想算法怎么写(第 11–13 章)。
- 确定性(determinism):同样输入必得同样输出,是回放与联机的前提(第 10、18 章)。
常见疑问
收尾前,把读者最常问的几个问题一次答清。
「内核这么多章,能不能只挑几章读?」 可以,但要小心跳过哪几章。第 4 章(内存)和第 6 章(句柄)不建议跳——跳过它们,第 11、12、13 章的实测数字会变成一堆没有根据的结论。第 7 章(容器)、第 9 章(曲线)相对独立,按需跳读问题不大。目录篇给了三条阅读路径,照着走最省事。
「不用 TypeScript,用 C++/Rust 写内核行吗?」 完全可以,而且更常见。本卷选 TypeScript 是为了「每一节都能在 Node 里真跑」,让机制看得见。所有原理——arena、句柄、ECS、任务图——跨语言成立。你换成 C++ 时,唯一要重新考虑的是「JS 有 GC,所以手动内存的收益没那么大」这类语言特性差异。
「内核写完了,为什么不直接接渲染?」 因为渲染卷会让内核的一些设计缺陷暴露得很晚。比如句柄设计不对,资源卸载时会崩溃;内存布局不对,上传 GPU 时每帧都要重新打包。先把内核跑通、测透,再接渲染,返工成本最低。
「本卷的代码能直接用在生产里吗?」 src/core 的 L1 代码是完整的、测过的,可以直接搬。但生产环境还需要错误处理、跨平台测试、性能回归等工程化工作——本书给的是「能跑通、能理解」的参照实现,不是开箱即用的库。
「为什么不直接用现成的 ECS 库?」 用现成的没问题,但你就学不到「为什么」。现成的库帮你做了存储布局、查询缓存、调度,恰恰是本卷最想讲清楚的部分。先用 helio 自己写一遍,你才会在看别人库的文档时,一眼看出它在哪些点上做了取舍。
「读完内核,最该练什么?」 练「对照」:拿一个你熟悉的引擎,问自己「它的内存管理对应本卷哪一章、它的实体系统对应哪一章」。能建立这张对照表,说明你真的把机制内化了,而不是记住了一堆 API。
「内核和渲染到底哪个更重要?」 都重要,但次序不能反。渲染决定「画面好不好看」,内核决定「引擎撑不撑得住」。先有能撑住的内核,渲染才有发挥空间;反过来,再漂亮的渲染也救不了一个每帧都在 GC 的底座。
怎么继续用这份内核
最后给三条落地建议,供你离开这本书之后接着走:
- 接自己的项目:把
src/core整个拷进你的工程,先从第 2 章的固定步长循环和第 3 章的子系统注册表用起。这两块改动成本最低、收益最直接。 - 当参考实现查:遇到「对象池该开多大」「哈希装载因子取多少」「查询怎么缓存」这类问题时,回到对应章节看 helio 怎么做的,再按自己的场景调整——本书给的是方法,不是唯一答案。
- 等卷二:《渲染引擎》会在这套内核上接出真正的画面。在那之前,你可以先把内核里的
RenderSubsystem从「数实体」换成任何你想要的占位逻辑,接口不用动。 - 回头重读目录篇:本卷目录篇 里的三条阅读路径与「常见问题速查」,在你已经有实践之后再读一遍,会有完全不同的体会。
这三条之外,还有一条隐性的建议:把本卷的「实测」习惯带走。遇到性能问题先写个基准、遇到可疑行为先写个最小复现,而不是凭直觉改代码。引擎工程里,直觉经常是错的——你以为是算法慢,其实是缓存没命中;你以为是缓存没命中,其实是每帧都在分配。
写在最后
回头看,这一卷想教的东西,其实可以用一句话概括:把「引擎为什么这么做」从黑盒变成你能亲手复现的东西。
你可能用过很多引擎,知道它们「能做什么」,但不知道它们「为什么这么做」。当你遇到一次诡异的掉帧、一个删了还能读到的对象、一段在不同设备上跑出不同结果的物理,你只能猜。本卷的每一章,都在把这类「只能猜」的时刻变成「能解释、能复现、能修」的时刻——arena 解释了掉帧、世代句柄解释了幽灵对象、定点数解释了跨端差异。
这也是为什么全书坚持「每一节的代码都在本机真跑过」。引擎原理是很容易被讲成「大概是这样」的领域,而「大概」在引擎里往往意味着「差一个数量级」或者「偶尔崩溃」。真跑出来的数字不会骗人:SoA 就是比 AoS 快,堆增量就是能从 60 MB 掉到 0,方块就是能逐位复现。这些结论你可以怀疑、可以在自己机器上重跑——这正是它们比任何断言都可靠的原因。
内核到此为止。它不漂亮,没有画面,跑起来只在控制台吐几行坐标。但它是真的:真的能编译、真的能跑、真的能复现。一个能跑的内核,胜过一张漂亮的架构图。接下来,该让它看见光了。
阅读导航:上一节:18.2 《微光岛》第一个能走动的方块 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。