《游戏引擎实现原理》4.1 为什么要自己管内存

本节把「为什么要自己管内存」从「避开 GC」纠正为「控制分配节奏、让布局可预测」,并用一个真跑过的基准说明差异:1M 个四字段对象在 JS 堆上占约 61 MB,而 6 条 1M 元素的 TypedArray 堆增量是 0.0 MB——因为 TypedArray 的 buffer 内容不走 JS 堆。

4.1 为什么要自己管内存

上一章我们给 helio 装上了断言、日志分级与 CVar 配置系统。从这一节开始,进入整个内核里最容易讲错、也最容易被口水话糊弄的一块:内存。

网上讲「游戏引擎为什么要自己管内存」的文章,几乎都会先甩一句「为了避开 GC」。这句话不算错,但它把问题讲浅了。在 TypeScript / JavaScript 里,你其实没有办法真正避开 GC——你写的每一个对象、每一个闭包、每一条字符串,都归垃圾回收器管。真正能做的,是把「会走 GC 的数据」压缩到极小,把「大块的、按帧流转的数据」搬到一个 GC 够不着的地方。这一节的目标就是把这件事讲清楚,并且用一次真跑过的实测给出数字。

本节不含任何分配器实现。它只回答一个问题:helio 为什么要把「内存」当成一等公民来设计?答案不是「快」,而是「分配节奏可控 + 布局可预测」。分配器的代码在 4.2,对齐与布局在 4.3。

本章与站内既有内容的分工

站内已经有一批文章从不同角度讲过内存,先把边界划清楚,避免重复:

既有文章它负责什么本节只做什么
/defold-object-pooling-memory/从 Defold 引擎内置对象池出发,讲内存构成、工厂与动态加载、Lua 表池写法、引用计数与泄漏排查清单不碰引擎 API,只从语言运行时层面量出「对象 vs TypedArray」的堆代价
/graphics-texture-memory/纹理采样、mipmap、压缩格式与 GPU 显存寻址只讲 CPU 侧一块 ArrayBuffer 怎么被手工布局,不涉及纹理格式
/game-engine-architecture-ecs/ECS 引擎骨架的「是什么 / 怎么选」只做「从零搭一个分配器、跑出真实数字」

一句话:别人讲「是什么、怎么选」,本章讲「怎么从零搭出来、跑出什么结果」。

重新定位:控制分配节奏,而不是「避开 GC」

先把「避开 GC」这个说法拆开。GC 的成本不在「回收」,而在何时回收、回收多少、有没有大块存活对象。对一个 60 FPS 的游戏,一帧的预算是 16.7 ms,而一次全量 GC(major GC)在桌面端动辄几毫秒到几十毫秒,落在哪一帧上,那一帧就会掉。于是引擎要解决的其实是三个问题:

  1. 分配节奏:能不能把「每帧都新建一堆对象」改成「一帧只在固定的几个点分配」?
  2. 布局可预测:同一份数据,在内存里是不是连续的?能不能一次 memcpy 式地整块搬给 GPU?
  3. 生命周期可预测:这批临时数据,是不是能整批释放,而不是逐个等 GC?

这三条里,第 2 条是最硬的约束。GPU 要的是一段连续的字节:顶点缓冲、索引缓冲、常量缓冲,都得是一块线性内存。JS 的普通对象做不到这件事——它们的字段散落在堆上,每个对象还带一个隐藏类指针,你没法把它「原样」交给显卡。

顺便澄清一个常见的误解:「手动管内存」不等于「手动释放内存」。 在 C++ 里你 new 了要记得 delete,在 Rust 里你要跟借用检查器搏斗;而在 helio 里,内存的所有权始终归那块 ArrayBuffer,我们只是决定「这块 buffer 什么时候用、用多少、什么时候整块重置」。没有悬垂指针的风险,也没有手动 free 的负担——这恰恰是「用 TypeScript 从零写引擎」相比写原生引擎时,可以放心享受的一层安全网。

而 TypedArray(Float32Array、Uint32Array……)背后的 ArrayBuffer 恰好就是一段连续的字节。它的内容存在 JS 堆之外,GC 不会去遍历这几十兆的数据,你也能把它整块传给 GPUBuffer。这才是 helio 把内存设计成核心的立足点。

