《游戏引擎实现原理》5.3 NullRHI:记录式假后端与测试

NullRHI 是 helio 的第三个后端:它不接触 GPU,把每一次 RHI 调用按顺序记进日志。于是渲染代码的调用序列变成可在 Node 里断言的纯数据,单测与 CI 不需要显卡、也不会因驱动差异而抖动。本节用同一段渲染代码分别跑 WebGPU 与 NullRHI,断言后者产出的调用日志。

5.3 NullRHI:记录式假后端与测试

前两节的验收都要真浏览器、真 GPU:跑得慢、依赖驱动、结果还带一点机器相关。但渲染器里大量逻辑——命令录制顺序、资源生命周期、状态切换、批次键——其实和 GPU 无关,它们只是「按某种顺序调用 RHI」。这类逻辑不该被绑在显卡上测。

NullRHI 就是为此存在的第三个后端:它一个 GPU 调用都不发,只把收到的每一次调用按顺序记下来。

为什么需要一个假后端

测试替身分两种。Mock 用来验证「某个方法被调用过几次」;Fake 是一个能跑、但实现简化的真东西。NullRHI 是后者:它实现了完整的 IRhiBackend,能接住任何一段渲染代码,只是把效果换成一行日志。

这么做的收益很具体:

  • 可在 CI 里跑:不需要显卡、不需要浏览器,npx vitest run 秒级返回。
  • 确定性:没有驱动差异、没有异步时序、没有精度抖动,日志逐条相等就是通过。
  • 回归护栏:一旦有人打乱了命令录制顺序,日志顺序会立刻变化,测试马上红。

它测不到的是「像素对不对」——那正是 5.1、5.2 那两个真后端要负责的。三个后端分工明确:WebGPU 与 WebGL2 保证画得对,NullRHI 保证调用得对。

同一段渲染代码,两种后端

要让 NullRHI 有意义,渲染代码就必须只依赖 IRhiBackend。下面这段「建一个三角形场景 + 渲染一帧」的代码,5.1 的 WebGPU 后端跑它出像素,本节的 NullRHI 跑它出日志:

// file: src/render/rhi/scene.ts
// tier: L1
// verify: npx vitest run src/render/rhi/scene.test.ts
//
// 「同一段渲染代码」:只依赖 IRhiBackend,不认识任何具体后端。
// WebGPU 后端把它渲染成像素,NullRHI 把它记录成调用日志——
// 这正是「换后端不动上层」这条铁律的可执行证明。
import {
  RhiBufferUsage,
  RhiFormat,
  RhiTextureUsage,
  type IRhiBackend,
  type RhiBindGroup,
  type RhiBuffer,
  type RhiRenderPipeline,
  type RhiTexture,
} from './rhi-types.ts';

export const TRIANGLE_WGSL = `struct Tint { color: vec4<f32>, }
@group(0) @binding(0) var<uniform> tint: Tint;

@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
  var p = array<vec2<f32>, 3>(vec2<f32>(-0.8, -0.8), vec2<f32>(0.8, -0.8), vec2<f32>(0.0, 0.8));
  return vec4<f32>(p[i], 0.0, 1.0);
}

@fragment
fn fs() -> @location(0) vec4<f32> {
  return tint.color;
}
`;

export interface TriangleScene {
  readonly target: RhiTexture;
  readonly tint: RhiBuffer;
  readonly pipeline: RhiRenderPipeline;
  readonly bindGroup: RhiBindGroup;
  readonly width: number;
  readonly height: number;
}

