《游戏引擎实现原理》1.1 打包与产物分析

卷四开篇,先把前三卷的 helio 引擎变成一个可分析的产物:用 module-graph 扫出 187 个模块的依赖图,量出「源码 → 打包 → 压缩」三段真实体积,再用符号级 tree-shaking 删掉入口用不到的顶层导出。全部代码在 Node 直跑,拼出来的 bundle 与原始 demo 逐位相同。

1.1 打包与产物分析

前三卷把 helio 引擎的每一块都讲透了:内核、渲染、子系统。但它们一直以「一堆 .ts 文件」的形态躺在仓库里——src/ 下 187 个非测试模块,谁依赖谁、一个可执行入口真正会带上多少代码,没有人量过。工程化的第一件事就是把源码变成一个可度量的产物:先知道入口拉起了哪些模块,再知道这份产物有多大、压缩后多大、有多少是白带进来的死代码。没有这三个数字,后面所有「优化」都是凭感觉。

这一节只做度量,不做优化。我们要写两个 Node 直跑的工具:一个把 src/ 扫成模块依赖图,一个在图上算出「源码字节 → 打包字节 → 压缩字节」三段体积,并用符号级 tree-shaking 找出入口用不到的顶层导出。数字全部来自本机实跑,不估、不猜。度量先行也是为了让后面每一处优化都有据可依——优化前后体积差了多少字节,这套工具随时能回答。

为什么从体积开始?因为《微光岛》的交付目标里有「首包要小、加载要快」这一条,而首包大小取决于两件事:入口真正需要哪些模块,以及打包器能不能把用不到的导出摇掉。这两件事都建立在同一张模块图上,所以先建图。

有人会问:真实项目直接上 esbuild 或 Vite 不就行了,为什么要自己写分析器?两个原因。其一,本书的约束是每个代码块都能在本机 Node 直跑、不依赖外部安装——引入一个打包器就等于把「可复现」押在它的版本与配置上。其二,分析体积这件事本身不需要一个完整的打包器:我们关心的是「哪些模块被拉进来」「各占多少字节」「压缩后多大」,这些只依赖静态的依赖关系与文件字节数,一个两百行的扫描器就够了。真正的 esbuild 强在生成可部署产物(处理 CSS、worker、外部依赖、目标降级),那是第 8 章构建部署的事;本节只做度量,所以自己写反而更透明。

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

站内已经有不少「方法级」的内容,本卷和它们的边界要划清楚——本卷只做「把前三卷的引擎工程化跑起来 + 真实构建/测试/性能数字」,不重推原理。

站内既有内容它讲什么本卷《工程与实战》做什么
/graphics-gpu-profiling-debugging/GPU 性能分析的方法论在真实 helio 产物上量体积、压缩率与加载耗时
/game/engine/ 顶层综述引擎选型与架构综述不选型,直接把前三卷的引擎工程化交付
/posts/typescript/TypeScript 语言与工程用 Node 直跑的构建工具链,量真实字节

换句话说:讲「怎么测 GPU 耗时」的去看 graphics-gpu-profiling-debugging;讲「该不该自己写引擎」的去看 game/engine;而「helio 的入口到底带了多少 KB、怎么把它压到首包能接受」——这是本卷的事。

模块图:打包分析的地基

打包器的第一步永远是建图。它从入口出发,顺着 import 语句把一个个模块连成一张有向图;只有站在这张图上,「入口可达集」「共享依赖」「死代码」才有定义。我们不引入任何第三方打包器,而是自己写一个构建期扫描器——它不执行代码,只用正则解析 import / export 语法,把每个模块的字节数、直接依赖、以及「从每个依赖里按名字取了哪些符号」记下来。

// file: 块都是完整可编译的文件。下面这个文件是整节的地基,后面 1.3 的分包器也会 import 它。

// file: tools/module-graph.mjs
// tier: L1
// verify: node tools/module-graph.mjs
//
// 构建期静态分析:把 src/ 下的 ES 模块扫成一张依赖图。不打包、不跑代码,
// 只解析 import / export 语法,得到每个模块的源字节数、它直接依赖的模块,
// 以及它从每个依赖里「按名字」取了哪些符号。后面 bundle-analyze 与
// split-chunks 都 import 这里的 buildGraph / reachableModules。

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, dirname, resolve, relative } from 'node:path';

