《游戏引擎实现原理》2.1 内核单测与假对象

引擎测试的难点不在断言,而在把外部世界换掉:时间、图形 API、磁盘与随机数。本节用 FakeClock、NullRHI、内存文件系统与种子化 PRNG 四类假对象,拼出一个无 GPU、无磁盘、无真实时钟的内核测试台,让同一关卡每次跑出逐位一致的结果。全部代码在 Node 直跑,17 条用例锁住行为。

2.1 内核单测与假对象

前三卷把 helio 从数学库一路搭到子系统,每一节都附了能在 Node 里直跑的代码。到了卷四,代码量已经大到「跑一遍看输出」不再够用——你需要一批可重复、可断言、能在 CI 里无人值守跑的测试。引擎测试最大的障碍从来不是断言难写,而是外部世界不可控:真实时钟走得忽快忽慢,GPU 驱动因机器而异,磁盘内容会变,Math.random() 每次不同。这四样里只要有一样没被换掉,测试就是「薛定谔的绿」。

本节做的是内核层的测试基建:把时间、图形、磁盘、随机这四个外部世界各换成一个假对象(test double),拼出一个「无 GPU、无磁盘、无真实时钟」的测试台。假对象不是 mock 框架的语法糖,而是引擎自己的一组实现——它们和真实现实现同一个接口,被测代码一行都不用改,就能在假环境里跑。

四个外部世界在 helio 里的对应物是:

  • 时间:所有取时都走 src/core/clock.ts 的 Clock 接口,测试注入 FakeClock(卷一 7.x 已建)。
  • 图形:所有渲染调用都走 src/render/rhi/ 的 IRhiBackend,测试注入 NullRHI(卷二 2.x 已建)。
  • 磁盘:资产加载器只依赖一个 FileSystem 接口,测试注入本节要写的 FakeIO。
  • 随机:游戏逻辑的随机走 Rng(种子化 PRNG),测试注入固定种子,本节要写。

前两样前三卷已经建好,本节补上后两样,并把四者装配成一个测试台。本机 npx tsc --noEmit 零错误,npx vitest run 17 条用例全绿——下面的代码都是 helio 仓库 src/testing/ 下的真实文件,不是示例片段。

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

卷四只做「把前三卷的引擎工程化跑起来」,不重推原理。下表说明它与站内既有内容的分工:

主题站内既有内容本节(卷四)的定位
引擎架构与 ECSgame-engine-architecture-ecs 讲 ECS 的设计选型不重推架构,只把内核代码接到可断言的测试台上
GPU 剖析与调试graphics-gpu-profiling-debugging 讲剖析方法与工具只关心「渲染调用序列」能否在无 GPU 的 Node 里被断言
抗锯齿等方法graphics-anti-aliasing-methods 讲算法原理渲染测试(2.2)只做逐像素回归,不讨论算法
前三卷的被测对象引擎内核 等分卷索引提供被测代码;本卷只补测试、构建与性能工程

假文件系统与确定性随机

资产加载器如果直接 import { readFileSync } from 'node:fs',它就没法在测试里被喂「缺失的文件」「读到一半报错的坏文件」这类极端输入,还会让测试依赖工作目录。helio 的做法是把文件系统收敛成一个只有四个方法的接口,生产环境包 node:fs,测试环境用内存实现。下面是内存实现 FakeIO。

// file: src/testing/fake-io.ts
// tier: L1
// verify: npx vitest run src/testing/fake-io.test.ts
//
// helio 测试工具:内存文件系统(FakeIO)。
// 资产加载器不该在单测里碰真实磁盘——磁盘慢、内容会变、还依赖工作目录。
// 这里把「文件系统」抽象成一个可注入对象,测试想造什么就造什么:
// 缺失的文件、读一半报错的文件、带延迟的读,都能精确复现。

/** 文件系统的最小抽象:只有加载器真正用到的四个操作。 */
export interface FileSystem {
  exists(path: string): boolean;
  readText(path: string): string;
  readBytes(path: string): Uint8Array;
  list(dir: string): string[];
}

export class IoError extends Error {
  readonly path: string;
  constructor(message: string, path: string) {
    super(message);
    this.name = 'IoError';
    this.path = path;
  }
}

interface FakeEntry {
  readonly bytes: Uint8Array;
  /** 读到第几个字节时抛出「读失败」,用于模拟坏文件 / 中途 IO 错误。 */
  readonly failAt: number;
}

/**
 * 内存文件系统。registerText / registerBytes 登记内容;
 * 目录由路径前缀隐式推导,不需要显式 mkdir。
 */