/** 在任意后端上建出「一个三角形 + 一个 tint uniform」的场景。 */
export function buildTriangleScene(
  rhi: IRhiBackend,
  width: number,
  height: number,
  color: readonly [number, number, number, number],
): TriangleScene {
  const target = rhi.createTexture({
    label: 'triangle-target',
    width,
    height,
    format: RhiFormat.RGBA8Unorm,
    usage: RhiTextureUsage.RenderTarget | RhiTextureUsage.CopySrc,
  });
  const tint = rhi.createBuffer({ label: 'tint', size: 16, usage: RhiBufferUsage.Uniform | RhiBufferUsage.CopyDst });
  rhi.writeBuffer(tint, new Float32Array(color));
  const module = rhi.createShaderModule(TRIANGLE_WGSL);
  const pipeline = rhi.createRenderPipeline({
    label: 'triangle',
    vertexModule: module,
    vertexEntry: 'vs',
    fragmentModule: module,
    fragmentEntry: 'fs',
    vertexBuffers: [],
    colorFormat: RhiFormat.RGBA8Unorm,
    topology: 'triangle-list',
  });
  const bindGroup = rhi.createBindGroup([{ binding: 0, buffer: tint }]);
  return { target, tint, pipeline, bindGroup, width, height };
}

/** 录制并提交一次绘制,返回回读到的像素(行优先、RGBA8、无 padding)。 */
export async function renderTriangleScene(rhi: IRhiBackend, scene: TriangleScene): Promise<Uint8Array> {
  rhi.beginFrame();
  rhi.beginRenderPass({
    texture: scene.target,
    loadOp: 'clear',
    clearValue: { r: 0.1, g: 0.1, b: 0.2, a: 1 },
  });
  rhi.draw({ pipeline: scene.pipeline, bindGroup: scene.bindGroup, vertexCount: 3 });
  rhi.endRenderPass();
  rhi.submit();
  return rhi.readTexture(scene.target);
}

NullRHI:把调用记成数据

实现很直白:每个方法往 calls 里推一条 { op, args },句柄照常自增,纹理回读返回一块按尺寸分配的全零缓冲(尺寸可断言,内容不参与)。

// file: src/render/rhi/null-rhi.ts
// tier: L1
// verify: npx vitest run src/render/rhi/null-rhi.test.ts
//
// NullRHI:不接触任何 GPU,把每一次调用按顺序记进日志。
// 它让「渲染代码的调用序列」变成可在 Node 里断言的纯数据,
// 单测与 CI 因此不需要显卡,也不会因为驱动差异而抖动。
import type {
  BindGroupEntry, BufferDesc, ColorAttachment, DrawCall, IRhiBackend, RenderPipelineDesc,
  RhiBindGroup, RhiBuffer, RhiRenderPipeline, RhiShaderModule, RhiTexture, TextureDesc,
} from './rhi-types.ts';

export interface RhiCall {
  readonly op: string;
  readonly args: readonly (string | number)[];
}

export class NullRHI implements IRhiBackend {
  readonly name = 'NullRHI';
  readonly calls: RhiCall[] = [];
  #next = 1;
  #textures = new Map<number, { readonly width: number; readonly height: number }>();

  #log(op: string, ...args: (string | number)[]): void {
    this.calls.push({ op, args });
  }

  createShaderModule(code: string): RhiShaderModule {
    this.#log('createShaderModule', code.length);
    return this.#next++;
  }

  createBuffer(d: BufferDesc): RhiBuffer {
    this.#log('createBuffer', d.size, d.usage);
    return this.#next++;
  }

  createTexture(d: TextureDesc): RhiTexture {
    const id = this.#next++;
    this.#textures.set(id, { width: d.width, height: d.height });
    this.#log('createTexture', d.width, d.height);
    return id;
  }

  createRenderPipeline(d: RenderPipelineDesc): RhiRenderPipeline {
    this.#log('createRenderPipeline', d.topology ?? 'triangle-list');
    return this.#next++;
  }

  createBindGroup(entries: readonly BindGroupEntry[]): RhiBindGroup {
    this.#log('createBindGroup', entries.length);
    return this.#next++;
  }

  writeBuffer(buffer: RhiBuffer, data: ArrayBufferView | ArrayBuffer, offset = 0): void {
    this.#log('writeBuffer', buffer, offset, data.byteLength);
  }

  beginFrame(): void {
    this.#log('beginFrame');
  }

  beginRenderPass(att: ColorAttachment): void {
    this.#log('beginRenderPass', att.texture, att.loadOp);
  }

  draw(call: DrawCall): void {
    this.#log('draw', call.pipeline, call.bindGroup, call.vertexCount);
  }

  endRenderPass(): void {
    this.#log('endRenderPass');
  }

  submit(): void {
    this.#log('submit');
  }

  /** 回读返回一块按纹理尺寸分配的全零缓冲,尺寸可断言,内容不参与。 */
  async readTexture(texture: RhiTexture): Promise<Uint8Array> {
    const rec = this.#textures.get(texture)!;
    this.#log('readTexture', texture, rec.width * rec.height * 4);
    return new Uint8Array(rec.width * rec.height * 4);
  }
}