const IMPORT_RE = /(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?[\s\S]*?\s*from\s*['"]([^'"]+)['"]/g;
const NAMED_IMPORT_RE = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
const EXPORT_DECL_RE = /^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
const EXPORT_LIST_RE = /^export\s*\{([^}]*)\}/gm;

/** 递归收集 rootDir 下所有非测试 .ts 文件。 */
export function collectModules(rootDir) {
  const out = [];
  const walk = (dir) => {
    for (const name of readdirSync(dir).sort()) {
      const full = join(dir, name);
      if (statSync(full).isDirectory()) walk(full);
      else if (name.endsWith('.ts') && !name.endsWith('.test.ts')) out.push(full);
    }
  };
  walk(rootDir);
  return out;
}

/** 抽出一个模块的静态依赖说明符、导出符号名、以及「从谁取了哪些符号」。 */
export function parseModule(source) {
  const specs = [];
  for (const m of source.matchAll(IMPORT_RE)) {
    const s = m[1];
    if (s.startsWith('.') || s.startsWith('/')) specs.push(s);
  }
  const exports = new Set();
  for (const m of source.matchAll(EXPORT_DECL_RE)) exports.add(m[1]);
  for (const m of source.matchAll(EXPORT_LIST_RE)) {
    for (const part of m[1].split(',')) {
      const name = part.trim().split(/\s+as\s+/).pop().trim();
      if (name) exports.add(name);
    }
  }
  const uses = [];
  for (const m of source.matchAll(NAMED_IMPORT_RE)) {
    const names = [];
    for (const part of m[1].split(',')) {
      const raw = part.trim();
      if (!raw || raw.startsWith('type ')) continue;
      names.push(raw.split(/\s+as\s+/)[0].trim());
    }
    uses.push({ spec: m[2], names });
  }
  return { specs, exports: [...exports], uses };
}

/** 建图:modules 以「相对 cwd 的路径」为键。 */
export function buildGraph(rootDir) {
  const files = collectModules(rootDir);
  const byAbs = new Map(files.map((f) => [resolve(f), relative(process.cwd(), f)]));
  const modules = new Map();
  for (const f of files) {
    const source = readFileSync(f, 'utf8');
    const { specs, exports, uses } = parseModule(source);
    const imports = [];
    const seen = new Set();
    for (const s of specs) {
      const dep = byAbs.get(resolve(dirname(f), s));
      if (dep !== undefined && !seen.has(dep)) {
        seen.add(dep);
        imports.push(dep);
      }
    }
    const deps = [];
    for (const g of uses) {
      const dep = byAbs.get(resolve(dirname(f), g.spec));
      if (dep !== undefined) deps.push({ dep, names: g.names });
    }
    modules.set(byAbs.get(resolve(f)), {
      path: byAbs.get(resolve(f)),
      bytes: Buffer.byteLength(source),
      imports,
      exports,
      uses: deps,
    });
  }
  return { modules };
}

/** 模块级可达集:从 entry 出发静态能到达的所有模块路径(含 entry)。 */
export function reachableModules(graph, entry) {
  const seen = new Set();
  const stack = [entry];
  while (stack.length) {
    const cur = stack.pop();
    if (seen.has(cur)) continue;
    seen.add(cur);
    for (const dep of graph.modules.get(cur)?.imports ?? []) if (!seen.has(dep)) stack.push(dep);
  }
  return seen;
}

/** 一组模块的源字节总和。 */
export function totalBytes(graph, paths) {
  let n = 0;
  for (const p of paths) n += graph.modules.get(p)?.bytes ?? 0;
  return n;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const graph = buildGraph('src');
  const entry = 'src/apps/glimmer-isle-demo.ts';
  const reach = reachableModules(graph, entry);
  console.log(`模块总数(不含测试):${graph.modules.size}`);
  console.log(`入口 ${entry} 可达模块:${reach.size}`);
  console.log(`可达源码字节:${totalBytes(graph, reach)}`);
}

