《游戏引擎实现原理》序:为什么还要从零写一个引擎
《游戏引擎实现原理》卷一序言:在现成引擎随手可得的今天,为什么还要从零写一个引擎内核。文章用三个真实痛点说明「自己写一遍」换来的判断力,讲清本套书选择 TypeScript 的理由、这一卷写什么不写什么、它和站内既有专题的分工,并给读者一段开始前的鼓励。
category
《游戏引擎实现原理》卷一序言:在现成引擎随手可得的今天,为什么还要从零写一个引擎内核。文章用三个真实痛点说明「自己写一遍」换来的判断力,讲清本套书选择 TypeScript 的理由、这一卷写什么不写什么、它和站内既有专题的分工,并给读者一段开始前的鼓励。
《游戏引擎实现原理》卷一前言:交代本套书的技术选型理由(为何是 TypeScript 而非 C++、为何以 WebGPU 为渲染目标)、L1–L4 可运行性口径的含义与验证方式、基准数字的诚实声明、代码块标注约定,以及跟读本卷所需的环境准备与目录篇、附录的配合读法。
稀疏集用 sparse 数组把稀疏 ID 映射到 dense 下标、dense 数组紧凑存值,换来 O(1) 增删查和一段连续内存上的遍历。本节实现可编译的 SparseSet,用 swap-remove 做句柄回收,并断言删除之后 dense 依然无空洞、遍历次数恒等于元素个数。
把句柄编码成「低 20 位槽位索引 + 高 12 位世代」的 32 位整数,槽位复用时世代自增,旧句柄的世代对不上就立刻失效。本节给出 helio 内核里可编译的 SlotMap 完整实现与 vitest 用例,并用「删除后再复用」的断言证明旧句柄再也取不到值。
对象被回收或移动之后,旧引用要么读到 undefined,要么更糟——读到恰好复用了同一块内存的另一个对象。本节用 TypeScript 复现裸索引句柄的三种失效形态,说明内核为什么必须用「索引 + 世代」的句柄替代裸指针,并给出可运行的验证用例。
本节给每笔分配挂上 Rendering/Audio/Physics/Gameplay 等内存标签,用 MemoryTracker 统计各标签的当前与峰值占用并按预算告警;再用分配计数快照做帧级泄漏检测,真跑制造一次忘记 release 的泄漏并当场抓出。
本节在固定 ArrayBuffer 上实现按 2 的幂分大小类的块分配器 PoolAllocator:16/32/…/1024 每类一条空闲链表,支持任意顺序 free;断言分配-释放-再分配复用同一块,并统计内部碎片率与各类占用,是对象池在字节层的对应物。
本节用空闲链表实现定长对象池 ObjectPool<T>,支持 acquire/release 与耗尽时抛错或扩容两种策略;并在本机真跑 10 万次构造,对照 new/GC 给出耗时与堆增量,诚实讨论池化反而更慢的场景,最后总结何时才该池化。
本节把顶点结构 [pos.xyz][pad][uv.uv][color.rgba] 手工布局到一块 ArrayBuffer 上,讲清 std140 的 16 字节对齐规则、padding 从哪来、以及「CPU 紧打包」为什么会让偏移错位;并用可运行代码算出结构体大小与步长,再补上 WebGPU 的 bytesPerRow 必须是 256 倍数这一约束。
本节从零实现 helio 的线性 arena 分配器:一块 ArrayBuffer + 一个只前进的偏移量,讲清 bump 分配、按 2 的幂对齐、highWaterMark 与 reset;再在其上搭出 ScratchArena 的 mark/release,为「帧内临时内存」提供一次性回收。全部代码经 tsc 与 vitest 真跑验证。
本节把「为什么要自己管内存」从「避开 GC」纠正为「控制分配节奏、让布局可预测」,并用一个真跑过的基准说明差异:1M 个四字段对象在 JS 堆上占约 61 MB,而 6 条 1M 元素的 TypedArray 堆增量是 0.0 MB——因为 TypedArray 的 buffer 内容不走 JS 堆。
内核要能在出错时立刻喊停、在发布后按需静音、在启动时被命令行改参数。本节用 TypeScript 从零实现 helio 的三件基础设施:分 debug/release 两档的 assert 与永远生效的 check、五级加分类过滤的 Logger、带类型化 get/set 与命令行解析的 CVarSystem,并在 Node 里真跑验证。
内核的其余部分不应该知道 node:fs 或 window 的存在。本节定义 helio 的 IPlatform 接口(时间、文件、线程、日志四个能力面),给出 NodePlatform 的真实实现,说明浏览器 WebPlatform 的差异,并真跑一段「同一份 measureMillis 代码在 PAL 上读时间戳」的用例。
引擎启动时几十个子系统谁先谁后不是拍脑袋定的。本节用 TypeScript 从零实现 helio 内核的 ISubsystem 接口与 SubsystemRegistry,用 DFS 三色标记做依赖拓扑排序、检测循环依赖,并保证关闭顺序严格是启动顺序的逆序,最后在 Node 里真跑断言把顺序验出来。
固定步长让物理与渲染解耦,但游戏逻辑时间本身还需要被缩放。本节实现 TimeService:用 timeScale 做慢动作、paused 做暂停,并把逻辑 delta 与 UI delta 拆成两套,真跑出「暂停时逻辑步为 0 而 UI 动画仍推进」的结果。
上一节量出的帧时间抖得厉害,直接喂给物理会跑出不确定的结果。本节实现 FixedStepLoop:用累加器把不均匀的 dt 变成均匀的物理步,再用 alpha 做渲染插值;并真实复现死亡螺旋,用 maxStepsPerFrame 把它钳制住。
帧预算决定了引擎能否稳住 60Hz。本节把一帧拆成逻辑、提交、GPU 与呈现四个阶段,用 text 时间线标出 16.67ms 的预算边界,再从零实现一个可注入假时钟的 FrameTimer,在 Node 里真跑出 300 帧的耗时分布、百分位与 FPS。
一个引擎最少要有哪些零件?helio 的答案是六个:主循环、内存、句柄、场景/实体、渲染接口、资源表。本节给出这六个零件的最小实现,并用一段 30 行的启动代码在 Node 里真跑出「第 1 帧到第 3 帧」的日志,再用 vitest 断言帧数。
本卷只做「从零实现 + 每节跑出真实产物」,渲染原理、引擎选型、子系统视角这些别人讲透的话题一律外链。本节用一张三列分工表把边界钉死,把这张表编码成可运行的路由函数,并给出 18 章的阅读路线、L1/L2/L3 代码分层怎么用,全部附可复现断言。
从零实现引擎的第一件事是划边界。helio 分成平台、内核、渲染、子系统、游戏五层,依赖只能向下。本节用一张 ASCII 分层图对照 Unity、Unreal、Godot、Bevy,并用 TypeScript 类型把「内核层不得依赖渲染层」钉死在编译期。