《游戏引擎实现原理》2.3 确定性回放与回归

黄金图像抓画面,回放抓逻辑。本节把「一局游戏」定义为「一个种子 + 一串输入帧」,用全整数模拟保证跨平台逐位一致,再把每帧状态折成哈希:两遍回放逐帧相同,一次改动被精确定位到分叉帧。本机实测同一份 120 帧回放两遍哈希全同,改一个摩擦常数后在第 1 帧即分叉。

2.3 确定性回放与回归

2.2 的黄金图像抓住了「画面画得对不对」,但它有两个局限:每跑一次都要真的上 GPU,慢且重;而且它只看渲染,看不到游戏逻辑本身——两个实体因为一个积分符号写反而悄悄换了位置,画面可能一模一样。真正该在每次提交里跑的,是比画面更底层、也更便宜的东西:游戏逻辑的状态。

要能对逻辑做回归,先得让逻辑可复现。这一节把「一局游戏」定义成一份回放记录:一个种子 + 一串输入帧。只要模拟是确定性的,重放这份输入就必须得到逐位相同的状态;再把每一帧的状态折成一个哈希,整局游戏就变成了一串数字。两次回放的哈希序列在某一帧分叉,就精确指出了「从哪一帧起逻辑变了」。

确定性不是天上掉下来的,它要靠纪律换:逻辑里不能有浮点、不能读真实时钟、不能碰 Math.random()。本节的状态用「毫单位」整数表示(1.0 = 1000),全程整数运算,因此跨平台逐位一致;随机只来自 2.1 的种子化 Rng,且只在生成实体时消费一次。本机 npx tsc --noEmit 零错误、npx vitest run 5 条用例全绿,下面是真实的回放实现。

状态哈希:把一局游戏折成一串数字

回放的核心是三件事:step(推进一帧)、stateHash(把状态折成哈希)、replayWith(用某套规则跑完整份输入并逐帧记录哈希)。step 全整数;stateHash 把每个整数字段拆成 4 个字节依次混入 FNV-1a,保证位级敏感——任何一个字段差 1,哈希就变。把「推进函数」参数化(StepFn)是为了能对照两个引擎版本:同一个输入日志,分别用新旧规则跑,哈希序列一比就知道哪一帧开始不一样。

// file: src/testing/replay.ts
// tier: L1
// verify: npx vitest run src/testing/replay.test.ts
//
// helio 测试工具:确定性回放与状态哈希。
// 一局游戏 = 一个种子 + 一串输入帧。只要模拟是确定性的,
// 重放同一份输入就必须得到逐位相同的状态;把每帧状态折成一个哈希,
// 两次回放的哈希序列一旦在某一帧分叉,就精确指出了引入非确定性的那一帧。

import { Rng } from './deterministic-rng.ts';

/** 一帧输入。轴用 -1/0/1,spawn 表示这一帧是否请求生成一个实体。 */
export interface InputFrame {
  readonly accelX: -1 | 0 | 1;
  readonly accelY: -1 | 0 | 1;
  readonly spawn: boolean;
}

/** 一份回放记录:种子 + 输入帧序列。这是「重现一局游戏」所需的全部信息。 */
export interface ReplayLog {
  readonly seed: number;
  readonly inputs: readonly InputFrame[];
}

/** 一个实体。位置/速度用「毫单位」整数(1.0 = 1000),全程整数运算,逐位一致。 */
export interface Entity {
  x: number;
  y: number;
  vx: number;
  vy: number;
}

export interface WorldState {
  tick: number;
  readonly entities: Entity[];
}

const SCALE = 1000;
const ACCEL = 120; // 毫单位/帧^2
const FRICTION = 24; // 每帧速度衰减
const MAX_SPEED = 4000;
const MAX_ENTITIES = 64;

/** 建一个空世界。 */
export function makeWorld(): WorldState {
  return { tick: 0, entities: [] };
}

/**
 * 推进一步。全部是整数运算:没有任何浮点,因此跨平台逐位一致。
 * rng 只在 spawn 时消费,用来决定新实体的初始速度。
 */
export function step(state: WorldState, input: InputFrame, rng: Rng): void {
  state.tick++;
  for (const e of state.entities) {
    e.vx = clamp(e.vx + input.accelX * ACCEL - sign(e.vx) * FRICTION, -MAX_SPEED, MAX_SPEED);
    e.vy = clamp(e.vy + input.accelY * ACCEL - sign(e.vy) * FRICTION, -MAX_SPEED, MAX_SPEED);
    e.x += e.vx;
    e.y += e.vy;
  }
  if (input.spawn && state.entities.length < MAX_ENTITIES) {
    state.entities.push({
      x: 0,
      y: 0,
      vx: rng.nextInt(-MAX_SPEED, MAX_SPEED + 1),
      vy: rng.nextInt(-MAX_SPEED, MAX_SPEED + 1),
    });
  }
}

