一个理所当然的疑问
打开任何一个引擎官网,你都能在十分钟内下载、安装、点出一个能跑的场景。Unity 有 Asset Store,Unreal 有 Nanite 和 Lumen,Godot 开源且轻量,Bevy 用 Rust 写得很优雅。既然如此,为什么还要从零写一个?
这个问题问得很好,值得认真回答,而不是用「学原理很重要」这种话糊弄过去。这篇序会用几个具体的场景说明:从零写一遍到底换来了什么,为什么值得,以及它不换来什么。
会用一个工具,和懂一个工具,是两回事
先看三个真实的场景。它们都不是「理论上可能发生」,而是几乎所有做过游戏的人都会撞上的墙。
场景一:200 个物体,帧率从 120 掉到 30
你在 Unity 里给一个物体加了 Rigidbody,设了质量,跑了 200 个物体,帧率从 120 掉到 30。你去查资料,有人说「开 GPU Instancing」,有人说「减少 Draw Call」,有人说「用对象池」。你都试了,有的有效有的没效,但你并不知道为什么——你不知道这 200 个物体的数据在内存里是怎么摆的,不知道物理更新和渲染更新是不是同一批线程在做,不知道掉帧到底是 CPU 还是 GPU 的锅。
这不是你的错。引擎把这些都藏起来了,藏得很好——好到你不需要知道也能做出游戏。直到你做不出游戏为止。
写过一遍内核的人,在这时会做一件不同的事:他会先问「这 200 个物体的位置数据是连续存放还是散落的」,因为这决定了 CPU 缓存命中率;他会看「物理和渲染是否在同一帧里争夺主线程」,因为这决定了瓶颈在谁身上。同样是「掉帧」,一个在猜,一个在推理。
场景二:热重载后,旧引用悄悄指向了别人
第二个场景更隐蔽。你给编辑器加了个热重载功能:改完代码,场景不重启就生效。测试时一切正常,上线后却偶尔崩溃——某个实体被销毁又重建,一个还攥着旧句柄的系统,读到了新对象的数据。
程序没报错。内存没越界。它只是安静地读到了错误的东西。这类 bug 在 C++ 里是悬垂指针,在 JS 里是「对象还在,只是不再是原来那个」。它的根源,是引擎用裸引用而不是世代句柄来表示对象——删除一个对象后,它的槽位被复用了,但旧引用并不知道自己已经过期。
读过第 6 章 的人,会本能地给对象加一个「世代号」:句柄 = 索引 + 世代,槽位复用时代号自增,旧句柄一比对就知道自己过期了。这个技巧本身不复杂,难的是意识到它需要存在。而只有当你亲手被一个「读到了别人的数据」的 bug 折磨过,你才会真正理解它为什么必要。
场景三:改了一次存档格式,老玩家进度全丢
第三个场景来自线上。你给存档加了一个新字段,顺手把旧的读档逻辑改了。本地测试用的是新档,没问题;上线当天,一批老玩家的存档全部读不出来,进度清零。
问题出在没有版本号与迁移路径。存档是「过去写的、未来读的」数据,它天然会跨版本。一个成熟的序列化方案必须有 schema 版本号,以及一条「从旧版本迁移到新版本」的路径。这件事在第 17 章 里会用一整节来讲,但如果你从没写过序列化,你根本不会想到它是个问题——你会以为「读档就是把 JSON 解析回来」。
三个场景,三种代价:性能排查靠猜、对象生命周期靠运气、数据兼容靠事后补救。它们的共同点是:故障发生在你完全没意识到的地方,因为那些地方被引擎封装了。
为什么「读源码」不是好办法
你可能会说:那我直接去读引擎源码不就行了?
可以,但读源码是低效的入门方式。真实引擎的源码是几百万行工业代码,充满了历史包袱、平台适配和性能 hack,直接读很容易迷路:你想知道「对象怎么管理」,结果要先读懂一套平台抽象、一层内存分配、一堆条件编译。等你终于摸到核心,已经忘了当初为什么出发。
自己写一遍,是最快的理解方式。 因为你写的每一行都是必要的,你能看到每个决策的动机,也能看到每个决策的代价。没有历史包袱要迁就,没有无关分支要跳过——一条从空文件到能跑的引擎的直线,就是最好的教材。
从零写,得到的不是「一个引擎」
需要说清楚:本套书写出来的 helio,不可能替代 Unity 或 Unreal。它没有成熟的编辑器,没有跨平台打包,没有插件生态,性能也远不如商业引擎。
写它的目的不是「造一个能用的引擎」,而是获得判断力:
- 当有人告诉你「用 SoA 布局能快好几倍」时,你能拿出自己的实测数据说「在我的场景里它只快了 1.6 倍,但内存少了一半」;
- 当引擎文档说「固定步长物理更稳定」时,你能解释为什么,以及什么时候累加器会引发死亡螺旋;
- 当你要在 ECS 和场景树之间做选择时,你知道两者的数据结构长什么样,代价分别落在哪里;
- 当同事说「用对象池优化一下」时,你能判断这里的瓶颈到底在分配、在缓存、还是在同步。
这种判断力,是「会用」和「懂」之间的那道坎。 它也是本套书唯一想给你的东西。helio 只是通往它的载体——载体本身会过时,判断力不会。
为什么是现在:引擎越成熟,判断力越值钱
你可能会说:这些道理十年前也成立,为什么现在专门写一本书来讲?
因为行业的重心变了。十年前,「能不能做出来」本身就是门槛——你会不会写引擎、会不会用引擎,决定了你能不能被录用。今天,做出一个能跑的原型已经变得非常便宜:引擎足够成熟,模板足够多,连代码本身都可以由 AI 助手生成一大半。
当「生成」变便宜的时候,稀缺的东西就换成了判断:判断这段生成的代码是不是真的对、判断掉帧的原因到底在哪、判断这个方案能不能撑到上线。判断力没法被生成,它只能来自理解。而理解,最可靠的来源就是亲手实现过一遍。
这个逻辑和站内其他几套书稿是一致的:工具让「写出能跑的代码」变便宜了,但没有让「判断它可不可靠」变便宜。 你越懂引擎内部是怎么转的,工具就越有用。
helio 会长成什么样
说了这么多「从零写」,具体写成什么样?先给一个轮廓,好让你心里有底。
helio 是一个纯 TypeScript、零运行时依赖的 3D 引擎内核,运行在 Node 26 上,不引入任何第三方库。它按模块组织:core(内存、句柄、容器、数学)、ecs(实体、组件、系统)、jobs(任务系统)、reflect(反射与序列化)、platform(平台抽象层)。每一章新增一个模块,代码是累积的——第 18 章的 helio 里,还留着第 1 章写下的那个主循环,只是它长大了。
它不会长成一个通用引擎。没有编辑器、没有可视化场景、没有跨平台打包、性能也谈不上顶尖。它只有两个目标:能被读懂,能被跑通。 前者靠克制——每个模块都短到能一口气读完;后者靠测试——每一节新增的逻辑都有对应的单测或基准。
到卷一结束时,helio 上跑着的《微光岛》已经能让一个小方块按固定步长、确定性地走动起来。它不大,但它是你自己的——从主循环到存档格式,每一行你都能解释。
为什么是 TypeScript
技术选型在前言 里会详细交代,这里只说结论和理由。
本套书用 TypeScript,而不是 C++ 或 Rust。这看起来是反直觉的选择——真正的引擎大多是 C++ 写的,Rust 也越来越多。理由很实际:本套书的每一节代码都必须能在你的机器上真跑起来。
| 方案 | 上手成本 | 可运行性 | 适合讲什么 |
|---|---|---|---|
| C++ | 高:编译器、CMake、平台 SDK、图形 API 环境 | 跨平台差异大,读者极易卡在环境上 | 贴近工业界,但门槛劝退 |
| Rust | 中:cargo 与工具链齐全 | 编译慢,迭代体验差 | 内存安全模型优雅,但学习曲线陡 |
| TypeScript | 低:Node 26 直接跑 .ts,零构建 | 复制、粘贴、回车就出结果 | 讲机制本身,不纠缠环境 |
TypeScript 在 Node 26 上直接就能跑,浏览器里也直接就能跑——你把代码复制下来,粘贴,回车,它就出结果。没有编译等待,没有链接错误,没有平台差异。这让「写代码验证」这件事的成本降到最低,而本套书的每一节都建立在这件事上。
更重要的是:在一个有 GC 的语言里讲手动内存管理,反而更诚实。 你不会误以为「arena 分配器能凭空消灭 GC 开销」,你会亲眼看到数据——TypedArray 的 buffer 内容确实不走 JS 堆,但访问它的代码本身还是会被 JIT 优化和 GC 影响。这种「带着镣铐跳舞」的体验,比在 C++ 里照抄一遍更接近真实的工程判断:你永远无法完全消除约束,只能理解它、绕开它、与它共处。
需要提醒的是:本卷讲的机制——内存布局、句柄、ECS、Job System——换到任何语言都成立。你在这里学到的不是「TS 版的引擎」,而是引擎本身;语言只是让它能被所有人跑起来的手段。
这一卷的边界
一份诚实的书,必须说清自己写什么、不写什么。本卷只写内核:内存、句柄、容器、数学、数据导向、ECS、并发、反射、序列化。
| 写 | 不写 | 为什么不写 |
|---|---|---|
| 内存分配器、对象池、句柄 | 渲染管线、光照、阴影 | 属于卷二《渲染引擎》 |
| 容器、向量矩阵、四元数 | 物理求解器、碰撞形状 | 属于卷三《子系统与工具》 |
| ECS 存储与查询调度 | 网络同步、帧同步 | 不在本套书范围,站内已有专门书稿 |
| Job System、依赖 DAG | 编辑器 UI、资源导入管线 | 属于卷三 |
| 反射、序列化、版本迁移 | 平台打包、上架流程 | 与「内核怎么转」无关 |
边界清楚,才能每一节都写透。本卷的 18 章都围绕同一个目标:让一个小方块在场景里走动。 为了这个目标,你需要一帧能稳定推进的主循环、一套不会失效的对象引用、一种能被批量高效更新的数据布局、一条能跨线程并行的执行路径,以及一份能存能读的进度。除此之外的东西,都被刻意留给了后面的卷。
范围红线:本卷只做到「一个小方块在场景里走动」为止,不碰真正的画面。这不是偷懒,而是先把地基夯到能承重——地基歪了,后面盖得越高越危险。
这一卷会把你带到哪里
18 章分五个部分,是一条精心排过序的施工路线:
| 阶段 | 章节 | 你会做出什么 |
|---|---|---|
| 地基 | 第 1–3 章 | 一个带帧预算统计的固定步长主循环,一套依赖拓扑的子系统生命周期 |
| 内存与句柄 | 第 4–6 章 | 一组分配器与对象池,一个让悬垂引用无处藏身的世代句柄系统 |
| 容器与数学 | 第 7–10 章 | 一套自研容器、一条变换与四元数管线、一份确定性模拟的基础 |
| 数据导向与 ECS | 第 11–13 章 | 一条从 OOP 到批处理的重构路径,一套 ECS 存储与查询调度 |
| 并发、反射与综合 | 第 14–18 章 | 一个 Job System、一套反射与序列化方案,最后装配成能跑的引擎 |
这条路线不是随便排的:没有主循环,子系统就无处安放;没有句柄,ECS 的实体就管不住;没有数据导向,Job System 就没有值得并行的数据。每一章都在为下一章铺路,这也是为什么跳章会比想象中更容易卡住。
到第 18 章,你会做一件有仪式感的事:把前 17 章的零件装进同一个引擎,然后运行《微光岛》,看着一个小方块在程序化生成的岛上,按固定步长、确定性地走起来。那一刻你会发现,你手里握着的不是一堆散落的技巧,而是一台你能解释每一处的机器。
三个常见的误解
到这一步,大概有三个误解会冒出来。它们都很合理,值得正面拆开:每个都先说清它是什么、为什么你会这么想,再给出实际情况和该换上的心智模型。
误解一:引擎就是渲染器。 一提到「游戏引擎」,你脑子里先浮现的是画面——光影、材质、粒子。这么想很正常:画面是引擎唯一能直接给你看到的东西,也是各家引擎宣传片里唯一展示的东西,于是「学引擎」在你心里就约等于「学渲染」。
实际情况是,渲染只是引擎的一层,而且往往是最上面那一层。在它能画出任何东西之前,引擎得先把一帧的时间算清楚、把对象的数据摆进连续内存、把成千上万个实体的引用管好、把互不依赖的更新分到多个线程上。
正确的心智模型:把引擎看成一台调度机器,渲染是它最后一道工序。 换掉渲染器(WebGL 换 WebGPU)引擎照样转,但换掉内存与调度模型,引擎就得重写。
误解二:从零写,就是要造一个能用的引擎。 你会本能地把「从零写」理解成「造产品」,于是觉得这件事既不现实、也没必要。这么想的根源是,我们习惯用「能不能上线」来衡量一件事值不值。
实际情况是,helio 的目标从来不是能用的产品,而是让你获得判断力。它跑不到 Unity 的水平,也不该被拿去发布商业游戏。
正确的心智模型:把 helio 当成一台教具,而不是一个产品。 教具不追求性能顶尖、功能齐全,只追求透明——每一处实现都能被你读懂、被你改坏、再被你修好;它能在别人说「用 SoA 快好几倍」时,让你拿出自己的实测数字来回答。
误解三:TypeScript 写不了引擎。 这句话有一半是对的:真正的商业引擎几乎都用 C++ 或 Rust 写,TypeScript 也确实不适合拿去发布一个 3A 引擎。但这么想的人,往往把「语言」和「引擎的核心」混为一谈了。
实际情况是,引擎的内核——数据布局、句柄、调度、序列化——是与语言无关的机制,换套语法照样成立。同一个 SoA 布局,在 C++ 引擎里和在本卷的 TypeScript 里,思路一模一样。
正确的心智模型:语言只是载体,判断力才是目的。 本卷用 TypeScript,是因为它让每节代码都能在你的机器上直接跑起来;你在这里学到的内存布局与 ECS,搬到任何语言都成立。
为什么是《微光岛》
贯穿全书的项目叫《微光岛》——一款低多边形 3D 收集冒险小游戏:玩家操控一个角色在一座程序化生成的小岛上走动,收集散落的「微光」,昼夜循环随时间推移。
选它,是因为它体量小到能从零写完,又覆盖了 3D 游戏的全部关键面:
| 玩法要素 | 牵出的技术点 | 对应章节 |
|---|---|---|
| 角色在岛上移动 | 帧循环、固定步长、时间缩放 | 2 |
| 场景里成百上千个物体 | 内存布局、对象池、句柄 | 4、5、6 |
| 物体挂在父子层级下 | 变换、矩阵、四元数 | 8、9 |
| 大量同类物体批量更新 | 数据导向、ECS、查询调度 | 11、12、13 |
| 程序化生成岛屿 | 确定性、定点数、可复现随机 | 10 |
| 加载与保存进度 | 反射、序列化、版本迁移 | 16、17 |
| 多核并行更新 | Job System、依赖 DAG | 14、15 |
如果换一个更复杂的项目——比如一个带战斗和联网的 3D 游戏——你会在前几章就陷入玩法细节,反而看不清引擎机制。而《微光岛》足够简单,简单到你全部的注意力都能放在「一帧是怎么转的、数据是怎么摆的」这件事上。
一份最小的心智模型
如果要在开始前,用几句话概括 3D 引擎内核的世界观,可以是这样:
- 一切从一帧开始。 游戏本质上是「读输入、更新状态、画出来」的循环,把这三步拆清楚,剩下的都是它的延伸。
- 时间是第一公民。 逻辑要跑在固定步长上,渲染做插值,暂停与慢动作本质是缩放时间;帧预算决定了每帧能做多少事。
- 引用要能被验证。 裸引用会失效,句柄(索引 + 世代)能在槽位复用时检测出「你已经过期了」。
- 数据的摆放比算法更贵。 缓存命中率往往比大 O 复杂度更决定实际性能,这就是数据导向的由来。
- 并行靠的是无冲突,不是线程多。 Job System 的价值在于把「互不依赖的工作」识别出来,而不是简单地多开几个线程。
这五条会在全书反复出现。你不需要现在就完全理解它们,但记住这几句话,读后面的章节时会更有方向感。
关于「读懂」与「会写」
还有一件事必须提醒:这本书默认你会动手,而不只是阅读。
引擎开发特别容易制造「我明白了」的错觉。你看别人写的一段固定步长循环,每一步都合理,读起来毫不费力;但合上书,让你从空白文件开始写一个不丢步、不死螺旋的循环,你可能连累加器该怎么钳制都要想半天。这种「读得懂、写不出」的状态,是入门阶段最常见的陷阱。
所以本卷的每一节,都请你把代码敲一遍,而不是复制粘贴、更不是只读一遍。敲的过程会让你遇到那些「看起来对、其实差一点」的地方——对齐算错了一字节、世代号忘了自增、任务依赖少声明了一条。这些细节,只有在键盘上才会暴露出来。
与站内既有专题的分工
本站的引擎与图形学主题已经积累了大量文章,它们质量不低,但有一个共同的特点:每一篇都假设你已经理解某个机制是什么,然后带你在一个点上钻深。
这恰恰是它们对初学者不够友好的地方。一个连「为什么要有世代句柄」都还没想清的人,去读一篇讲 ECS 查询优化的文章,收获会很有限——不是因为文章写得不好,而是因为缺了前置的那一层。这些文章串起来,像是一排深井,每口井都很深,但井与井之间没有路。
这一卷要补的就是这条路:一条从零到能跑的递进路径。它从「一帧怎么开始」讲起,经过内存、句柄、容器、数学、数据导向、ECS、并发、反射,最后用一个能走的方块收尾。读完它,你再去读那些专题文章,才能真正读出味道来。两者是互补关系:本卷负责按顺序搭起一台引擎,专题负责就一个问题钻到底。
这本书不适合谁
一份诚实的取舍,也应该说清它不服务谁。如果你属于下面几类,这本书可能不是最合适的选择:
- 想找一本 Unity / Unreal 的速查手册:本卷从零讲机制,不按 API 分类组织。你需要的是官方文档,不是这本书。
- 想两周内做出一个商业级 3D 游戏:本卷的目标是打地基,不是快速交付。想立刻出画面的人,会觉得它「绕远」。
- 只想读、不想写:全书默认你动手敲代码。只读不写,引擎的知识很难真正落进手里。
- 完全不想碰底层:如果你只关心「怎么把玩法做出来」,那么直接读卷二会更省时间;本卷的价值恰恰在于那些「看起来用不上」的底层。
如果你读完上面几条反而更想继续,那说明这本书正是为你准备的。
怎么判断自己真的学会了
学完一章之后,怎么知道自己是真懂了还是「读懂了」?这个问题比它看起来更重要,因为引擎开发特别容易制造「我明白了」的错觉。下面这份清单可以用来自查,每一项都对应一种可以被观察的行为,而不是一种感觉:
- 能预判一段代码的效果。 看到一个分配器的对齐参数,你心里已经知道它会向上取整到多少字节。
- 能解释现象背后的原因。 面对「遍历突然变慢」,你答得出是数据布局、缓存缺失还是分支预测的问题。
- 知道什么时候不该自己写。 能判断某个功能用语言自带的设施就够,还是真的需要一套手写的数据结构。
- 能读懂一段引擎源码。 打开一个成熟引擎的仓库,你能大致定位「内存管理在哪、ECS 在哪」,而不是迷失在目录里。
- 能写出可断言的测试。 你能用
vitest断言一段逻辑的确定性结果,而不是靠肉眼观察输出。
反过来,下面这些信号说明你可能还停在「读过」的阶段:一遇到性能问题就凭感觉调参数;能看懂书里的例子但自己写不出来;分不清「代码能跑」和「结论成立」的区别;写完实现之后才回头补测试。
如果你发现自己中了几条,不必沮丧。引擎开发的判断力本来就是靠「写—跑—改」的循环长出来的,回到对应章节,把示例亲手敲一遍,往往比再读一遍有效。
这本书怎么读
同一本书,不同的人该有不同的读法。动手之前,先想清楚你属于哪一种。
如果你想系统学,走主线。 按章顺序从第 1 章 读到第 18 章,每章代码都亲手敲一遍。18 章分五个部分,是一条排过序的施工路线:地基(1–3)→ 内存与句柄(4–6)→ 容器与数学(7–10)→ 数据导向与 ECS(11–13)→ 并发、反射与综合(14–18)。
这条主线一次读完大约 8 周(每天一小时)。每一章都在为下一章铺路,跳章会比想象中更容易卡住——但它也是最慢、最完整的一条,第 18 章会把前 17 章的零件拼成一个能跑的引擎。
如果你只想先看到东西跑起来,走捷径。 别从第 1 章硬啃:先跳到 1.3 一个引擎最少要有哪些零件 拿到最小可运行的骨架,再跳到 2.2 固定步长、累加器与渲染插值 把主循环跑起来——终端里会打印出帧耗时,那一刻你就有了一个「活着」的引擎。
尝到甜头之后再回到主线,从第 3 章接着走。先跑起来,比先读明白更能帮你坚持。
如果你只关心某一块,按主题跳读。 每章第 1 节都有一小节讲「本章与站内既有内容的分工」,先扫一眼再决定要不要细读。常见的对应关系是:
| 你想搞懂的东西 | 去看 |
|---|---|
| 帧循环、时间缩放与暂停 | 第 2 章 |
| 内存分配、对象池、句柄 | 第 4–6 章 |
| 变换、矩阵、四元数 | 第 8–9 章 |
| 数据布局与遍历性能 | 第 11、13 章 |
| ECS 存储与查询调度 | 第 12–13 章 |
| 多核并行与任务调度 | 第 14–15 章 |
| 反射、序列化与存档迁移 | 第 16–17 章 |
代码块上的标注会帮你定位「能立刻跑」的代码:看到 // file: 说明这是一个完整可编译的文件,把整段复制出来存成同名路径就能跑;看到 // tier: L1 说明它属于纯 TypeScript 内核,Node 26 直接执行即可(// tier: L3 只能过类型检查,// tier: L4 需手动验证,本卷用不到 L2)。想亲手验证正文里的结论,就找块首的 // verify: 命令照跑一遍。
四篇附录按「卡住了怎么办」组织,别把它们当摆设:附录 A
查模块、类型与 API 签名;附录 B
解决环境与 verify 命令跑不通;附录 C
核对基准的原始数字;附录 D
查报错原文与排查清单。
没有唯一正确的读法,只有适合你的读法。选一条,然后动手。
开始之前
你不需要装任何引擎,也不需要显卡。打开终端,确认 Node 版本不低于 26:
node --version # 应输出 v26.x 或更高
然后从第 1 章 1.1 开始。如果你还想先确认这本书适不适合自己,可以回到目录 看那三条阅读路径,或者继续读前言 了解技术选型与环境准备。
给读者的一段话
最后,说一段不是技术的话。
写引擎内核这件事,最难的不是某一章的算法,而是在看不见结果的时候坚持把地基打扎实。前几章你写出来的东西,屏幕上什么都没有——只有一个在终端里打印帧耗时的循环,一堆看不懂的十六进制偏移。你会怀疑:这到底有没有用?
有用。当你走到第 11 章,第一次看到「同样的逻辑,改一下数据布局,耗时就掉了一大截」;当你走到第 14 章,第一次看到八个线程一起把一帧的活干完;当你走到第 18 章,看到那个小方块按你写的固定步长稳稳走动——你会明白,前面那些「什么都没发生」的章节,恰恰是让这一切成立的原因。
如果读完这一卷,你再看任何引擎的架构图时能说出「这一层大概在做什么、为什么这么分」,那这一卷就成功了。 你不需要成为引擎工程师,但你会成为一个不再被引擎牵着走的开发者。
祝阅读愉快。
阅读下一节:前言
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。