这里要特别说清楚一件事:helio 不会去写一个真正的 malloc。 在一个自带 GC 的运行时里,绕过 GC 去手工申请系统内存,只会把安全性和可移植性一起赔进去。我们真正要建立的,是「分配发生在哪里、由谁负责、什么时候回收」这套纪律——底层仍然是 ArrayBuffer,只不过分配这件事由我们显式调度,而不是交给 GC 随机决定。

可运行产物:1M 元素,AoS 与 SoA 的堆增量实测

光说没用,我们量一次。下面这个 Node 脚本对比两种存法在同样 1M 元素下的 JS 堆增量(process.memoryUsage().heapUsed)与分配耗时。第一组是 AoS(Array of Structs,一堆独立小对象),第二组是 SoA(Struct of Arrays,几条 TypedArray)。脚本用 --expose-gc 手动触发一次 GC 再采样,减少噪声。

代码文件是 bench/alloc-cost.mjs,属于基准层(tier: B),用 Node 直接跑,不需要编译:

// file: bench/alloc-cost.mjs
// tier: B
// verify: node --expose-gc bench/alloc-cost.mjs
//
// 实测「1M 个小对象数组(AoS)」与「1M 元素 TypedArray(SoA)」
// 在 JS 堆上的增量(process.memoryUsage().heapUsed)与分配耗时。
// 结论要点:TypedArray 的 buffer 内容不走 JS 堆,堆增量接近 0。

import { performance } from 'node:perf_hooks';

const N = 1_000_000;
const MB = 1024 * 1024;

function heapBytes() {
  if (typeof globalThis.gc === 'function') globalThis.gc();
  return process.memoryUsage().heapUsed;
}

function measure(label, build, sinkOf) {
  const before = heapBytes();
  const t0 = performance.now();
  const keep = build();
  const ms = performance.now() - t0;
  const delta = heapBytes() - before;
  let sink = 0;
  sinkOf(keep, (v) => (sink += v)); // 读一遍防止被优化掉
  return { label, heap: delta, perElem: delta / N, ms, sink };
}

const aos4 = measure(
  'AoS  1M 个 {x,y,z,color}(4 个 double 字段)',
  () => {
    const a = new Array(N);
    for (let i = 0; i < N; i++) a[i] = { x: i * 1.0, y: i * 2.0, z: i * 3.0, color: 1.0 };
    return a;
  },
  (a, add) => { for (let i = 0; i < N; i++) add(a[i].x); },
);

const aos6 = measure(
  'AoS  1M 个 {x,y,z,u,v,color}(6 个 double 字段)',
  () => {
    const a = new Array(N);
    for (let i = 0; i < N; i++) {
      a[i] = { x: i * 1.0, y: i * 2.0, z: i * 3.0, u: 0.5, v: 0.25, color: 1.0 };
    }
    return a;
  },
  (a, add) => { for (let i = 0; i < N; i++) add(a[i].x); },
);

const soa4 = measure(
  'SoA  4 条 TypedArray(x/y/z f32 + color u32)',
  () => {
    const x = new Float32Array(N), y = new Float32Array(N), z = new Float32Array(N);
    const color = new Uint32Array(N);
    for (let i = 0; i < N; i++) { x[i] = i; y[i] = i * 2; z[i] = i * 3; color[i] = 0xffffffff; }
    return { x, y, z, color };
  },
  (k, add) => { for (let i = 0; i < N; i++) add(k.x[i]); },
);

const soa6 = measure(
  'SoA  6 条 TypedArray(x/y/z/u/v f32 + color u32)',
  () => {
    const x = new Float32Array(N), y = new Float32Array(N), z = new Float32Array(N);
    const u = new Float32Array(N), v = new Float32Array(N);
    const color = new Uint32Array(N);
    for (let i = 0; i < N; i++) {
      x[i] = i; y[i] = i * 2; z[i] = i * 3; u[i] = 0.5; v[i] = 0.25; color[i] = 0xffffffff;
    }
    return { x, y, z, u, v, color };
  },
  (k, add) => { for (let i = 0; i < N; i++) add(k.x[i]); },
);

for (const r of [aos4, aos6, soa4, soa6]) {
  console.log(
    `${r.label}\n  heapUsed 增量: ${(r.heap / MB).toFixed(1)} MB  (${r.perElem.toFixed(1)} B/元素)  耗时: ${r.ms.toFixed(1)} ms`,
  );
}

