《游戏引擎实现原理》8.2 构建与部署

把《微光岛》真正构建成可发布的静态产物:一次 vite 生产打包(minify + 内容哈希)、静态资源拷贝、index.html 生成、带 sha256 的产物清单,再用一个校验器重算哈希并做篡改检测。真实 CDN 上传属 L4,本节显式标注。

8.2 构建与部署

上一节把预算量清楚了,这一节把交付物真正造出来。构建(build)是把源码与资源变成一组可发布的静态文件;部署(deploy)是把这组文件放到能被访问的地方。这两件事经常被混为一谈,但它们的可验证性天差地别:构建完全可以在本机跑到底,部署的最后一步却必须落到真实的托管平台上。

本节把这两件事切开:能跑的部分——生产构建、产物清单、内容哈希校验、本地静态服务——全部在本机真跑;不能跑的部分——上传 CDN、配缓存头、绑定域名、上 HTTPS——明确标成 L4,并说清为什么命令行复现不了。

先给一个贯穿全节的判断标准:一次交付是否可信,取决于它能不能被「重算一遍」。构建能不能重算出同一份哈希?清单能不能重算出同一份 sha256?产物能不能被真实 HTTP 取回?能重算的,才有资格进回归流水线;不能重算的,就只能人工放行。

这个标准也解释了为什么本节不追求「一键部署」。一键部署看着很爽,但它把「产物对不对」和「传上去了没有」两件事揉成了一个按钮,一旦出错你分不清是哪一步坏了。本节选择把可重算的部分全跑一遍、把不可重算的部分显式标出来,就是为了让每一步的成败都单独可见。

生产构建:一次打包四件事

tools/build-prod.mjs 把交付拆成四步:vite 生产打包(minify + [name]-[hash] 内容寻址)、拷贝 WGSL 着色器、生成 index.html、写 dist/manifest.json。哈希名意味着文件内容一变、文件名就变,浏览器就能对它做「永久缓存」——这是静态托管的性能地基。

为什么是这四步而不是「一条 vite build 就完事」?因为 vite build 只解决 JS 打包,而一个游戏交付还要带着着色器这类非 JS 资源,还要有一个能被访问的入口 HTML,还要有一份能核对完整性的清单。这四步对应「代码、资源、入口、凭据」,缺任何一步,交付物都不完整。

下面是 tools/build-prod.mjs,L1,Node 直跑。

// file: tools/build-prod.mjs
// tier: L1
// verify: node tools/build-prod.mjs
//
// helio《微光岛》生产构建:vite 打包(minify + 内容哈希)+ 静态资源拷贝 +
// index.html 生成 + dist/manifest.json(每文件 sha256 与字节数)。
// 本机可跑的是「构建产物」;上传到 CDN / 静态托管是 L4,见正文标注。
import { build } from 'vite';
import { createHash } from 'node:crypto';
import { mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, cpSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative } from 'node:path';

const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DIST = join(ROOT, 'dist');
const ENTRY = join(ROOT, 'src/apps/glimmer-isle-demo.ts');

rmSync(DIST, { recursive: true, force: true });
mkdirSync(DIST, { recursive: true });

// 1) 打包 JS:minify + [name]-[hash] 内容寻址。
const result = await build({
  root: ROOT, logLevel: 'error',
  build: { outDir: DIST, emptyOutDir: false, minify: true,
    rollupOptions: { input: ENTRY,
      output: { entryFileNames: 'assets/[name]-[hash].js', format: 'es' } } },
});
const out = Array.isArray(result) ? result[0] : result;
const entryName = out.output.find((c) => c.type === 'chunk' && c.isEntry)?.fileName;
if (!entryName) throw new Error('no entry chunk emitted');

// 2) 拷贝静态资源:WGSL 着色器。
cpSync(join(ROOT, 'shaders'), join(DIST, 'shaders'), { recursive: true, dereference: true });

// 3) 生成 index.html:只引用带哈希的入口,浏览器可长缓存。
writeFileSync(join(DIST, 'index.html'),
  `<!doctype html>\n<meta charset="utf-8">\n<title>微光岛 glimmer-isle</title>\n` +
  `<canvas id="app" width="960" height="540"></canvas>\n` +
  `<script type="module" src="/${entryName}"></script>\n`, 'utf8');

// 4) 清单:递归遍历 dist,对每个文件算 sha256。
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
  e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]);