直接 node tools/module-graph.mjs 跑一遍,本机输出:

模块总数(不含测试):187
入口 src/apps/glimmer-isle-demo.ts 可达模块:6
可达源码字节:21231

一个数字就说明了很多问题:全仓库 187 个模块,但《微光岛》的可玩 demo(glimmer-isle-demo.ts)从入口出发只静态可达 6 个模块、共 21231 字节。也就是说,模块级的 tree-shaking 已经天然发生了——打包器根本不会把另外 181 个模块带进来,因为它们不在入口的可达集里。这是「按需加载」的地基,1.3 的分包就建立在这张可达集上。

这个扫描器用的是正则而不是真正的 TypeScript 解析器,所以要清楚它的边界。它只认静态的 import ... from '...' 与 export ... from '...',对动态 import()、对字符串拼接出来的模块名、对 require,一概视而不见。对我们的用途这恰好够:helio 的引擎代码全是静态导入,动态导入只出现在游戏层的懒加载点,而那种场景我们会在 1.3 用显式的「懒加载根」清单来建模,不指望扫描器猜出来。真要做通用工具,这里该换成 TypeScript Compiler API 或 esbuild 的 metafile——但那会把一个两百行的脚本变成几百行的工程,得不偿失。

三段体积与 tree-shaking

有了图,就能算体积了。打包器把入口可达的模块按拓扑序(被依赖者在前)拼成一份 bundle,去掉 import / export 语法糖,再用 node:zlib 真压 gzip 与 brotli。同一份模块图还能顺手做符号级 tree-shaking:从入口出发,凡是既没被别的模块 import、又没在本模块内部被引用的顶层导出,整段删掉。下面是 tools/bundle-analyze.mjs 的核心节选——完整文件里还包含读文件、拼字符串等琐碎代码。

// snippet: tools/bundle-analyze.mjs(节选:拓扑拼接与三段体积)
// tier: L1
// verify: node tools/bundle-analyze.mjs

/** 拓扑序:被依赖者在前(Kahn);环会让 Kahn 提前停下,剩余按名字补末尾。 */
function topoOrder(graph, paths) {
  const set = new Set(paths);
  const indeg = new Map([...set].map((p) => [p, 0]));
  for (const p of set) {
    for (const dep of graph.modules.get(p)?.imports ?? []) {
      if (set.has(dep)) indeg.set(p, indeg.get(p) + 1);
    }
  }
  const queue = [...set].filter((p) => indeg.get(p) === 0).sort();
  const out = [];
  while (queue.length) {
    const p = queue.shift();
    out.push(p);
    for (const q of set) {
      if (q !== p && (graph.modules.get(q)?.imports ?? []).includes(p)) {
        indeg.set(q, indeg.get(q) - 1);
        if (indeg.get(q) === 0) queue.push(q);
      }
    }
  }
  for (const p of [...set].sort()) if (!out.includes(p)) out.push(p);
  return out;
}

/** 把一组模块按拓扑序拼成一份 bundle 文本(带分隔注释,便于人工核对)。 */
export function bundle(graph, paths) {
  return topoOrder(graph, paths)
    .map((p) => `// ---- ${p} ----\n${stripModuleSyntax(readFileSync(resolve(p), 'utf8')).trim()}`)
    .join('\n\n') + '\n';
}

跑 node tools/bundle-analyze.mjs,本机实跑输出如下:

== 三段体积:源码 / 打包 / 压缩 ==
全图模块数 187  入口可达模块数 6
源码字节  全图 636208  入口可达 21231
bundle字节 全图 627495  入口可达 20842
gzip字节  入口可达 7230
brotli字节 入口可达 6176

== 符号级 tree-shaking ==
保留导出体 11272 字节,删除未被引用导出体 793 字节
删除样例:src/core/kernel.ts#RenderSubsystem, src/core/fixed-step-loop.ts#interpolate, src/core/arena.ts#ScratchArena

写出 artifacts/glimmer.bundle.ts(20842 字节)