本机(Node 26.5.0)跑出来的原始数据如下。这些是单机实测值,绝对数值不可移植——它们取决于 V8 版本、heapUsed 的采样时机与对象的具体形状;你要关心的是量级与比值,不是小数点。

存法1M 元素的堆增量每元素分配耗时
AoS,{x,y,z,color} 四字段对象61.0 MB64.0 B26.5 ms
AoS,{x,y,z,u,v,color} 六字段对象106.8 MB112.0 B34.1 ms
SoA,4 条 TypedArray0.0 MB0.0 B4.4 ms
SoA,6 条 TypedArray0.0 MB0.0 B5.1 ms

命令是 node --expose-gc bench/alloc-cost.mjs,逐条复跑都能得到同一量级。堆增量两次跑几乎一致(61.0 / 106.8 / 0.0 / 0.0 MB),耗时则有 ±20% 的抖动,属正常现象。

读这张表时请抓住两条对照:横向比 AoS 与 SoA(同样的元素数,堆增量差了几个数量级),纵向比四字段与六字段(同一类存法,字段数一变,单元素成本几乎翻倍)。前者说明「放在哪里」比「放什么」更关键,后者说明「对象形状」是一个你无法回避的变量。

这些数字说明什么

第一,TypedArray 的堆增量是 0.0 MB。 不是「很小」,是采样精度内测不出。6 条 1M 元素的 Float32Array 一共 24 MB 的字节,全部落在 ArrayBuffer 里,而 ArrayBuffer 的数据段不计入 heapUsed。GC 遍历堆时看不到这 24 MB,也就不会因为它产生停顿。这正是「buffer 内容不走 JS 堆」的字面含义。

第二,普通对象的代价与字段数强相关,而且不是线性的。 四字段对象 64 B/元素,六字段对象跳到 112 B/元素。多两个 double 字段,每元素多出 48 字节——因为超过一定字段数后,V8 不再把数值内联存进对象体内,而是改成「存一个指向装箱数值的指针」,一个字段的代价从 8 字节涨到十几字节。你没法从源码层面控制这件事,只能通过减少对象数量来绕开它。

第三,分配耗时的差距同样明显。 61 MB 的对象分配要 26.5 ms 上下,而 4 条 TypedArray 只要 4.4 ms 上下。对象分配每次都要走「查隐藏类 → 找空闲槽 → 填字段」的路径,还要维护可达性;TypedArray 的分配则是「申请一块字节,把指针记下来」,中间的写入是纯数值操作,不产生任何 GC 可见的垃圾。

第四,SoA 的 0.0 MB 并不意味着「零成本」。 那 24 MB 字节是真实存在的,只不过它住在 ArrayBuffer 里,不计入 heapUsed。所以这个基准量的是「对 GC 的压力」,不是「总内存占用」。如果你的目标是降低总内存,SoA 未必更省——它省的是 GC 压力与分配次数。把这两件事混为一谈,是另一个常见误区。

把上面这些合起来,就是 helio 的内存策略:

  • 大块、连续、按帧流转的数据(顶点、变换矩阵、临时缓冲)→ 放进 ArrayBuffer,用 TypedArray 视图读写;
  • 数量少、生命周期长的对象(子系统、配置、句柄表)→ 允许走 GC,但要把数量压到个位数;
  • 中间的「每帧新建又丢弃」 → 用 4.2 的线性 arena 一次性接管,绝不让它们进入堆。

这三条不是拍脑袋定的,而是直接从上面的实测推出来的:第 1 条对应「TypedArray 堆增量为 0」,第 3 条对应「对象分配 26.5 ms vs TypedArray 4.4 ms」的耗时差。每一条策略背后都有一个可复跑的数字,这也是本书坚持「先跑再写」的原因。

这套策略到底能省下什么,用一个具体场景算一遍更清楚。假设你的游戏锁 60 FPS,每帧预算 16.7 ms。一次 major GC 在桌面 V8 上通常是几毫秒量级;如果堆里同时有几十兆的存活对象,一次全量标记往往要更久。关键在于:GC 什么时候触发,你说了不算。你唯一能控制的是「堆上堆了多少可回收垃圾」,而这个量由「每帧分配了多少对象」决定。