export class FakeIO implements FileSystem {
  readonly #files = new Map<string, FakeEntry>();
  /** 每次读取累计的字节数——用来断言加载器没有重复读同一文件。 */
  #bytesRead = 0;

  get bytesRead(): number {
    return this.#bytesRead;
  }

  /** 登记一个文本文件(UTF-8 编码)。 */
  registerText(path: string, content: string): this {
    return this.registerBytes(path, new TextEncoder().encode(content));
  }

  /** 登记一个二进制文件。failAt >= 0 表示读到该偏移时抛错。 */
  registerBytes(path: string, bytes: Uint8Array, failAt = -1): this {
    this.#files.set(normalize(path), { bytes: bytes.slice(), failAt });
    return this;
  }

  exists(path: string): boolean {
    return this.#files.has(normalize(path));
  }

  readText(path: string): string {
    return new TextDecoder().decode(this.readBytes(path));
  }

  readBytes(path: string): Uint8Array {
    const key = normalize(path);
    const entry = this.#files.get(key);
    if (entry === undefined) throw new IoError(`文件不存在:${path}`, path);
    if (entry.failAt >= 0) {
      throw new IoError(`读取在偏移 ${entry.failAt} 处失败:${path}`, path);
    }
    this.#bytesRead += entry.bytes.byteLength;
    return entry.bytes.slice();
  }

  /** 列出某个目录下的直接子项(文件名或子目录名,去重后字典序)。 */
  list(dir: string): string[] {
    const prefix = normalize(dir).replace(/\/$/, '') + '/';
    const names = new Set<string>();
    for (const key of this.#files.keys()) {
      if (!key.startsWith(prefix)) continue;
      const rest = key.slice(prefix.length);
      const slash = rest.indexOf('/');
      names.add(slash === -1 ? rest : rest.slice(0, slash) + '/');
    }
    return [...names].sort();
  }
}

/** 统一分隔符、去掉前导 `./`,让 `a/b`、`./a/b`、`a//b` 视为同一路径。 */
function normalize(path: string): string {
  return path.replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/^\.\//, '');
}

随机数的处理原则一样:游戏逻辑里的随机(掉落、散布、抖动)绝不能调 Math.random(),因为它无法回放,同一个 bug 第二次跑就复现不出来。Rng 用 mulberry32——一个种子决定一整条序列,跨机器、跨运行逐位一致;split(label) 还能给每个子系统派生独立子流,避免「给掉落系统多抽一次数,把粒子系统的序列也带偏」。完整实现如下。

// file: src/testing/deterministic-rng.ts
// tier: L1
// verify: npx vitest run src/testing/deterministic-rng.test.ts
//
// helio 测试工具:确定性随机数发生器(PRNG)。
// 游戏逻辑里的随机(掉落、散布、抖动)绝不能用 Math.random()——
// 它无法回放,同一个 bug 第二次跑就复现不出来。这里用 mulberry32:
// 一个种子决定一整条序列,跨机器、跨运行逐位一致。

/** 一个随机数流:给定种子后,nextU32 的序列完全确定。 */
export class Rng {
  #state: number;

  constructor(seed: number) {
    // 归一到 32 位无符号;种子 0 会退化成常量序列,故强制错开。
    this.#state = (seed >>> 0) || 0x9e3779b9;
  }

