1.2 本书范围与站内分工
上一节画了 helio 的分层图,这一节回答一个更现实的问题:这本书和站内已有的几百篇文章,到底谁写什么? 如果搞不清分工,读者会在「渲染原理」和「引擎实现」之间反复横跳,作者也会把别人讲透的内容再抄一遍。本节的立场很简单——别人讲过的「是什么、怎么选」,我们全部外链出去;本书只写「怎么从零搭出来、跑出什么结果」。
这不是偷懒,而是对读者负责。你花钱买的是「从零实现」这一块,不是「引擎概论」的第十个版本。把边界划清楚,双方都省事。
本卷写什么,不写什么
卷一《引擎内核》只覆盖一件事:一个 3D 引擎在「不碰 GPU、不碰玩法」的那一半里,需要哪些零件,以及这些零件怎么从零实现。具体包括:
- 写:时间步与帧预算、子系统生命周期、平台抽象、手动内存管理、句柄与对象池、基础数据结构、数学(向量/矩阵/四元数)、定点数与确定性、缓存友好与 SoA、ECS、任务系统、反射与序列化。
- 不写:光栅化、着色器、渲染图、GPU 资源管理、材质系统——这些属于渲染卷。也不写引擎选型、商业引擎对比、美术管线、发行运营。
- 只点到为止:凡是「为什么要这样做」的理论,本卷给一句话结论 + 一个外链,不展开。
- 写的是「内核」:引擎里「不依赖 GPU、不依赖玩法」的那一半。跨平台的窗口、输入、文件读取属于平台层,本卷也写;但只写到「够内核跑起来」为止。
- 不写的是「外围」:编辑器、构建管线、资源热更、打包发布,这些属于工具链,本卷不碰。
贯穿项目是低多边形 3D 收集游戏**《微光岛》**(glimmer-isle)。每一章的新零件都会往这个小项目里加一块,到第 18 章它应该能跑起来。
18 章大致按「从地基到屋顶」排列,你可以把它当成一条依赖链读:
- 第 1 章 引擎是什么:分层、范围、最小零件(也就是本章)。读完你会得到一个能打印前三帧日志的骨架。
- 第 2–3 章 时间与平台:帧预算、固定步长、时间缩放;子系统生命周期、平台抽象、日志与 cvar。产物是一个跨 Node 与浏览器的
Platform接口。 - 第 4–5 章 手动内存:为什么要自己管内存、线性 arena、对齐;对象池 freelist、块分配器、内存标签与预算。产物是一套能在 typed array 上分配与回收的分配器,并附真实基准。
- 第 6 章 句柄:悬垂指针问题、slotmap 代际句柄、sparse set 回收。产物是一个「删了再访问会失败」的安全句柄系统。
- 第 7 章 基础数据结构:动态数组与哈希、bitset 与环形缓冲、字符串驻留与 GUID。产物是内核自己的一小套容器。
- 第 8–10 章 数学:向量矩阵布局与约定、变换合成与求逆;欧拉角/四元数/slerp/曲线;浮点确定性、定点数、数值鲁棒性。产物是一个可编译、带数值断言的数学库。
- 第 11 章 缓存与布局:cache 与内存访问、SoA/AoS/AoSoA、把 OOP 重构为批量处理。产物是一次「同语义、不同布局」的性能对比。
- 第 12–13 章 ECS:继承 vs 组合、组件存储、实体生命周期;查询匹配缓存、迭代与脏标记、系统依赖调度。产物是《微光岛》能用的 ECS。
- 第 14–15 章 任务系统:为什么要 job system、线程池与任务队列、work stealing;依赖 DAG、帧任务图、主循环与渲染同步。产物是一个能并行跑一批任务的调度器。
- 第 16–17 章 反射与序列化:为什么需要反射、类型注册表、显式注册与 codegen;反射驱动的序列化、引用与环、版本迁移。产物是一个能存能读、能升级版本的存档系统。
- 第 18 章 组装:把内核拼成一个整体,《微光岛》渲染出第一个立方体,然后交棒给渲染卷。
这条路线本身也是一条「依赖只能向下」的链——你几乎不会在第 4 章用到第 13 章的东西,反过来却会。所以本卷建议顺序读,不要跳。跳读的代价不是看不懂某一章,而是当你写到第 12 章时会发现第 6 章的句柄设计留了坑,得回头重做。
把「写 / 不写 / 只给实现」落到主题粒度,就是下面这张覆盖表。凡是标「不写」的,本卷最多给一句话结论,正文会把链接甩出去:
| 主题 | 本卷态度 | 去哪里看 |
|---|---|---|
| 渲染原理(光栅化、着色器、后处理) | 不写 | /posts/graphics/ |
| 引擎选型(Unity / Unreal 怎么选) | 不写 | /posts/game/engine/ |
| 子系统用法(动画、物理、存档怎么用) | 不写 | /posts/defold/ |
| 数学公式的推导过程 | 只给实现 | /graphics-math-foundations/ |
| ECS 的架构综述 | 只给实现 | /game-engine-architecture-ecs/ |
| 手动内存与分配器 | 从零实现 | 本卷第 4–5 章 |
| 句柄与对象池 | 从零实现 | 本卷第 5–6 章 |
| 数学库(向量/矩阵/四元数) | 从零实现 | 本卷第 8–10 章 |
| 缓存友好的数据布局 | 从零实现 | 本卷第 11 章 |
| ECS 内核 | 从零实现 | 本卷第 12–13 章 |
| 任务系统与任务图 | 从零实现 | 本卷第 14–15 章 |
| 反射与序列化 | 从零实现 | 本卷第 16–17 章 |
一句话总结这张表:「为什么」看别人,「怎么做」看本卷。这个划分不是拍脑袋,而是照着前面那张分工表来的——左列是站内已有的强项,右列是本卷要补的空白。
分工对照表
下面这张表是本节的核心。它明确列出:同一个话题,站内既有文章负责什么,本卷只做什么。三列分别是「既有文章 / 它负责什么 / 本节只做什么」。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/posts/graphics/
(45 篇) | 渲染原理:光栅化、着色器、光照、后处理的「为什么」 | 把渲染层当成一个接口,只定义内核层看到的 RenderBackend,不实现任何 GPU 逻辑 |
/graphics-math-foundations/ | 数学推导:矩阵、四元数、投影的公式来源 | 直接给出可编译的数学实现,并验证数值性质(第 8–10 章) |
/game-engine-architecture-ecs/ | 一篇文章讲清 ECS 引擎骨架 | 从零实现 ECS 的存储、查询、调度(第 12–13 章) |
/posts/game/engine/ | 引擎选型:Unity 与 Unreal 怎么选 | 不选型,只做「内核从零实现」,把商业引擎当参照系 |
/posts/defold/
(47 篇) | 子系统视角:现成引擎里动画/物理/内存怎么用 | 从实现者视角造这些子系统的地基(句柄、池、序列化) |
/defold-object-pooling-memory/ | Defold 里对象池与内存的用法 | 从零写对象池与 freelist,并给出真实基准(第 5 章) |
/defold-save-serialization/ | 存档序列化的子系统用法 | 从零实现反射驱动的序列化与版本迁移(第 16–17 章) |
这张表的读法是:左列是「读什么」,中列是「它解决什么问题」,右列是「本文/本卷的独占职责」。三列之间不重叠,这正是分层的意义在内容层面的投影。
有人会问:既然别人写过了,为什么还要自己实现一遍?因为读得懂和写得出是两种能力。看懂一篇 ECS 架构综述,和亲手处理组件存储的连续内存、查询的位掩码匹配、实体回收的代际号,中间隔着一整个调试周期。本卷要补的正是这一段。
具体来说,本卷不重复造轮子的边界是:
- 概念层不重复:一个概念「是什么、为什么存在、有几种流派」,只给一句结论 + 外链,不展开论证。
- 推导层不重复:公式怎么来的、矩阵为什么这样乘,指向既有数学文章;本卷只给可编译实现和数值验证。
- 选型层不重复:用哪家引擎、哪种架构,属于项目决策,不属于本书;本卷只保证「你自己实现时知道每个取舍的代价」。
- 实现层不外包:只要一句代码能从零写出来并跑出产物,本卷就自己写——这是本书唯一不可让渡的部分。
怎么用这套书
这套书对代码的要求是「可复现」而不是「看起来对」。每节的可运行代码都按层级标注,你要先知道自己这节能跑到什么程度:
- L1(纯 TS 内核,Node 直跑):不需要浏览器、不需要 GPU。用
npx tsc --noEmit做类型检查,用npx vitest run <file>跑单测。本卷绝大多数代码是这一层。 - L2(渲染,headless 浏览器):需要 WebGPU,命令行不可复现,只在渲染卷出现。
- L3(仅类型):只做类型层面的验证,不产生运行期行为。
- L4(命令行不可复现):需要在真实设备上手动验证,本卷基本不涉及。
每段代码的第一行注释都会写明 // file:(还原路径)、// tier:(层级)和 // verify:(验证命令)。这意味着你可以把全书代码抽出来,一条命令复现每一节的结果——本节的产物就是这么验证的。
前置知识方面:会写 TypeScript、知道 ArrayBuffer 和 typed array 是什么、大致听说过矩阵乘法,就够了。不需要预先懂 GPU 编程,也不需要装任何游戏引擎。
那为什么用 TypeScript 而不是 C++?这是个绕不开的问题。工业引擎(Unreal、Godot、Bevy)确实用 C++ 或 Rust,但本书的目的是讲清机制,不是做出最快的二进制。用 TypeScript 有三个实打实的好处:
- 零门槛复现:Node 直接跑
.ts,不用配 CMake、不用装编译器、不用管平台差异。你 clone 下来npx vitest run就能看到每一节的结果。 - 内存模型显式:
ArrayBuffer+ typed array 逼着你把「字节偏移」「对齐」「视图」这些手动内存的概念写出来。C++ 里一个指针掩盖掉的东西,这里必须明写,反而更适合教学。 - 类型系统当教具:上一节的
AllowedImports就是例子——用类型表达架构约束,是 TypeScript 独有的表达力。
代价也要说清楚:本书的数字不能当成 C++ 的性能预期。我们用 TS 验证的是「算法与布局的取舍方向」,不是绝对速度。任何基准数字都会标注运行环境,并且只做同机对比,绝不跨语言下结论。
另外,本书刻意不用 enum、namespace 和构造函数参数属性。这不是风格洁癖,而是因为 Node 的 .ts 直跑只做类型剥离、不做语法转换——用了这些「非可擦除语法」就编译不过。所以你看到的每一段代码,都是能直接 node xxx.ts 跑起来的。
几个读者最常问的问题,先在这里一次性回答:
- 「我该按什么顺序读?」 顺序读第 1–18 章。本卷是一条依赖链,跳读会在后面章节付出返工代价。
- 「需要装游戏引擎吗?」 不需要。本卷所有 L1 代码只需要 Node 和一个包管理器。
- 「数字能信吗?」 所有性能数字都在附录 C 里,标注了运行环境,只做同机对比,不跨语言外推。
- 「和渲染卷怎么衔接?」 本卷第 18 章把内核交给渲染卷,渲染卷接着把 GPU 那一半补上。
- 「能只读某一章吗?」 可以当参考手册用,但要跑通代码仍建议先读前几章打好地基。
- 「代码在哪?」 每段代码的
// file:注释就是还原路径,按层级(L1/L2/L3)分别验证。
这些回答其实都能从「写什么 / 不写什么」推出来,之所以单列,是因为它们决定了你怎么分配时间——先读哪章、哪些可以跳过、哪些必须动手敲一遍。
可运行产物:把分工编码成路由表
表格是给人看的,但「不越界」这件事如果只靠自觉,迟早会漂。helio 的做法是把这张表编码成一个可查询的路由函数:给它一个话题,它告诉你这个话题该由谁负责——是站内既有文章,还是本卷自己动手。这样「越界」就变成了一条可以断言的失败用例。
// file: src/core/scope-router.ts
// tier: L1
// verify: npx vitest run src/core/scope-router.test.ts
//
// helio 内核:本书范围与站内分工的「可查询」版本。
// 把 1.2 的对照表编码成路由表:给一个话题,返回它该由谁负责——
// 是站内既有文章,还是本卷(helio 内核)自己动手写。
// 路由表让「不越界」这件事变成一条可以跑的断言,而不是一句口号。
export type Owner =
| { readonly kind: 'external'; readonly path: string }
| { readonly kind: 'this-book'; readonly part: 'kernel' };
export interface ScopeRule {
readonly topic: string;
readonly owner: Owner;
}
/** 话题 → 归属。顺序即优先级,先命中先返回。 */
export const SCOPE_RULES: readonly ScopeRule[] = [
{ topic: '渲染原理', owner: { kind: 'external', path: '/posts/graphics/' } },
{ topic: '数学推导', owner: { kind: 'external', path: '/graphics-math-foundations/' } },
{ topic: 'ECS 理论', owner: { kind: 'external', path: '/game-engine-architecture-ecs/' } },
{ topic: '引擎选型', owner: { kind: 'external', path: '/posts/game/engine/' } },
{ topic: '子系统视角', owner: { kind: 'external', path: '/posts/defold/' } },
{ topic: '内核从零实现', owner: { kind: 'this-book', part: 'kernel' } },
];
const FALLBACK: Owner = { kind: 'this-book', part: 'kernel' };
/** 按话题查归属;未命中任何规则时默认落到本卷(从零实现是本书的本职)。 */
export function route(topic: string): Owner {
for (const rule of SCOPE_RULES) {
if (topic.includes(rule.topic)) return rule.owner;
}
return FALLBACK;
}
export function isExternal(owner: Owner): owner is Extract<Owner, { kind: 'external' }> {
return owner.kind === 'external';
}
配套测试,验证路由表真的按表走,且所有外链目标都在 /posts/ 下:
// file: src/core/scope-router.test.ts
// tier: L1
// verify: npx vitest run src/core/scope-router.test.ts
import { describe, expect, it } from 'vitest';
import { isExternal, route, SCOPE_RULES } from './scope-router.ts';
describe('scope-router', () => {
it('渲染原理类话题外链到 graphics 专题', () => {
const owner = route('光栅化与渲染原理');
expect(owner).toEqual({ kind: 'external', path: '/posts/graphics/' });
expect(isExternal(owner)).toBe(true);
});
it('引擎选型外链到 game/engine', () => {
expect(route('引擎选型对比')).toEqual({ kind: 'external', path: '/posts/game/engine/' });
});
it('从零实现类话题留在本卷', () => {
expect(route('内核从零实现:时间步')).toEqual({ kind: 'this-book', part: 'kernel' });
});
it('未命中规则时默认本卷', () => {
expect(route('某个没人写过的话题')).toEqual({ kind: 'this-book', part: 'kernel' });
});
it('路由表内每条规则都指向站内绝对路径', () => {
for (const rule of SCOPE_RULES) {
if (rule.owner.kind === 'external') {
// 专题 section 页形如 /posts/<topic>/,普通文章被压到根级 /<slug>/
expect(rule.owner.path.startsWith('/')).toBe(true);
expect(rule.owner.path.length).toBeGreaterThan(1);
}
}
});
});
本机实跑结果(npx vitest run src/core/scope-router.test.ts):
✓ src/core/scope-router.test.ts > scope-router > 渲染原理类话题外链到 graphics 专题
✓ src/core/scope-router.test.ts > scope-router > 引擎选型外链到 game/engine
✓ src/core/scope-router.test.ts > scope-router > 从零实现类话题留在本卷
✓ src/core/scope-router.test.ts > scope-router > 未命中规则时默认本卷
✓ src/core/scope-router.test.ts > scope-router > 路由表内每条规则都指向站内绝对路径
Test Files 1 passed (1)
Tests 5 passed (5)
route() 的默认分支值得多说一句:未命中任何规则的话题,一律判给本卷。这是刻意的——本书的职责就是「别人没写的、要从零实现的」,所以兜底方向必须是「自己写」,而不是「甩给别人」。
再写一个几行的 demo,把几个「读者真会问的问题」丢进路由表,直接看它判给谁:
// file: src/apps/scope-demo.ts
// tier: L1
// verify: node src/apps/scope-demo.ts
import { route } from '../core/scope-router.ts';
// 把几个「读者可能会问的问题」丢进路由表,看它判给谁。
const topics = [
'光栅化和渲染原理',
'引擎选型:Unity 还是 Unreal',
'子系统视角下的对象池',
'内核从零实现:帧预算',
'一个从没人写过的话题',
];
for (const t of topics) {
const owner = route(t);
const who = owner.kind === 'external' ? owner.path : `本卷(${owner.part})`;
console.log(`${t.padEnd(22, ' ')} -> ${who}`);
}
本机实跑结果(node src/apps/scope-demo.ts):
光栅化和渲染原理 -> /posts/graphics/
引擎选型:Unity 还是 Unreal -> /posts/game/engine/
子系统视角下的对象池 -> /posts/defold/
内核从零实现:帧预算 -> 本卷(kernel)
一个从没人写过的话题 -> 本卷(kernel)
注意最后两行:一个是明确命中「内核从零实现」规则,一个是什么都没命中走了兜底,结果都指向本卷。这正是我们想要的不变量——「要不要自己写」的答案永远偏向自己写。下一节我们把「本卷到底要造哪些零件」列成一张清单,并真的跑一个最小骨架出来。
阅读导航:上一节:1.1 引擎的分层与模块边界 · 下一节:1.3 一个引擎最少要有哪些零件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。