设想一个最朴素的做法:每帧给场景里 2000 个实体各算一次世界变换,用普通对象存结果。

  • 每帧新建 2000 个 {x, y, z} 对象;
  • 60 FPS 下,一秒就是 12 万个对象;
  • 一分钟后,堆上已经产生过 720 万个短命对象。

这些对象活不过一帧,但它们的存在会让 GC 越来越频繁地介入,而每一次介入落在哪一帧是随机的。玩家看到的现象不是「平均帧率下降」,而是周期性卡顿——每隔一两秒卡一下。这种「平均 60 FPS 但体感很卡」的现象,几乎总是分配节奏没管好。

helio 的对策不是「少算」,而是「换个地方放结果」:把 2000 个实体的世界变换算进一条 Float32Array(2000 * 16) 里,这条数组的字节在 ArrayBuffer 上,每帧原地覆写、绝不新建。于是 GC 眼里这件事从来没发生过。

注意这里的规模:2000 * 16 = 32000 个 float,一条 128 KB 的 Float32Array 就够了,而且这条数组只创建一次。对比之下,「每帧 2000 个对象」在同样的时间窗口里会产生指数级增长的垃圾量。这不是「优化了几个百分点」,而是「把 GC 从关键路径上整个拿掉」——两者的量级完全不同。

一个直观的自检方式:打开 DevTools 的 Memory 面板,录一段游戏运行时的 Allocation Timeline。如果看到一条持续上升的锯齿线,说明每帧都在制造垃圾;理想状态应该是接近水平的直线,只有在加载关卡这种明确的时刻才出现台阶。helio 的内核设计目标,就是让主循环里那条线尽量平。

AoS 与 SoA 不是「二选一」,是「按访问模式选」

上面把 AoS 说得很惨,但别急着把项目里所有对象都改成数组。两者的差异不在「好」与「坏」,而在「访问模式」:AoS 擅长「一次拿一个对象的全部字段」,SoA 擅长「一次拿很多对象的同一个字段」。两者解决的是不同问题:

场景更适合原因
对象数量少、字段多、需要相互引用AoS(普通对象)可读性好,entity.velocity 直白;几十个对象对 GC 无感
数量上万、逐字段批处理(位置更新、碰撞)SoA(TypedArray)连续内存、可整块遍历、零堆分配
需要整块上传给 GPUSoA只有连续字节能直接映射成 GPUBuffer
需要动态增删、引用计数AoS 或句柄表见第 6 章的 slot map

helio 的分工是:子系统、配置、句柄表这类「数量少、活得久」的用 AoS;每帧参与批处理的数值数据用 SoA。 4.2 的 arena 和 4.3 的顶点布局,都是为后半句服务的。

还有一个常被忽略的中间态:对象数量中等、但每帧创建销毁的数据(比如每帧的绘制命令、临时碰撞对)。这类数据既不适合用长寿命的 SoA 常驻数组(因为大小每帧都在变),也不适合用 AoS(因为会产生大量垃圾)。它们的正解是「一块按帧复用的 scratch buffer」——这正是 4.2 里 ScratchArena 要解决的事。

什么时候不该手动管内存

前面几节都在讲「为什么要管」。这一节反过来划边界。GC 语言里讲手动内存,最容易犯的错不是「不敢管」,而是「什么都想管」——把本来交给 GC 更省心的地方也搬进 ArrayBuffer,结果是代码变复杂、bug 变多,性能却一点没动。

先给结论:手动内存的收益,只在 热路径 + 高复用 + 布局敏感 这三条同时成立时才出现。缺任何一条,多出来的复杂度都会把收益吃掉。

判断维度该手动管不该手动管
调用频率每帧都跑的路径(变换、碰撞、绘制命令)每局只跑几次的初始化、加载
数据规模上万级元素,且每帧原地覆写几百到几千个对象,GC 完全够用
生命周期长寿命、跨帧复用(一条 buffer 只建一次)一次性任务,跑完就丢
布局要求要连续字节,要整块给 GPU 或整块 memcpy只需按字段随机访问,不要求连续
团队成本有明确 owner,能长期维护这套纪律多人协作,谁都要能一眼看懂

逐条展开:

