《游戏引擎实现原理》11.1 缓存层级与访存代价
缓存层级决定了一次访存要等多久:L1 一个周期,主存上百个周期。本节用 1M 元素的连续遍历与指针追逐、以及 4096 方阵的行主序与列主序求和做对照,在本机实测出 2.2 倍与 6.2 倍的差距,并说清在 V8 里数据导向的收益主要在分配与 GC 压力,而不在访存速度。
tag
缓存层级决定了一次访存要等多久:L1 一个周期,主存上百个周期。本节用 1M 元素的连续遍历与指针追逐、以及 4096 方阵的行主序与列主序求和做对照,在本机实测出 2.2 倍与 6.2 倍的差距,并说清在 V8 里数据导向的收益主要在分配与 GC 压力,而不在访存速度。
数值计算的另一类问题是边界而非精度:比较不能用等号、除法会炸出 Infinity、NaN 会顺着运算扩散。本节实现一组数值稳健工具——相对 epsilon 比较、saturate、clamp、safeDiv、sanitize、safeNormalize,并用 vitest 把每个边界行为钉成断言。
定点数把小数点钉死在固定位置,用纯整数运算换来跨平台逐位一致。本节实现 Q16.16:一个 int32 的低 16 位是小数、高 16 位是整数,乘法拆高低位避免溢出。实测同一段弹簧积分跑 10000 步:单精度与双精度相差 2.9e-7,而定点版本跑两遍结果逐位相同。
同一份浮点代码在不同平台上会算出不同结果,根源是加法不满足结合律、FMA 改变了舍入时机、单精度与双精度位宽不同。本节在 Node 里实测:(0.1+0.2)+0.3 与 0.1+(0.2+0.3) 相差 1.1e-16,一段积分跑 10 万步后单精度比双精度差了 0.2,有无 FMA 甚至能把 4.4e-16 变成 0。
《游戏引擎实现原理》卷一附录 D:汇总跟读本卷时最容易踩的坑与高频疑问。覆盖 erasable syntax 的写法替代、句柄失效的排查、内存布局的对齐陷阱、基准数字为何波动、Job System 在 Node 与浏览器的差异、反射为什么不用装饰器元数据,以及「怎么调试」这类实操问题,每条给出原因与解决方向。
《游戏引擎实现原理》卷一附录 B:跟读本卷所需的环境准备与代码复现方法。列出实测的 Node / TypeScript / vitest 版本,给出从零搭建 playground 的完整步骤、推荐的 tsconfig 与 package.json 并逐个解释编译选项、各层代码(L1 运行、L2 无头浏览器、L3 类型检查、B 基准)的跑通命令,以及常见环境问题的排查清单与目录结构约定。
《游戏引擎实现原理》卷一《引擎内核》全书导航页:说明本卷定位、读者画像与前置知识、学习节奏与三条阅读路径,交代贯穿项目《微光岛》与从零引擎 helio,讲清 L1–L4 可运行性口径,并按五个部分列出 18 章 54 节的完整章节索引,另含序、前言、附录 A–D 与常见问题速查表,方便读者按需定位、跳读与随时查阅。
《游戏引擎实现原理》卷一《引擎内核》全 61 篇,用 TypeScript 从零实现 3D 引擎内核 helio:从引擎分层与主循环起步,经内存分配器与对象池、世代句柄与稀疏集、自研容器与游戏数学、数据导向布局、ECS 存储与查询调度、基于 worker_threads 的 Job System 与依赖 DAG、显式注册表反射与序列化,最后装配成引擎,让《微光岛》里出现第一个能走动的方块。
《游戏引擎实现原理》卷一序言:在现成引擎随手可得的今天,为什么还要从零写一个引擎内核。文章用三个真实痛点说明「自己写一遍」换来的判断力,讲清本套书选择 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 里真跑验证。