const files = {};
let total = 0;
for (const abs of walk(DIST).sort()) {
  const buf = readFileSync(abs);
  const rel = relative(DIST, abs).split('\\').join('/');
  files[rel] = { bytes: buf.byteLength, sha256: sha256(buf) };
  total += buf.byteLength;
}
writeFileSync(join(DIST, 'manifest.json'),
  JSON.stringify({ entry: entryName, files, totalBytes: total }, null, 2), 'utf8');

console.log(`entry: ${entryName}`);
for (const [rel, m] of Object.entries(files)) {
  console.log(`  ${rel.padEnd(46)} ${String(m.bytes).padStart(7)} B  ${m.sha256.slice(0, 12)}`);
}
console.log(`files=${Object.keys(files).length} total=${total} B`);
console.log('=== build-prod: dist/ 就绪 ===');

第 1 步里有个容易踩的坑:cpSync 默认保留符号链接。本仓库的 shaders/ 是一个软链,如果不加 dereference: true,拷贝进 dist/ 的会是一个指向仓库外的悬空软链,清单遍历时一读就报 EISDIR。加了这个参数,着色器才被真正复制成实体文件。

四步里最容易被忽视的是第 4 步。很多项目构建完就完事了,产物「长什么样」只有 ls 一眼的印象。清单把这份印象变成数据:每个文件的字节数与哈希都被记下来,谁想改一个字节,哈希就对不上。对静态托管来说,这份清单还有第二个用途——它是缓存失效的凭据:部署时对比新旧清单,只有哈希变了的文件才需要重新上传,其余的直接命中 CDN 缓存。

产物清单与内容哈希

跑 node tools/build-prod.mjs,本机结果(darwin/arm64,Node 26.5.0):

项值
入口 JSassets/glimmer-isle-demo-DX8Z_ZSj.js,6173 B
入口 sha25633afec073049f767459f29a480a4fe9e1fadfe9db85c76627e3e34e68cf4fad2
静态资源29 个 .wgsl(另加 index.html 与清单本身)
文件总数31
总体积51881 B

清单里有两条信息缺一不可:字节数回答「多大」,sha256 回答「是不是这一份」。只有字节数,一次同长度的篡改就能骗过去;只有哈希,就没法判断体积有没有膨胀。两者都记,才能同时守住完整性与预算。

把总体积 51881 B 拆开看,入口 JS 只占 6173 B,其余 4 万多字节几乎全是那 29 个 .wgsl。这和上一节包体预算里「首包 gzip 2.44 KiB」并不矛盾——后者量的是首包(入口 JS),前者量的是整包(含全部着色器)。首包要小是为了快速可玩,整包要全是为了渲染不缺料,两个数字量的是两件事。真上线时,着色器会按关卡懒加载,而不是全塞进首包。

这里要说明一点:静态资源数(29 个着色器)与总体积会随 shaders/ 目录的内容变化——每加一个着色器,文件数与总体积就往上走。所以「文件总数」与「总体积」是快照,而入口哈希、校验逻辑与四步流程才是契约。前者会随资源增长而变,后者不该变。

光有清单还不够,得有东西去核对它。tools/verify-dist.mjs 干三件事:逐个文件重算 sha256 与字节数、检查 index.html 引用的入口存在且等于 manifest.entry、检查 dist/ 里没有未登记的文件。

// file: tools/verify-dist.mjs
// tier: L1
// verify: node tools/verify-dist.mjs
//
// 校验 dist/ 与 manifest.json 是否自洽:逐个文件重算 sha256 与字节数,
// 检查 index.html 引用的入口存在,且 dist 中没有未登记的文件。
// 这是「产物可信」的本地门禁,真实 CDN 上传(L4)不在此覆盖。
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, relative } from 'node:path';

const DIST = join(fileURLToPath(new URL('..', import.meta.url)), 'dist');
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
  e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]);

const manifest = JSON.parse(readFileSync(join(DIST, 'manifest.json'), 'utf8'));
let fail = 0;
const bad = (msg) => { console.log(`FAIL  ${msg}`); fail++; };

for (const [rel, m] of Object.entries(manifest.files)) {
  const buf = readFileSync(join(DIST, rel));
  if (buf.byteLength !== m.bytes) bad(`${rel} 字节不符 manifest=${m.bytes} disk=${buf.byteLength}`);
  else if (sha256(buf) !== m.sha256) bad(`${rel} sha256 不符`);
}