一、小数据量下 GC 完全够用。 一个几百到几千个对象的场景,就算每帧全新建对象,产生的垃圾量对现代 GC 也只是噪声。前面那张表里的 61.0 MB 是 1M 元素的量级;缩到 2000 个对象,堆增量连 1 MB 都不到,根本触发不了可感知的停顿。这时候引入 arena,是在用复杂度换一个你量不出来的收益。

二、短生命周期、一次性任务不值得建池。 加载关卡、解析配置、烘焙导航网格这类「跑一次就结束」的逻辑,哪怕临时对象很多,只要它们在这段逻辑结束前全部变成垃圾、不进入主循环,就交给 GC。建池的意义是「跨帧复用同一块内存」,一次性任务里没有「复用」可言。

三、可读性与团队协作是真实成本。 SoA 的可读性天然比 AoS 差:positions[i * 3 + 1] 远不如 entity.y 直白。对象池还要求每个使用者都记得「用完归还」,一旦有人忘了还,泄漏会以「池慢慢耗尽」的形式出现,排查成本很高。这类成本不会出现在 benchmark 里,但会出现在维护账单上。

四、警惕过早优化。 在没量过之前,你不知道瓶颈在哪。前面那组基准之所以有意义,是因为它测的是「1M 元素、每帧流转」这个明确的热路径;如果把它当成「所有对象都该搬出堆」的通用结论,就是拿一把尺子去量所有东西。正确的顺序永远是:先写对,再量,最后才优化热点。

把这四条反过来读,其实是在提醒一件事:手动管内存是有前置条件的。 你得先有一个被量出来的热点,再有一个稳定的复用窗口,最后还得有「连续布局」这个硬需求把 GC 挡在外面。三条里少一条,前面那些池、那些 TypedArray,就只是「看起来更专业」而已。

还有一种情况值得单说:性能还没成为问题的时候,任何内存优化都无从验证。 没有 profile、没有基准、没有可观测的数字,你改完之后既不知道有没有变快,也不知道有没有变慢。这种「盲改」是过度手动化最常见的入口——它不是坏代码,而是无法证伪的代码。本书之所以每个策略后面都跟一个可复跑的数字,就是为了避免走到这一步。

顺带说清一个容易混淆的点:「该管」和「不该管」不是两拨人、两套代码,而是同一份数据在不同阶段的不同处理方式。 一块顶点数据在加载期是「一次性任务」,可以随便用临时对象拼装;一旦进入主循环,它就该是一块常驻的连续 buffer。判断依据不是数据类型,而是它此刻处在哪条路径上。

几个常见的过度手动化,供你对照自己有没有踩到:

  • 把只有几十个实例的配置表、注册表搬进 TypedArray,只为了「统一」;
  • 给只在加载期创建一次的顶点数据也套一层对象池;
  • 为了省下几百个临时对象,把一段本可以直白写成 map / filter 的逻辑改写成手工游标;
  • 在没有任何 profile 数据的情况下,先按「以后可能会卡」把整个数据层重写一遍。

这些做法本身不算错,但它们都在没有收益的地方付了复杂度。判断标准很简单:如果拿掉它,性能数字没有变化,那就该拿掉。

落到日常判断上,可以只问自己三个问题:

  • 这块数据是不是每帧都在跑?不是,就交给 GC;
  • 它是不是一条能跨帧复用的常驻 buffer?不是,就交给 GC;
  • 它是不是要整块上传或整块遍历?不是,就交给 GC。

三个问题里只要有一个答「否」,就别急着把它搬出堆。前面小结里那三条策略本身就是分层的——子系统、配置这些「数量少、活得久」的对象仍然走 GC;真正被搬进 ArrayBuffer 的,只有「大块、连续、按帧流转」的那一类。

反过来也成立:即使决定手动管,也没必要「全盘手动」。手动管内存不是「接管全部内存」,而是「把最贵的那一小块接管过来」,剩下的一概交回 GC,才不至于让纪律变成负担。这正是下一节 arena 要兑现的承诺:只接管主循环里那一小块反复分配的内存,其余什么都不碰。

动手自检清单与引用须知