几个结论值得逐条读:

  1. 打包本身就省字节。入口可达源码 21231 字节,拼成 bundle 后是 20842 字节——去掉 import / export 语法糖与空行,直接少了 389 字节,没有任何运行时开销。
  2. 压缩才是大头。20842 字节的 bundle 经 gzip 只剩 7230 字节(34.7%),brotli 更是 6176 字节(29.6%)。源码里大量是重复的标识符与中文注释,字典压缩对它们极其友好——这也解释了为什么「发到线上的首包」永远要看 gzip 后的数字。
  3. 符号级 tree-shaking 只省了 793 字节。被摇掉的三个符号 RenderSubsystem、interpolate、ScratchArena 都是「定义了但入口这条路径没用到」的顶层导出。793 字节不多,但方向对:模块级可达已经把 181 个模块挡在门外,符号级再在留下的模块里抠出没用的导出。

gzip 与 brotli 的差距(7230 对 6176)也值得记一笔。gzip 用固定 32KB 窗口与 DEFLATE 的哈夫曼编码,是所有 HTTP 服务器默认就支持的格式;brotli 的静态字典专门收录了 function、return 这类 JS/TS 高频词,所以对源码文本的压缩率通常再高 10% 到 20%。工程上的结论是:首包按 gzip 估算上限、按 brotli 估算下限,两者都报出来,才不会在切换压缩格式时被体积变化打个措手不及。

值得强调的是第 3 点背后的双重判据:一个顶层导出只有在「外部没人 import」且「本模块内部也没引用」时才能删。kernel.ts 里的 World 类就没被删——因为 Kernel 构造函数内部 new World() 用到了它。只按「有没有被别的模块 import」来删,会把模块自用的符号误删,编译直接报未定义。tree-shaking 从来不是「删掉没被 import 的」,而是「删掉没有任何活引用链到达的」。

还有一点容易被忽略:上面统计的「全图 636208 字节」是把 187 个模块全部算进去的源码体量。它和入口可达的 21231 字节之间的差额,正是「如果不管三七二十一全打包」会多出来的部分。很多团队的首包之所以大,不是因为入口真的用了那么多,而是因为打包配置把整个 src/ 都塞进了一个 chunk,或者某个 barrel 文件 export * from 把一片模块都拖了进来。有了这张图,这类问题一眼可见:只要入口的可达集远小于模块总数,就说明打包器的静态分析在正常工作。

可运行产物:拼出来的 bundle 能跑

前面所有分析都建立在一个前提上:我们拼出来的 bundle 是正确的。验证方式很朴素——把它写成一个 .ts 文件,用 node 直接跑,看输出是否与原始 demo 逐位相同。bundle-analyze.mjs 已经把它写到了 artifacts/glimmer.bundle.ts,执行:

$ node artifacts/glimmer.bundle.ts
[glimmer-isle] frames=120 step=0.016666666666666666 speed=2
[glimmer-isle] run A: x=9.71445146547012e-17 z=2.666666666666667 checksum=5142.666666666668
[glimmer-isle] run B: x=9.71445146547012e-17 z=2.666666666666667 checksum=5142.666666666668
[glimmer-isle] deterministic: x=true z=true checksum=true
[glimmer-isle] PASS: 两次运行逐位相同

把这段输出和直接 node src/apps/glimmer-isle-demo.ts 的输出做 diff,两者完全一致。这说明「扫图 → 拓扑排序 → 去语法糖 → 拼接」这条链没有破坏模块语义:依赖在前,引用先于使用,行为不变。这也顺手验证了 module-graph.mjs 的图是准的——如果依赖边漏了一条或序排错了,拼出来的文件会因为「用了未定义的名字」直接崩掉,而不会安静地跑出同样的校验和。

这一节我们只建立了「度量」这条腿:入口可达集、三段体积、tree-shaking 的真实收益。下一节换一种资源——贴图——看它在磁盘上、压缩后、以及 GPU 块压缩后各是什么体积,解码又要花多少时间。

阅读导航:上一节:回到目录 · 下一节:1.2 资源压缩与纹理管线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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