// dist 中每个文件都必须登记(防止漏打包的残留产物)
const onDisk = new Set(walk(DIST).map((p) => relative(DIST, p).split('\\').join('/')));
for (const rel of onDisk) if (rel !== 'manifest.json' && !(rel in manifest.files)) bad(`未登记文件 ${rel}`);

// index.html 引用的入口必须存在
const html = readFileSync(join(DIST, 'index.html'), 'utf8');
const ref = html.match(/src="\/([^"]+)"/)?.[1];
if (!ref) bad('index.html 未引用任何入口');
else if (!onDisk.has(ref)) bad(`index.html 引用的 ${ref} 不存在`);
else if (ref !== manifest.entry) bad(`入口与 manifest.entry 不符:${ref} vs ${manifest.entry}`);

console.log(`checked=${Object.keys(manifest.files).length} files, total=${manifest.totalBytes} B`);
console.log(fail === 0 ? '=== verify-dist: ALL PASS ===' : `=== verify-dist: ${fail} FAILED ===`);
process.exit(fail === 0 ? 0 : 1);

跑 node tools/verify-dist.mjs 输出 checked=31 files, total=51881 B 与 ALL PASS,退出码 0。校验器有没有用,得靠一次故意破坏来证明:往 dist/index.html 追加一行注释(11 字节),重跑校验器立刻报 FAIL index.html 字节不符 manifest=205 disk=216、退出码 1;再跑一次构建把产物复原,又回到 ALL PASS。能抓住篡改的校验器才叫校验器,只会在正确输入上打勾的那种,等于没有。

构建可复现性:同一份源码,同一份哈希

内容哈希只有在「构建可复现」时才真正有价值。如果同一份源码每次构建出的哈希都不同,那 [name]-[hash] 不但不能做长缓存,反而会让每次部署都全量失效。

实测:连续跑三次 node tools/build-prod.mjs,入口文件名三次都是 assets/glimmer-isle-demo-DX8Z_ZSj.js——哈希完全一致。这说明这条流水线在当前范围内是可复现的:没有嵌入时间戳、没有随机数、没有依赖构建机器的绝对路径。

这里要克制,不能把话说过头。可复现是有前提的:同一个 Node 版本、同一个 vite 版本、同一份锁文件。换了工具链小版本,minify 的输出就可能差几个字节,哈希随之改变——这不是 bug,而是「构建输入变了」的正常结果。所以真正该进回归的是「同一环境内哈希稳定」,而不是「跨环境哈希相同」。这也是为什么本节的构建产物只在本机验证,跨环境的一致性属于 L4 的范畴。

产物等价性:minify 不能改变行为

哈希对得上,只证明「产物是这份产物」,不证明「产物是对的」。minify 会重命名变量、折叠常量、删掉死代码——任何一步做错,产物都可能语法合法却行为不对。所以在部署前,还得让产物自己跑一遍。

本机做法是把构建出的入口直接交给 Node:node dist/assets/glimmer-isle-demo-*.js。它打印的五行输出(frames、两次 run 的位置与校验和、determinism 判定)与直接跑 node src/apps/glimmer-isle-demo.ts 逐字一致,其中 run A 与 run B 的 checksum=5142.666666666668 在两次运行里相同,deterministic: x=true z=true checksum=true 也成立。产物的实际输出如下。

