10.1 引擎实现原理全景回顾
四卷 60 章到这里走完了。这不是一本讲某个 API 的书,而是把「一个能跑的引擎由哪些原理构成」拆成可运行的小块,逐块实现:内核的内存、句柄、容器、数学、数据导向、ECS、任务与反射,渲染的 RHI、资源、场景、剔除、排序、材质、光照、帧图、后处理与多线程,子系统的资源管线、动画、物理、音频、输入、脚本与编辑器,最后是工程的构建、测试、性能、跨端、裁剪与调试。
为什么值得在收官处回头看一眼?因为原理是散的,引擎是整体的。内存布局决定 ECS 遍历多快,句柄安全决定序列化怎么处理引用,反射决定编辑器能不能自动生成属性面板,任务图决定渲染线程和主线程怎么在帧边界上同步——这些跨卷的因果链,只有把 60 章摆在同一张桌子上才看得清。单独读内存那一章,你会以为它只是讲分配器;放进整机,它是 ECS、资源系统、帧内存的共同地基。
一个常见的误解是:引擎的「难」在于算法。其实引擎里用到的算法大多是教科书内容——快速排序、AABB 求交、四元数插值、拓扑排序,没有一个是新发明的。真正难的是接缝:内存分配器怎么和 ECS 的组件存储对齐,资源系统怎么和渲染的 GPU 生命周期对上,反射注册表怎么同时喂给序列化、脚本绑定和编辑器面板。这本书按「内核 → 渲染 → 子系统 → 工程」的顺序组织,恰恰就是一个引擎从下往上搭的顺序:先有地基(内存与数据结构),再有主干(渲染管线),然后长出枝叶(各子系统),最后打包交付(工程化)。
这一节不写任何新原理,只做三件事:给出一张按「内核 → 渲染 → 子系统 → 工程」组织的原理全景图,每一章都能点进去;把书里的原理与站内既有文章对照一遍;再用一个本机可跑的小工具,把 helio 的真实体量盘成数字——187 个模块、16349 行,是四卷落下来的实际规模,不是估计。
需要说清一个词:「原理」在这里不是指物理定律或数学定理,而是指引擎里反复出现、且必须做对的那一类结构性决策。为什么要用世代句柄而不是裸指针,为什么组件要按 archetype 摆而不是按对象摆,为什么渲染要经过一层 RHI 抽象而不是直接调 WebGPU——这些决策没有唯一正确答案,但每一个都决定了引擎能不能在规模上来之后还站得住。全书讲的,就是这些决策的动机、代价和落地方式。
本章与站内既有内容的分工
站内已经有不少「原理级」和「选型级」的内容,本节和它们的边界要划清楚——本节只做「把四卷的实现串成一张地图」,不重推原理,也不替你做选型。
| 站内既有内容 | 它讲什么 | 本节做什么 |
|---|---|---|
/posts/graphics/ | 图形学的原理与工程方法:剔除、GI、体积云、水体等 | 不重推图形原理,只说明引擎如何把一条帧管线装起来 |
/posts/game/engine/
顶层综述 | 引擎选型与架构综述 | 不选型,给出本书自建引擎 helio 的完整原理地图 |
/game-engine-architecture-ecs/ | ECS 架构的通用讲解 | 把 ECS 放回整机:它与内存、句柄、任务图如何咬合 |
换句话说:讲「GI 怎么算」的去看 graphics;讲「该不该自研引擎」的去看 game/engine;而「一个引擎由哪些原理拼成、它们分别在哪一卷实现、点进去从哪读」——这是本节的事。本节是全套书稿的索引式回顾,所有链接都指向真实存在的章节。
一句话记住三者的关系:站内文章给你「为什么」,本书给你「怎么装」,而本节负责告诉你「装完之后整体长什么样」。
一个引擎由哪些原理构成:四卷全景
把 60 章按「它解决引擎里的哪个问题」重新归类,就是一张引擎的原理地图。这里刻意按卷而不是按主题排列,因为卷的划分本身就编码了一条依赖顺序:内核提供数据类型,渲染消费它们,子系统把渲染接进游戏,工程把它们全部交付出去。跨卷的主题(比如「内存」既出现在内核的分配器,也出现在渲染的 GPU 内存池)如果按主题重排,反而会打散这条链。下面按卷列出每章的职责与代表节,点进去就是那一章的入口。
这张图有三种用法。顺序通读:从内核第 1 章一路读到工程第 10 章,按依赖顺序理解每一层为什么这么设计。按问题查:手里有一个具体问题(「实体销毁后引用为什么还活着」「阴影为什么闪」),直接跳对应章节的代表节。按交付目标倒推:先确定要交付什么(首包多大、跑在哪些设备上),再顺着工程卷往回找到必须做对的那一层。三种用法对应三种读者,但底层是同一张图。
读的时候要留意一个细节:这张图里每一章的「代表节」都只列了该章的第一节,但真正决定成败的往往是章内后几节——比如内存章的代表节是「为什么要自己管内存」,而真正落地的 arena 分配器在 4.2;帧图章的代表节讲 Pass 依赖,而编译执行在 14.3。代表节是入口,不是全部。
卷一《引擎内核》· 18 章 —— 卷内目录
从「一个引擎最少要有哪些零件」起,把内存、句柄、容器、数学、数据导向、ECS、任务与反射逐块实现,最后装配成一个可运行的内核。
| 章 | 原理要点 | 代表节 |
|---|---|---|
| 1 | 分层与模块边界:平台层到游戏层 | 1.1 引擎的分层与模块边界 |
| 2 | 帧、时间与帧预算 | 2.1 帧、时间与帧预算 |
| 3 | 子系统生命周期:拓扑启动、逆序关闭 | 3.1 子系统注册与启动顺序 |
| 4 | 内存:为什么引擎要自己管 | 4.1 为什么要自己管内存 |
| 5 | 分配器:对象池与块分配 | 5.1 对象池与 freelist |
| 6 | 句柄:悬垂指针与 slot map | 6.1 为什么指针会失效 |
| 7 | 容器:动态数组、哈希、位集、环形缓冲 | 7.1 动态数组与开放寻址哈希 |
| 8 | 数学:向量与矩阵的内存布局 | 8.1 向量代数与 TypedArray 布局 |
| 9 | 旋转:欧拉角、四元数与曲线 | 9.1 欧拉角、万向节死锁与四元数 |
| 10 | 数值:浮点确定性与定点数 | 10.1 浮点确定性与跨端一致性 |
| 11 | 数据导向:缓存层级与 SoA/AoS | 11.1 缓存层级与访存代价 |
| 12 | 从继承到组合到数据 | 12.1 从继承到组合到数据 |
| 13 | ECS:查询匹配与迭代脏标记 | 13.1 查询匹配与缓存命中 |
| 14 | Job System:worker 与线程池 | 14.1 为什么 JS 也需要 Job System |
| 15 | 任务图:依赖 DAG 与帧内调度 | 15.1 依赖 DAG 与拓扑执行 |
| 16 | 反射:类型注册与属性描述符 | 16.1 引擎为什么需要反射 |
| 17 | 反射驱动的序列化与版本迁移 | 17.1 反射驱动的序列化 |
| 18 | 内核装配:把前 17 章拼成引擎 | 18.1 内核装配 |
卷二《渲染引擎》· 18 章 —— 卷内目录
把内核产出的场景数据一路画到屏幕上:先定义 RHI 抽象,再实现资源、场景、剔除、排序、材质、光照,最后用帧图把 Pass 串起来,装配成一个能跑的渲染器。
| 章 | 原理要点 | 代表节 |
|---|---|---|
| 1 | 一帧的 CPU / GPU 时间线 | 1.1 一帧的 CPU / GPU 时间线 |
| 2 | RHI:为什么要抽象 | 2.1 为什么需要 RHI |
| 3 | 命令编码器与 render pass | 3.1 命令编码器与 render pass |
| 4 | 资源绑定与 GPU 内存 | 4.1 bind group 与资源绑定 |
| 5 | 多后端:WebGPU 与 WebGL2 | 5.1 WebGPU 后端 |
| 6 | 顶点格式与纹理资源 | 6.1 顶点格式与程序化网格 |
| 7 | 场景图、变换与相机 | 7.1 场景图与变换层级 |
| 8 | 视锥剔除、BVH 与 LOD | 8.1 视锥剔除与包围体 |
| 9 | 排序键与实例化批次 | 9.1 排序键与前后绘制 |
| 10 | 材质模型与着色器变体 | 10.1 材质模型与参数 |
| 11 | WGSL 组织与反射驱动绑定 | 11.1 WGSL 组织与 include |
| 12 | 光照模型与光源类型 | 12.1 光照模型与着色路径 |
| 13 | 阴影贴图与级联阴影 | 13.1 阴影贴图的引擎侧实现 |
| 14 | 帧图:Pass 依赖与资源生命周期 | 14.1 Pass 依赖与资源生命周期 |
| 15 | 全屏 Pass 链与后处理 | 15.1 全屏 Pass 链 |
| 16 | 2D:精灵批次、SDF 与 UI | 16.1 精灵批次与图集 |
| 17 | 多线程渲染与离屏录制 | 17.1 渲染线程与命令缓冲 |
| 18 | 渲染器装配与昼夜循环 | 18.1 渲染器装配与场景接入 |
卷三《子系统与工具》· 11 章 —— 卷内目录
给内核和渲染器接上资源管线、动画、物理、音频、输入、脚本,再往上一层层搭出编辑器:文档模型、撤销栈、属性面板与视口。
| 章 | 原理要点 | 代表节 |
|---|---|---|
| 1 | 资源模型、GUID 与依赖图 | 1.1 资源模型、GUID 与依赖图 |
| 2 | 导入器、缓存与热重载 | 2.1 资源导入器与离线处理 |
| 3 | 动画:剪辑、轨道与混合树 | 3.1 剪辑、轨道与求值器 |
| 4 | 物理集成与固定步长 | 4.1 引擎与物理库的边界 |
| 5 | 音频:混音图与总线 | 5.1 混音图与音频总线 |
| 6 | 输入:设备抽象与动作映射 | 6.1 设备抽象与事件队列 |
| 7 | 脚本绑定与热重载 | 7.1 反射驱动的属性暴露 |
| 8 | 编辑器与运行时的关系 | 8.1 编辑器与运行时的关系 |
| 9 | 命令模式与撤销栈 | 9.1 命令模式与可撤销操作 |
| 10 | 反射驱动的属性面板 | 10.1 反射驱动的属性面板 |
| 11 | gizmo、拾取与视口 | 11.1 gizmo 与拾取射线 |
卷四《工程与实战》· 10 章 —— 卷内目录
把前三卷的引擎真正交付出去:构建、测试、性能、跨端、裁剪、调试,最后把《微光岛》做完,做一次跨引擎对照。
| 章 | 原理要点 | 代表节 |
|---|---|---|
| 1 | 构建与资产管线 | 1.1 打包与产物分析 |
| 2 | 测试:单测、headless 黄金图像与确定性回放 | 2.1 内核单测与替身 |
| 3 | 性能:帧预算、GC 与 GPU 计时 | 3.1 帧预算与性能分析 |
| 4 | 跨端与降级 | 4.1 WebGPU / WebGL2 能力降级 |
| 5 | 模块化与裁剪 | 5.1 模块边界与依赖 |
| 6 | 调试工具 | 6.1 调试绘制与运行时可视化 |
| 7 | 把《微光岛》做完 | 7.1 玩法装配与存档 |
| 8 | 发布:性能预算、构建部署与上线清单 | 8.1 性能预算达标 |
| 9 | 跨引擎对照 | 9.1 单轴对照 |
| 10 | 收官:全景回顾与下一步 | 10.2 下一步:读源码、做贡献、做自己的引擎 |
这张图里最该被记住的一条因果链是:内核的存储布局决定了渲染和子系统的上限。ECS 的查询缓存(13 章)之所以能 O(1) 命中,是因为组件按 archetype 连续摆放(12 章);批量渲染之所以能一次提交上千个实例(渲染 9 章),是因为实例数据本来就是 SoA 布局(内核 11 章);编辑器的属性面板之所以能自动生成(子系统 10 章),是因为反射注册表(内核 16 章)在编译期就把类型描述符存好了。四卷不是四个独立专题,而是一条从内存一路搭到编辑器的栈。
反过来,工程卷又会自下而上地约束前面三卷:一旦「首包要小」(工程 1 章)成为交付目标,内核里那些「反正没人 import」的模块就必须能被摇掉(工程 5 章的模块边界);一旦「低端机也要跑 60 帧」(工程 3、4 章),渲染的剔除与 LOD(渲染 8 章)就不能只是正确,而必须够快。所以四卷的阅读顺序是自下而上,但理解顺序常常是自上而下:先知道要交付什么,才知道下面哪一层必须做对。
四卷之间还有一条贯穿项目把它们缝在一起——《微光岛》。内核卷在 18.2 用拼好的内核跑出第一个能走动的方块;渲染卷在 18.2 给它接上昼夜循环;子系统卷让它有动画、能被碰撞、能出声、能操作,最后能在一个编辑器里被摆布;工程卷把它打包、测试、压到低端机上、发布出去。回头看你可能已经发现:《微光岛》从头到尾都不复杂,它的作用不是「好玩」,而是给每一章的原理提供一个必须落地的真实场景。一个只在测试里跑过的分配器和一个在游戏主循环里每帧被调用上千次的分配器,是完全两回事。
如果把这张图当成一本查问题的索引,可以先按症状定位。下面这张小表把常见症状映射到最该先看的那一章——它不是完整的排错手册,只是让你知道该从四卷的哪一段下手。
| 症状 | 先看哪一章 |
|---|---|
| 帧时间抖动、偶发掉帧 | 内核第 2 章 / 工程第 3 章 |
| 实体销毁后旧引用还活着 | 内核第 6 章 |
| ECS 遍历慢、缓存命中低 | 内核第 11–13 章 |
| 任务没并行起来、多核跑不满 | 内核第 14–15 章 |
| 编辑器读不到组件字段 | 内核第 16 章 / 子系统第 7 章 |
| 存档升级后打不开 | 内核第 17 章 |
| 屏幕全黑、什么都没画出来 | 渲染第 3、5 章 |
| 纹理上传后是黑的、mipmap 有缝 | 渲染第 4、6 章 |
| 物体位置或朝向不对 | 渲染第 7 章 |
| 阴影闪烁、锯齿明显 | 渲染第 13 章 |
| 后处理把画面压灰 | 渲染第 15 章 |
| 首包太大、加载太慢 | 工程第 1、5 章 |
| 低端机跑不到目标帧率 | 工程第 3、4 章 |
原理、章节与站内文章对照
本书讲的是「怎么把原理实现成一个引擎」,站内 graphics 与 game 专题讲的是「这些原理本身」和「怎么选型」。下面把两条线对一下:左边是原理,中间是本书的实现章节,右边是站内更偏原理或综述的文章。两边不重复,各看各的深度。
| 原理 | 本书章节 | 站内既有文章 |
|---|---|---|
| 帧图与 Pass 调度 | 渲染第 14 章 | /graphics-render-graph/ |
| 抗锯齿与全屏后处理 | 渲染第 15 章 | /graphics-anti-aliasing-methods/ |
| 色彩管理与 HDR 管线 | 渲染第 15 章 | /graphics-color-management-hdr-pipeline/ |
| GPU 性能分析与调试 | 工程第 3 章 | /graphics-gpu-profiling-debugging/ |
| ECS 架构与数据布局 | 内核第 12–13 章 | /game-engine-architecture-ecs/ |
| 渲染管线总论 | 渲染第 1 章 | /posts/graphics/ |
| 引擎选型与架构综述 | 全书 | /posts/game/engine/ |
这张对照表也划清了本书的边界:站内文章能给你某一条管线的深度,本书给你的是这条管线在整机里的位置和一份能跑的参考实现。想深挖某条管线,去读对应的站内文章;想知道它怎么和别的部分接起来,回到本书对应的章节。
也要诚实地说,本书不覆盖某些很大的题目——地形与开放世界流式、网络同步、光线追踪与全局光照、体积云与水体。它们不是不重要,而是每一个都够写一整本书,且站内 graphics 与 game 专题已经有专门的文章。这些「边界外」的话题会在下一节 10.2 里逐条列出并给出外链。
可运行产物:把 helio 盘一遍
前面是地图,这一节把地图落到数字上。helio 的源码在 src/ 下,按顶层目录分成 core(内核)、render(渲染)、ecs、math、game、generated 与 apps。下面这个 L1 脚本 tools/engine-inventory.mjs 扫出每个领域的模块数与行数——它不执行引擎代码,只统计文件。
为什么用静态盘点而不是运行时统计?因为运行时统计只能看到「这一帧走到了哪些模块」,看不到全仓库的规模;而收官回顾要的恰恰是总量。静态扫描还顺带满足一个约束:它不依赖 GPU、不依赖浏览器,一条 node 命令就能复现,正是本书对 L1 的定义。
// file: tools/engine-inventory.mjs
// tier: L1
// verify: node tools/engine-inventory.mjs
//
// 全书收官盘点:把 src/ 下的引擎源码按顶层目录扫一遍,输出每个领域
// 有多少模块、多少行。数字全部来自真实文件,不估、不猜。
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
function scan(dir) {
let modules = 0;
let lines = 0;
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const full = join(d, name);
if (statSync(full).isDirectory()) walk(full);
else if (name.endsWith('.ts') && !name.endsWith('.test.ts')) {
modules += 1;
lines += readFileSync(full, 'utf8').split('\n').length - 1;
}
}
};
walk(dir);
return { modules, lines };
}
const ROOT = 'src';
const areas = readdirSync(ROOT).filter((n) => statSync(join(ROOT, n)).isDirectory()).sort();
let totalModules = 0;
let totalLines = 0;
console.log('== helio 引擎源码盘点(不含 *.test.ts)==');
for (const area of areas) {
const { modules, lines } = scan(join(ROOT, area));
totalModules += modules;
totalLines += lines;
console.log(`${area.padEnd(10)} 模块 ${String(modules).padStart(4)} 行 ${String(lines).padStart(6)}`);
}
console.log(`合计 模块 ${String(totalModules).padStart(4)} 行 ${String(totalLines).padStart(6)}`);
node tools/engine-inventory.mjs 在本机的真实输出:
== helio 引擎源码盘点(不含 *.test.ts)==
apps 模块 8 行 387
core 模块 67 行 5222
ecs 模块 4 行 346
game 模块 2 行 34
generated 模块 1 行 29
math 模块 3 行 161
render 模块 102 行 10170
合计 模块 187 行 16349
数字和四卷的篇幅分布高度吻合。render 102 个模块 10170 行、core 67 个模块 5222 行,两者相加占了全仓库近九成的模块与行数——对应卷二、卷一共 36 章的主体;ecs 与 math 是内核里独立出来的数据导向与数学部分;apps 只有 8 个模块 387 行,因为示例程序天生短小;generated 是反射代码生成器吐出来的注册表,只有 1 个文件,恰好说明「显式注册表 + 代码生成」(内核 16.3)把该自动化的部分都自动化了。全仓库 187 个模块、16349 行,就是这套书稿四卷 60 章落地的真实体量。
这个规模对一个「教学引擎」是合适的:187 个模块意味着每一块都还能被一个人读懂,而 16349 行的体量又足以暴露真实引擎会遇到的问题——内存对齐、句柄失效、资源循环依赖、着色器变体爆炸,一个都不少。它不追求像 Unreal 那样几千万行,但求每一行都能讲清「为什么这么写」。
上面这个脚本只统计了非测试模块。测试模块单独还有 99 个文件、6388 行,占了全部 .ts 文件的三分之一还多——这个比例本身就是工程卷的态度:第 2 章讲的那套「内核单测 + headless 黄金图像 + 确定性回放」,不是写完之后补的,而是从内核第一块代码起就长在旁边的。
光有静态盘点还不够,再看一眼装配后的引擎能不能真的转起来。node src/apps/kernel-boot.ts 把内核启动一遍,本机某次输出(毫秒数随机器与运行而异,结构与数值范围稳定):
[helio] boot order: Time -> Move -> Render
[helio] frame 1: steps=1 | Time 0.011ms Move 0.058ms Render 0.015ms
[helio] frame 2: steps=1 | Time 0.001ms Move 0.004ms Render 0.002ms
[helio] frame 3: steps=1 | Time 0.001ms Move 0.004ms Render 0.021ms
[helio] frame 4: steps=1 | Time 0.003ms Move 0.007ms Render 0.003ms
[helio] cube after 4 frames: x=0.1 z=-0.05
这一小段输出里同时活着三条原理:boot order: Time -> Move -> Render 是子系统按依赖拓扑排序启动(内核 3.1);steps=1 说明固定步长累加器在 1/60 秒的输入下每帧推进一个物理步(内核 2.2);方块 4 帧后停在 x=0.1 z=-0.05,是速度 vx=1.5、vz=-0.75 被 Move 子系统按 dt 积分出来的结果,而句柄 cube.handle 在 4 帧后依然有效,靠的是 slot map 的世代校验(内核 6.2)。60 章里这些机制各自成篇,在这里它们第一次同时在同一个内核对象上生效。
值得注意的是那些毫秒数:Time、Move、Render 三个子系统每帧的耗时都在 0.001ms 到 0.058ms 之间,第一帧偏高是模块首次加载与 JIT 预热的结果,第二帧之后就稳定在微秒级。这个量级本身没有意义(实体只有 1 个),有意义的是内核已经把逐子系统的耗时如实报出来了——工程卷的性能工作(第 3 章)正是建立在「内核能报出每一段花了多少时间」这个前提上。没有这个接口,性能优化就无从下手。
这个「把耗时如实报出来」的能力,其实从内核卷第 2 章设计帧预算时就已经埋下了:帧预算不是事后加的性能面板,而是主循环接口的一部分。这也是为什么这本书反复强调「接口设计先于实现」——一个能在运行时自报家门的内核,和一个只能靠外部打点猜测的内核,后续能做的事完全不同。
如果把两个实验放在一起看,会发现一个有意思的对照:静态盘点数出 187 个模块,而内核启动只拉起其中很小的一部分。这不是矛盾,而是「按需加载」的正常结果——回顾 1.1 节的模块图分析,《微光岛》的可玩 demo 从入口出发只静态可达 6 个模块。一个引擎的全部代码和某个具体程序实际用到的代码本来就是两个数量级,理解这个差别,是理解「为什么首包可以很小」的起点。
这就是全景回顾的意义:单看每一章都只是「一个数据结构的实现」,摆在一起才是「一个引擎」。下一节把地图收起来,给你几条能真正走下去的路——去读真实引擎的源码、去给开源项目提第一个 PR、或者按这本书的路线做自己的引擎。
阅读导航:上一节:回到目录 · 下一节:10.2 下一步:读源码、做贡献、做自己的引擎 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。