可运行产物:调用日志的断言

测试把「同一段渲染代码」喂给 NullRHI,然后断言日志的操作序列、draw 的参数、以及两次运行的日志长度:

// file: src/render/rhi/null-rhi.test.ts
// tier: L1
// verify: npx vitest run src/render/rhi/null-rhi.test.ts
import { describe, expect, it } from 'vitest';
import { NullRHI } from './null-rhi.ts';
import { buildTriangleScene, renderTriangleScene } from './scene.ts';

describe('null-rhi', () => {
  it('同一段渲染代码在 NullRHI 上产出可断言的调用日志', async () => {
    const rhi = new NullRHI();
    const scene = buildTriangleScene(rhi, 16, 16, [0, 1, 0, 1]);
    const pixels = await renderTriangleScene(rhi, scene);

    expect(rhi.calls.map((c) => c.op)).toEqual([
      'createTexture', 'createBuffer', 'writeBuffer', 'createShaderModule',
      'createRenderPipeline', 'createBindGroup',
      'beginFrame', 'beginRenderPass', 'draw', 'endRenderPass', 'submit', 'readTexture',
    ]);
    expect(pixels.length).toBe(16 * 16 * 4);
  });

  it('draw 的参数带着句柄与顶点数,顺序可核对', async () => {
    const rhi = new NullRHI();
    const scene = buildTriangleScene(rhi, 8, 8, [1, 0, 0, 1]);
    await renderTriangleScene(rhi, scene);
    const draw = rhi.calls.find((c) => c.op === 'draw')!;
    expect(draw.args).toEqual([scene.pipeline, scene.bindGroup, 3]);
    const pass = rhi.calls.find((c) => c.op === 'beginRenderPass')!;
    expect(pass.args[0]).toBe(scene.target);
    expect(pass.args[1]).toBe('clear');
  });

  it('两次渲染的日志长度一致,说明调用序列稳定可回归', async () => {
    const runOnce = async (): Promise<number> => {
      const rhi = new NullRHI();
      await renderTriangleScene(rhi, buildTriangleScene(rhi, 4, 4, [0, 0, 1, 1]));
      return rhi.calls.length;
    };
    expect(await runOnce()).toBe(await runOnce());
  });
});
$ npx vitest run src/render/rhi/null-rhi.test.ts
 ✓ null-rhi > 同一段渲染代码在 NullRHI 上产出可断言的调用日志
 ✓ null-rhi > draw 的参数带着句柄与顶点数,顺序可核对
 ✓ null-rhi > 两次渲染的日志长度一致,说明调用序列稳定可回归
 Test Files  1 passed (1)
      Tests  3 passed (3)

注意第一条断言:从建资源到回读,12 个操作的顺序被完整钉死。任何人改动 renderTriangleScene 的录制顺序,这条日志都会变,测试立刻失败。这就是「调用得对」的最小回归护栏。

阅读导航:上一节:5.2 WebGL2 后端实现与能力降级 · 下一节:6.1 顶点格式与程序化网格 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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