[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: 两次运行逐位相同

这一步的意义在于:它把第 2 章 2.3 的「确定性回放」复用到了产物上。源码层逐位相同还不够,发布出去的那份字节码也必须逐位相同。如果哪天引入了一个会在 minify 阶段被错误折叠的表达式,这个冒烟测试会第一时间报警。

静态托管:本机可验证到哪一步

构建产物是一组纯静态文件,托管本来就不需要应用服务器。本机可以起一个 loopback 静态服务,把「产物确实可被 HTTP 提供」这一步验证掉。

下面是 tools/serve-dist.mjs,L1,Node 直跑。

// file: tools/serve-dist.mjs
// tier: L1
// verify: node tools/serve-dist.mjs
//
// 本地静态服务冒烟:起一个 loopback http 服务,用 fetch 取回 dist/ 的
// index.html 与清单里的入口 JS,证明产物可被 HTTP 提供。
// 仅本机验证,不对外;真实 CDN 上传是 L4。
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';

const DIST = join(fileURLToPath(new URL('..', import.meta.url)), 'dist');
const srv = http.createServer((req, res) => {
  const p = req.url === '/' ? '/index.html' : req.url;
  try { res.end(readFileSync(join(DIST, p))); }
  catch { res.statusCode = 404; res.end('nf'); }
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;
const entry = JSON.parse(readFileSync(join(DIST, 'manifest.json'), 'utf8')).entry;
for (const p of ['/', '/index.html', `/${entry}`]) {
  const r = await fetch(`http://127.0.0.1:${port}${p}`);
  console.log(`${p.padEnd(46)} ${r.status}  ${(await r.text()).length} chars`);
}
srv.close();
console.log('=== serve-dist: 产物可被 HTTP 提供 ===');

本机实跑结果:/ 与 /index.html 各返回 200、199 字符,清单里的入口 JS 返回 200、6111 字符。此外,把构建出的入口 JS 直接交给 Node 跑(node dist/assets/glimmer-isle-demo-*.js),输出与源码直跑逐字一致——这证明 minify 没有改变行为,产物是「等价可执行」的。

到这里,本机能做的全做完了。再往下的每一步都属于 L4——本节需在真实设备 / 浏览器 / 托管平台上手动验证,命令行不可复现:

  • 上传到静态托管(Vercel / Cloudflare Pages / 对象存储):需要账号与凭据,本机没有。上传本身可以用 CLI 自动化,但「用哪个账号、传到哪个环境」是一次人工决策。
  • 缓存策略:给带哈希的 assets/* 配 Cache-Control: max-age=31536000, immutable,给 index.html 配 no-cache。这要在托管平台控制台或 CDN 规则里配,命令行改不了;配错了的后果是「用户永远拿到旧版」,而且很难在本地复现。
  • HTTPS 与自定义域名:涉及 DNS 与证书签发,属于平台侧,且绑定后需要真实 DNS 传播才能验证。
  • 真实网络下的加载表现:首包在真实 4G / 弱网下的耗时,只有真机测得出,本机的 loopback 服务不构成证据。

把这四件事列出来,是为了划一条清晰的线:线以下是「可回归」的,线以上是「需放行」的。每次提交跑 build-prod + verify-dist + serve-dist,就能守住「产物没坏、体积没涨、哈希对得上、能被 HTTP 提供」这四条;而真正让用户看到新版本的那一下,仍然是一次人工放行。

一个常见的反模式是把 L4 的事伪装成 L1:写一个「部署脚本」在没有凭据时静默跳过上传、却打印「部署成功」。这种脚本比没有脚本更危险——它让人以为交付闭环了,实际上什么都没发生。诚实的做法是让 L4 显式失败或显式标注,而不是用一句假的成功日志盖过去。这也是本节把真实部署单列出来的原因。

交付分层:哪些进回归、哪些要放行

把本节所有动作按可复现性分层,一张表看清边界:

动作层级命令 / 条件能否进回归
生产构建L1node tools/build-prod.mjs能,产物哈希稳定
产物完整性校验L1node tools/verify-dist.mjs能,纯计算
本地静态服务冒烟L1node tools/serve-dist.mjs能,loopback
产物等价性冒烟L1node dist/assets/glimmer-isle-demo-*.js能,输出逐字比对
上传静态托管L4平台账号 + 凭据否,人工放行
缓存头 / HTTPS / 域名L4平台控制台 + DNS否,人工配置

表里前四行覆盖了「交付物本身是否可信」,后两行覆盖「交付物是否可达」。前者是代码问题,可以自动化;后者是运维问题,必须有人拍板。把两者混在一个「部署脚本」里,是大多数交付事故的来源。

小结:构建的产物是一份可核对的契约

把这一节的三份产物串起来看:build-prod 产出 dist/ 与清单,verify-dist 重算哈希确认完整性,serve-dist 确认可被 HTTP 取回。三者共同定义了一份契约——「这次交付出去的字节,就是清单里记的那一份」。这份契约不关心你部署到哪家平台,只关心「部署上去的东西有没有被换过」。

这份契约由三条可执行的断言支撑:

  • 构建可复现:同一份源码连续构建,入口哈希三次一致。
  • 产物可核对:verify-dist 重算 sha256 全过,篡改一个字节即失败。
  • 行为可等价:产物直跑输出与源码直跑逐字相同。

下一节把前八章的所有验收点收敛成一份上线检查清单,把这份契约连同前面每一章的检查项一起摆到放行之前。

阅读导航:上一节:8.1 性能预算达标 · 下一节:8.3 上线检查清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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