《游戏引擎实现原理》1.2 本书范围与站内分工

本卷只做「从零实现 + 每节跑出真实产物」,渲染原理、引擎选型、子系统视角这些别人讲透的话题一律外链。本节用一张三列分工表把边界钉死,把这张表编码成可运行的路由函数,并给出 18 章的阅读路线、L1/L2/L3 代码分层怎么用,全部附可复现断言。

1.2 本书范围与站内分工

上一节画了 helio 的分层图,这一节回答一个更现实的问题:这本书和站内已有的几百篇文章,到底谁写什么? 如果搞不清分工,读者会在「渲染原理」和「引擎实现」之间反复横跳,作者也会把别人讲透的内容再抄一遍。本节的立场很简单——别人讲过的「是什么、怎么选」,我们全部外链出去;本书只写「怎么从零搭出来、跑出什么结果」。

这不是偷懒,而是对读者负责。你花钱买的是「从零实现」这一块,不是「引擎概论」的第十个版本。把边界划清楚,双方都省事。

本卷写什么,不写什么

卷一《引擎内核》只覆盖一件事:一个 3D 引擎在「不碰 GPU、不碰玩法」的那一半里,需要哪些零件,以及这些零件怎么从零实现。具体包括:

  • 写:时间步与帧预算、子系统生命周期、平台抽象、手动内存管理、句柄与对象池、基础数据结构、数学(向量/矩阵/四元数)、定点数与确定性、缓存友好与 SoA、ECS、任务系统、反射与序列化。
  • 不写:光栅化、着色器、渲染图、GPU 资源管理、材质系统——这些属于渲染卷。也不写引擎选型、商业引擎对比、美术管线、发行运营。
  • 只点到为止:凡是「为什么要这样做」的理论,本卷给一句话结论 + 一个外链,不展开。
  • 写的是「内核」:引擎里「不依赖 GPU、不依赖玩法」的那一半。跨平台的窗口、输入、文件读取属于平台层,本卷也写;但只写到「够内核跑起来」为止。
  • 不写的是「外围」:编辑器、构建管线、资源热更、打包发布,这些属于工具链,本卷不碰。

贯穿项目是低多边形 3D 收集游戏**《微光岛》**(glimmer-isle)。每一章的新零件都会往这个小项目里加一块,到第 18 章它应该能跑起来。

18 章大致按「从地基到屋顶」排列,你可以把它当成一条依赖链读:

  1. 第 1 章 引擎是什么:分层、范围、最小零件(也就是本章)。读完你会得到一个能打印前三帧日志的骨架。
  2. 第 2–3 章 时间与平台:帧预算、固定步长、时间缩放;子系统生命周期、平台抽象、日志与 cvar。产物是一个跨 Node 与浏览器的 Platform 接口。
  3. 第 4–5 章 手动内存:为什么要自己管内存、线性 arena、对齐;对象池 freelist、块分配器、内存标签与预算。产物是一套能在 typed array 上分配与回收的分配器,并附真实基准。
  4. 第 6 章 句柄:悬垂指针问题、slotmap 代际句柄、sparse set 回收。产物是一个「删了再访问会失败」的安全句柄系统。
  5. 第 7 章 基础数据结构:动态数组与哈希、bitset 与环形缓冲、字符串驻留与 GUID。产物是内核自己的一小套容器。
  6. 第 8–10 章 数学:向量矩阵布局与约定、变换合成与求逆;欧拉角/四元数/slerp/曲线;浮点确定性、定点数、数值鲁棒性。产物是一个可编译、带数值断言的数学库。
  7. 第 11 章 缓存与布局:cache 与内存访问、SoA/AoS/AoSoA、把 OOP 重构为批量处理。产物是一次「同语义、不同布局」的性能对比。
  8. 第 12–13 章 ECS:继承 vs 组合、组件存储、实体生命周期;查询匹配缓存、迭代与脏标记、系统依赖调度。产物是《微光岛》能用的 ECS。
  9. 第 14–15 章 任务系统:为什么要 job system、线程池与任务队列、work stealing;依赖 DAG、帧任务图、主循环与渲染同步。产物是一个能并行跑一批任务的调度器。
  10. 第 16–17 章 反射与序列化:为什么需要反射、类型注册表、显式注册与 codegen;反射驱动的序列化、引用与环、版本迁移。产物是一个能存能读、能升级版本的存档系统。
  11. 第 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 一个引擎最少要有哪些零件 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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