  /** 下一个 32 位无符号整数(mulberry32)。 */
  nextU32(): number {
    let t = (this.#state += 0x6d2b79f5) >>> 0;
    t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
    t = (t ^ (t + Math.imul(t ^ (t >>> 7), t | 61))) >>> 0;
    return (t ^ (t >>> 14)) >>> 0;
  }

  /** 下一个 [0, 1) 的浮点数。 */
  nextFloat(): number {
    return this.nextU32() / 4294967296;
  }

  /** 下一个 [lo, hi) 的整数(含 lo,不含 hi)。 */
  nextInt(lo: number, hi: number): number {
    if (hi <= lo) throw new RangeError(`nextInt 需要 hi > lo,收到 [${lo}, ${hi})`);
    return lo + (this.nextU32() % (hi - lo));
  }

  /**
   * 派生一条独立的子流。用于「每个子系统一条流」——
   * 这样给掉落系统多抽一次数,不会连带改变粒子系统的序列。
   * 派生算法是纯函数:父流的状态与标签共同决定子流种子。
   */
  split(label: string): Rng {
    let h = this.nextU32();
    for (let i = 0; i < label.length; i++) {
      h = Math.imul(h ^ label.charCodeAt(i), 0x01000193) >>> 0;
    }
    return new Rng(h);
  }
}

/** 把一串字符串稳定地折成一个 32 位种子(FNV-1a),便于用关卡名当种子。 */
export function seedFromString(s: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

可运行产物:无 GPU、无磁盘、无真实时钟的测试台

把四类假对象装配到一起,就是 KernelHarness——一个「启动一个关卡」的测试台:给它一个关卡名,它推导出 RNG 种子、建好假时钟与 NullRHI、把资源文件塞进 FakeIO。之后无论跑多少帧逻辑、提交多少次渲染,结果都是确定的、可断言的。

// file: src/testing/kernel-harness.ts
// tier: L1
// verify: npx vitest run src/testing/kernel-harness.test.ts
//
// helio 测试工具:内核测试台(KernelHarness)。
// 把四个「外部世界」一次替换干净:时间(FakeClock)、图形(NullRHI)、
// 磁盘(FakeIO)、随机(Rng)。装配好的测试台里跑任何一段游戏逻辑,
// 结果都是确定的、可断言的,不需要显卡、磁盘或真实时钟。

import { FakeClock } from '../core/clock.ts';
import { NullRHI } from '../render/rhi/null-rhi.ts';
import type { RhiCall } from '../render/rhi/null-rhi.ts';
import { FakeIO } from './fake-io.ts';
import { Rng, seedFromString } from './deterministic-rng.ts';

/** 启动参数:一个关卡名当种子,外加若干资源文件。 */
export interface HarnessOptions {
  /** 关卡名。用它推导 RNG 种子,同一关卡每次跑得到同一条随机序列。 */
  readonly level: string;
  /** 预置到假文件系统的资源:路径 -> 文本内容。 */
  readonly files?: Readonly<Record<string, string>>;
}

export class KernelHarness {
  readonly clock: FakeClock;
  readonly rhi: NullRHI;
  readonly io: FakeIO;
  readonly rng: Rng;
  readonly level: string;
  /** 逻辑帧计数,每次 tick 自增。 */
  #tick = 0;

  constructor(opts: HarnessOptions) {
    this.level = opts.level;
    this.clock = new FakeClock(0);
    this.rhi = new NullRHI();
    this.io = new FakeIO();
    this.rng = new Rng(seedFromString(opts.level));
    for (const [path, content] of Object.entries(opts.files ?? {})) {
      this.io.registerText(path, content);
    }
  }

  get tick(): number {
    return this.#tick;
  }

  /** 推进一帧:假时钟前进 dtMs,帧号加一。返回这一帧的逻辑秒数。 */
  tickFrame(dtMs: number): number {
    this.clock.advance(dtMs);
    this.#tick++;
    return dtMs / 1000;
  }

  /** 录进 NullRHI 的调用序列,供断言「渲染代码到底调了哪些 API」。 */
  get calls(): readonly RhiCall[] {
    return this.rhi.calls;
  }
}

kernel-harness-demo.ts 用这个测试台跑同一关卡两遍,再换一个关卡跑一遍,node 直跑输出如下:

glimmer-isle: 帧=60 时钟=1000ms 掉落=4 rngAcc=2671534676 调用=[createBuffer -> beginFrame -> beginRenderPass -> draw -> endRenderPass -> submit]
glimmer-isle: 帧=60 时钟=1000ms 掉落=4 rngAcc=2671534676 调用=[createBuffer -> beginFrame -> beginRenderPass -> draw -> endRenderPass -> submit]
twin-tide: 帧=60 时钟=1000ms 掉落=2 rngAcc=3974803899 调用=[createBuffer -> beginFrame -> beginRenderPass -> draw -> endRenderPass -> submit]

两行 glimmer-isle 逐字相同:帧数、时钟、掉落次数、随机累加值、NullRHI 的调用序列全部一致,而这中间没有显卡、没有磁盘、没有真实时钟。换成 twin-tide,随机累加值随之改变——说明种子确实在起作用,而不是「随机恰好一样」。配套的 17 条用例(fake-io.test.ts / deterministic-rng.test.ts / kernel-harness.test.ts)锁住了这些行为:路径归一化、坏文件在指定偏移报错、list 只列直接子项、同种子同序列、split 子流独立、假时钟精确推进、NullRHI 调用序列可断言。

内核的测试台搭好之后,还有一类东西是假对象替代不了的——真实的像素。NullRHI 只能告诉你「调用了 draw」,告诉你「画出来长什么样」必须真的上 GPU。下一节我们把测试台搬进 headless Chromium,做黄金图像比对。

阅读导航:上一节:回到目录 · 下一节:2.2 渲染测试:headless 截图与黄金图像 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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