在往下走之前,你可以用这几条检查自己的项目是不是已经在「手动管内存」的轨道上:

  • 场景里参与每帧计算的数据,是不是存放在 TypedArray 里,而不是每帧 new 出来的对象数组里?
  • 有没有一个明确的地方,写着「这块 ArrayBuffer 归谁、活多久、什么时候整块释放」?
  • 顶点/索引/常量数据,是不是来自一块连续的 buffer,而不是一堆散落的 JS 对象?
  • 临时缓冲(每帧算完就丢的)有没有被单独隔离出来,方便一次性回收?
  • 有没有为「这块 buffer 一共用了多少、峰值是多少」留一个可观测的数字(4.2 的 highWaterMark)?
  • 关掉 GC 的「自动触发」(或把它调到很宽松)之后,游戏还能不能稳定跑?如果会崩,说明你依赖了隐式回收。
  • 主循环里有没有哪一行 new / [] / {} 是「每帧必执行」的?有的话,它就是下一个要被搬进 buffer 的候选。

只要有一条答「没有」,4.2 的线性 arena 就能帮到你。

这几条不需要一次全做到。真实的项目里,更常见的路径是先拿「每帧必执行的那一行 new」开刀,把它搬进一条常驻的 TypedArray,然后再慢慢把临时的中间结果收进 scratch buffer。一步一步来,比一次性重写整个数据层要稳得多。

反过来,如果你现在维护的项目里,每帧的数据已经老老实实待在 TypedArray 里,那你也别急着引入 arena——没有分配压力的时候,额外的抽象只会增加复杂度。helio 之所以在第一章就强调「按需分层」(1.1 节),原因就在这里:每一层抽象都要换来一个可度量的收益,否则就是负债。

上面那组数字很有说服力,但如果你要引用它,请先读完这三条:

第一,heapUsed 的采样时机直接决定读数。 我们在采样前后各手动触发了一次 GC(--expose-gc),否则上一次分配遗留的垃圾会被算进这一次的增量里。如果你去掉 globalThis.gc() 直接读 heapUsed,同一段代码可能量出明显偏大的数字。这就是为什么脚本必须用 node --expose-gc 跑,而不是普通的 node。

第二,对象的「形状」比对象本身更影响结果。 同一个逻辑数据,写成四字段对象是 64 B/元素,写成六字段对象是 112 B/元素。差别不在「你存了几个数」,而在 V8 有没有把数值内联进对象体。所以「一个对象大概多少字节」这种说法本身就不成立——必须按你的真实字段数和赋值顺序去量。

第三,绝对数值不可移植,但比值稳定。 61 MB、106.8 MB、0.0 MB 是本机 Node 26.5.0 的读数;换台机器、换个 V8 版本都会变。但「TypedArray 的堆增量是 0」这个结论在所有版本上都成立,因为它是由 ArrayBuffer 的存储位置决定的,不是由优化器心情决定的。引用时请引用结论,不要引用小数点。

把这三条合起来看,其实就是一句话:这个基准量的是「GC 压力」,不是「内存总量」。 你要用它来说服别人「把数据搬出 JS 堆」,它是够格的;但别用它去论证「SoA 一定比 AoS 省内存」——那需要另一组测量。搞清楚一个数字能证明什么、不能证明什么,比记住这个数字本身更重要。

小结

本节没有写一行分配器代码,也没有碰对齐与布局。它只做了两件事:把「为什么要自己管内存」的动机从「避开 GC」校正为「控制分配节奏 + 让布局可预测」,并用一次实测把「TypedArray 的内容不在 JS 堆上」这句话钉死在数字上。接下来两节才会进入实现:4.2 写线性 arena 与栈式分配器,4.3 写对齐、padding 与真实布局。

如果你只想记住一句话,那就是:手动管内存的目标不是「不用 GC」,而是「让 GC 看不到你」。 把大块的、每帧流转的数据放进 ArrayBuffer,剩下的零星对象留给 GC,两者各司其职,主循环才能平稳。

下一节我们就动手写这个 arena:它本质上就是「一块 ArrayBuffer + 一个只前进的偏移量」,代码短到能一眼看完,但它把上面三条纪律全部落到了实处——分配发生在你指定的地方,回收由你显式触发,布局天然连续。这正是「可控」二字在代码层面的样子。

阅读导航:上一节:目录 · 下一节:4.2 线性 arena 与栈式分配器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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