《游戏引擎实现原理》3.2 GC、内存预算与分配追踪
帧时间稳住了,GC 的一次 Stop-The-World 照样能把它抖起来。本节用 process.memoryUsage() 与 --trace-gc 把内存账本读出来,按子系统定位分配热点,并在独立子进程里实测对象池对 GC 次数、停顿与吞吐的真实收益。
category
帧时间稳住了,GC 的一次 Stop-The-World 照样能把它抖起来。本节用 process.memoryUsage() 与 --trace-gc 把内存账本读出来,按子系统定位分配热点,并在独立子进程里实测对象池对 GC 次数、停顿与吞吐的真实收益。
帧预算是性能工程的第一把尺。本节把一帧拆成 logic/submit/present 三阶段,从零实现可注入时钟的 FrameProfiler,在 Node 里真跑 600 帧粒子负载,读出中位、p95、p99 与超预算比例,再用 CPU/GPU 流水线模型说清一帧里谁在等谁。
黄金图像抓画面,回放抓逻辑。本节把「一局游戏」定义为「一个种子 + 一串输入帧」,用全整数模拟保证跨平台逐位一致,再把每帧状态折成哈希:两遍回放逐帧相同,一次改动被精确定位到分叉帧。本机实测同一份 120 帧回放两遍哈希全同,改一个摩擦常数后在第 1 帧即分叉。
NullRHI 只能告诉你「调用了 draw」,画出来长什么样必须真的上 GPU。本节把测试搬进 headless Chromium,用 WebGL2 渲染一个固定场景、落 PNG 存档,再用逐像素比对与容差判定抓住回归。本机实测:同一场景两次渲染零差异,一个 3px 的位移被抓出 622 个差异像素、最大通道差 204。
引擎测试的难点不在断言,而在把外部世界换掉:时间、图形 API、磁盘与随机数。本节用 FakeClock、NullRHI、内存文件系统与种子化 PRNG 四类假对象,拼出一个无 GPU、无磁盘、无真实时钟的内核测试台,让同一关卡每次跑出逐位一致的结果。全部代码在 Node 直跑,17 条用例锁住行为。
全书的最后一节:给出三条能真正走下去的路——去读 three.js、Bevy、Godot、Filament 这些真实引擎的源码该从哪个文件入手,怎么给开源项目提第一个 PR,以及如何按本书四卷的顺序做自己的引擎并用一个自检脚本量进度。同时明确本书不覆盖的边界:地形流式、网络同步、光追与全局光照、体积云与水体,都指向站内既有文章。
全书收官的第一节,把四卷 60 章拉成一张「一个引擎由哪些原理构成」的全景图:内核的内存、句柄、容器、数学、数据导向、ECS、任务与反射,渲染的 RHI、剔除、材质、光照、帧图与后处理,子系统的资源、动画、物理、音频、输入与编辑器,工程端的构建、测试、性能、跨端与裁剪。每一章都给出直达代表节的链接,并用本机实跑的小工具把 helio 的真实体量盘出来。
《微光岛》不可能把整包都塞进首屏。本节写一个分包器:入口 chunk 只装从启动入口静态可达、且不穿过懒加载点的模块,其余按懒加载根切成独立 chunk。实测首包 19517 字节(gzip 6558)、整包 29212 字节;不上提共享依赖则整包膨胀到 47439 字节。
同一张 256×256 地形贴图,在显存里是 262144 字节的裸 RGBA,无损 PNG 压到 64992 字节,BC1 块压缩只留 32768 字节但 PSNR 掉到 37 dB。本节用 Node 从零实现 PNG 编码器与 BC1 编解码,量出三种产物的真实体积、压缩率与解码耗时,全部本机实跑。
卷四开篇,先把前三卷的 helio 引擎变成一个可分析的产物:用 module-graph 扫出 187 个模块的依赖图,量出「源码 → 打包 → 压缩」三段真实体积,再用符号级 tree-shaking 删掉入口用不到的顶层导出。全部代码在 Node 直跑,拼出来的 bundle 与原始 demo 逐位相同。
命令不能直接改文档的内部结构,否则脏标记与重绘会被绕过。本节把命令接到第 8 章的文档模型上:文档是唯一编辑目标,命令经它落值、打脏标记、发变更通知;SetProperty 与 AddNode 两条命令演示属性与结构两种脏标,并附本机真跑结果。
一次「多选后一起改属性」必须整体撤销,一次拖拽滑条刷出的几十条微编辑却该合并成一步。本节实现带原子性的事务(任一子命令抛错就补偿回滚)、按时间窗合并的连续编辑判据,并讨论撤销栈的条数上限与字节预算,全部代码在本机真跑并附 9 条 vitest 用例。
编辑器最让人信任的能力是 Ctrl+Z。本节把一次编辑封装成带 do/undo 的 Command 对象,从零实现命令栈的撤销、重做与合并判定,并用 7 条 vitest 用例把 do/undo/redo、redo 链断裂、同目标合并与栈上限逐条钉死。
关卡怎么存盘才既好读又耐改?本节把 8.2 的场景文档写成《微光岛》的 JSON 关卡格式:节点之间用稳定 uid 互相引用而不是数组下标,文件头带 format 与 version,加载时按版本迁移、遇到更高的版本直接拒绝,并用一组用例真跑往返与迁移。
一份可编辑的场景文档长什么样?本节用扁平节点表存节点树,区分运行期句柄 id 与稳定标识 uid,把「改了一个节点,哪些东西要重算」交给脏标记来回答:任何改动沿父链向上标脏到根,刷新时按祖先先于后代的顺序处理。每次改动还追加一条变更记录,作为第 9 章撤销栈的接缝。
编辑器与运行时到底共用什么、隔离什么?本节用一份共享的场景数据模型,把编辑态与运行态拆成两份实例:进入 Play 时深拷贝文档,运行时只改副本,Stop 后编辑态原样恢复。最后用一组 vitest 用例把快照、暂停与幂等停止在本机真跑一遍,并说明为何场景文档用扁平节点表而不是 ECS。
本节不承诺真正的 HMR,只做「模块边界 + 数据与代码分离 + 状态迁移」的数据路径:可替换模块把状态导出成纯数据加版本戳,替换时沿迁移链升到新版本再灌进新实例。状态迁移逻辑用 vitest 真跑,浏览器里的动态 import 与真实刷新时序标为 L4。
脚本不是魔法,就是一组可选回调。本节把 onCreate / onUpdate / onDestroy 接到 ECS 实体的世代句柄上,用状态机挡住误用,让调度顺序由优先级与挂载次序决定,并把帧内卸载延迟到帧末以免遍历中改表;全部用 vitest 真跑。
组件字段要能被脚本与编辑器读写,就不能靠字符串硬编码。本节把内核的类型描述符接到组件实例上,实现带类型强制、只读标记与隐藏字段的属性绑定,并用 vitest 把类型强制、快照与变更比对在本机真跑一遍,给出可供撤销栈与多选编辑复用的最小变更集。
资产浏览器要把引擎眼里的扁平「路径→资产」解释成一棵可展开的树,关卡编辑则把《微光岛》的关卡数据装进编辑器并校验引用。本节实现资产树构建、类型判定、稳定 GUID、拖放载荷编解码与关卡加载,并在本机真跑 13 条 vitest 用例;拖放交互属 L4,需在浏览器中手动验证。