/** 把世界状态折成一个 32 位哈希(FNV-1a)。任何字段变了,哈希就变。 */
export function stateHash(state: WorldState): number {
  let h = 0x811c9dc5;
  const mix = (n: number): void => {
    // 每个整数拆成 4 个字节依次混入,保证位级敏感。
    for (let i = 0; i < 4; i++) {
      h ^= (n >>> (i * 8)) & 0xff;
      h = Math.imul(h, 0x01000193);
    }
  };
  mix(state.tick);
  mix(state.entities.length);
  for (const e of state.entities) {
    mix(e.x);
    mix(e.y);
    mix(e.vx);
    mix(e.vy);
  }
  return h >>> 0;
}

/** 回放结果:每帧的哈希序列与最终状态。 */
export interface ReplayResult {
  readonly hashes: readonly number[];
  readonly final: WorldState;
}

/** 一步推进函数:把「用哪套模拟规则」参数化,便于对照两个引擎版本。 */
export type StepFn = (state: WorldState, input: InputFrame, rng: Rng) => void;

/** 用指定的推进函数跑完一份回放,逐帧记录状态哈希。 */
export function replayWith(stepFn: StepFn, log: ReplayLog): ReplayResult {
  const state = makeWorld();
  const rng = new Rng(log.seed);
  const hashes: number[] = [];
  for (const input of log.inputs) {
    stepFn(state, input, rng);
    hashes.push(stateHash(state));
  }
  return { hashes, final: state };
}

/** 用默认模拟规则跑完一份回放。 */
export function replay(log: ReplayLog): ReplayResult {
  return replayWith(step, log);
}

/**
 * 对比两次回放,返回第一个分叉的帧号;完全相同返回 -1。
 * 这是回归测试的核心:把上次的哈希序列存成「黄金哈希」,新版本逐帧比。
 */
export function firstDivergence(a: ReplayResult, b: ReplayResult): number {
  const n = Math.min(a.hashes.length, b.hashes.length);
  for (let i = 0; i < n; i++) {
    if (a.hashes[i] !== b.hashes[i]) return i;
  }
  return a.hashes.length === b.hashes.length ? -1 : n;
}

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

function sign(v: number): number {
  return v > 0 ? 1 : v < 0 ? -1 : 0;
}

/** 用固定规则合成一串输入,供 demo 与测试复用(不含任何随机)。 */
export function scriptedInputs(frames: number): InputFrame[] {
  const out: InputFrame[] = [];
  for (let i = 0; i < frames; i++) {
    out.push({
      accelX: i % 17 < 6 ? 1 : i % 17 < 12 ? -1 : 0,
      accelY: i % 23 < 8 ? -1 : i % 23 < 16 ? 1 : 0,
      spawn: i % 10 === 0,
    });
  }
  return out;
}

export { SCALE };

可运行产物:两遍回放与一次回归定位

replay-demo.ts 跑三件事:用同一份 120 帧输入回放两遍(应当逐帧相同),再用一个「把摩擦常数从 24 改成 25」的假想版本回放一遍(应当分叉),并用 firstDivergence 定位分叉帧。假想版本用 replayWith 注入自己的一套 StepFn,其余代码一字不改。

// file: src/testing/replay-demo.ts
// tier: L1
// verify: node src/testing/replay-demo.ts
//
// 确定性回放的演示:跑两遍同一份输入,证明哈希一致;
// 再对照一个「把摩擦系数从 24 改成 25」的假想版本,用 firstDivergence 定位分叉帧。

import { firstDivergence, replay, replayWith, scriptedInputs, stateHash } from './replay.ts';
import type { InputFrame, StepFn, WorldState } from './replay.ts';
import { Rng } from './deterministic-rng.ts';

const log = { seed: 0xc0ffee, inputs: scriptedInputs(120) };

const a = replay(log);
const b = replay(log);
console.log(`回放 A 最终哈希 = ${a.hashes.at(-1)}(${a.final.entities.length} 个实体)`);
console.log(`回放 B 最终哈希 = ${b.hashes.at(-1)}`);
console.log(`A 与 B 首次分叉帧 = ${firstDivergence(a, b)}`);

