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 顶点格式与程序化网格 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。