《游戏引擎实现原理》18.3 回顾与路线:进入渲染卷

卷一《引擎内核》的收尾:逐章回顾 18 章各自解决了什么问题、它们如何拼成一台能跑的引擎,给出从零到能跑的施工顺序与各阶段验收标准,并说明进入卷二《渲染引擎》的路线——GPU 抽象、渲染图、相机与资源,以及内核与渲染之间那道接缝的三个具体例子。

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. 先让它转起来(第 1–3 章)。主循环 + 子系统生命周期 + 平台抽象。此时引擎还不能做任何事,但它有了「一帧」这个单位。
  2. 再让它不崩(第 4–6 章)。内存分配、对象池、世代句柄。这三章解决的是「越跑越慢、删了还读到」这类最难查的 bug。
  3. 然后让它够用(第 7–13 章)。容器、数学、数据导向布局、ECS。到这里引擎才真正「会算账」:能组织世界、能批量更新、能查询。
  4. 最后让它能扩展(第 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 基础过一遍——那些文章讲「为什么这么画」,卷二讲「怎么把内核的数据画出来」。两者不重复,配合着读最省力。

卷二《渲染引擎》会讲什么

为了让你对接下来的路有个预期,把卷二的主线提前交个底。它大致会沿这条线展开:

  1. GPU 抽象与设备管理。在平台层之上包一层设备接口,把 WebGPU 的设备、队列、交换链管起来,对应内核里 PAL 的写法。
  2. 渲染图(render graph)。把一帧的绘制拆成若干 Pass(阴影、不透明、透明、后处理),声明它们的读写依赖,自动排布执行顺序——这套「依赖 + 拓扑」的思路,正是第 3 章子系统排序和第 15 章任务图的复用。
  3. 网格、材质与着色器。把第 4.3 节的顶点布局真正传上 GPU,用第 7 章的容器管理材质表。
  4. 相机、视口与剔除。复用第 8–9 章的矩阵与四元数,做视锥剔除、批量排序。
  5. 资源加载与热重载。用第 6 章的句柄做资源引用,用第 16–17 章的反射与序列化做资源清单。
  6. 渲染线程与同步。把第 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 组件存储:稀疏集与 archetypeECS 的核心:数据怎么存
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 《微光岛》第一个能走动的方块 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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