// 假想的「新版本」:摩擦系数 24 -> 25,其余一字不改。
const FRICTION_V2 = 25;
const stepV2: StepFn = (state: WorldState, input: InputFrame, rng: Rng): void => {
  state.tick++;
  for (const e of state.entities) {
    e.vx += input.accelX * 120 - Math.sign(e.vx) * FRICTION_V2;
    e.vy += input.accelY * 120 - Math.sign(e.vy) * FRICTION_V2;
    e.x += e.vx;
    e.y += e.vy;
  }
  if (input.spawn && state.entities.length < 64) {
    state.entities.push({ x: 0, y: 0, vx: rng.nextInt(-4000, 4001), vy: rng.nextInt(-4000, 4001) });
  }
};

const v2 = replayWith(stepV2, log);
console.log(`版本 V2 最终哈希 = ${v2.hashes.at(-1)}`);
console.log(`A 与 V2 首次分叉帧 = ${firstDivergence(a, v2)}`);

// 黄金哈希回归:把每帧哈希当作「黄金」,新版本逐帧比,第一处分叉即定位到帧。
const golden = a.hashes;
let firstBad = -1;
for (let i = 0; i < golden.length; i++) {
  if (v2.hashes[i] !== golden[i]) {
    firstBad = i;
    break;
  }
}
console.log(`黄金哈希回归:V2 在第 ${firstBad} 帧首次偏离(共 ${golden.length} 帧)`);
void stateHash;

node src/testing/replay-demo.ts 的输出如下:

回放 A 最终哈希 = 45568358(12 个实体)
回放 B 最终哈希 = 45568358
A 与 B 首次分叉帧 = -1
版本 V2 最终哈希 = 1539754927
A 与 V2 首次分叉帧 = 1
黄金哈希回归:V2 在第 1 帧首次偏离(共 120 帧)

三条结论:

  1. 同一份回放两遍哈希全同(A = B = 45568358,首次分叉帧 = -1)。120 帧、每帧 12 个实体、每个实体 4 个整数字段,全部逐位一致。这就是「确定性」的可操作定义。
  2. 一次改动被精确定位到帧。假想版本只把摩擦常数从 24 改成 25,firstDivergence 立刻报出「第 1 帧」——因为第 0 帧只生成实体、还没积分,第 1 帧起摩擦才起作用。分叉帧号比「最终哈希不一样」有用得多:它直接把排查范围缩小到那一帧的代码路径。
  3. 回归的判据是「哈希序列」,不是「最终状态」。两个版本可能碰巧最终哈希相同却中途分叉,只看最终值会漏。逐帧哈希序列才能捕捉到「中途错、后来又碰巧对了」这类隐蔽回归。

配套的 5 条用例(replay.test.ts)锁住了这些行为:同回放两遍哈希序列相等、换种子后含 spawn 的帧开始分叉、改某一帧输入从该帧起分叉、空世界无 spawn 时哈希只随 tick 变、最终状态不含小数。全部在本机 npx vitest run 全绿。

回归门禁:黄金哈希怎么用

把回放的哈希序列存进仓库,就得到一份黄金哈希(golden hash)。回归门禁的流程很短:跑一份固定回放 → 得到哈希序列 → 与黄金哈希逐帧比 → 第一处不一致就是回归点。它比黄金图像轻得多(不需要 GPU、不需要浏览器),适合放进每次提交的快速通道;黄金图像则放到更重的 nightly 或渲染改动时跑。

有三条实践要点。其一,黄金哈希要随「有意的改动」显式更新——就像黄金图像一样,改了模拟规则后要由人审阅 diff、确认分叉点是预期的,再更新黄金值,而不是让测试自动重生成。其二,回放日志要能持久化——把它序列化成文件(种子 + 输入帧数组),bug 报告里附上它,任何人本地重放都能复现。其三,确定性要在 CI 与真机都成立——同一份回放在开发机与 CI 机上必须得到相同哈希;一旦不一致,多半是某处漏了浮点或读了真实时间,本节的全整数纪律正是为此。

到这里,卷四的「引擎测试」三件套就齐了:假对象把外部世界换掉(2.1),黄金图像抓画面(2.2),确定性回放抓逻辑(2.3)。下一章进入性能工程——把帧预算、GC 与 GPU 计时都变成可测量、可回归的数字。

阅读导航:上一节:2.2 渲染测试:headless 截图与黄金图像 · 下一节:3.1 帧预算与性能剖析 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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