《游戏引擎实现原理》2.2 渲染测试:headless 截图与黄金图像

NullRHI 只能告诉你「调用了 draw」,画出来长什么样必须真的上 GPU。本节把测试搬进 headless Chromium,用 WebGL2 渲染一个固定场景、落 PNG 存档,再用逐像素比对与容差判定抓住回归。本机实测:同一场景两次渲染零差异,一个 3px 的位移被抓出 622 个差异像素、最大通道差 204。

2.2 渲染测试:headless 截图与黄金图像

上一节的 NullRHI 能把渲染代码的调用序列变成可断言的纯数据,但它有一件事做不到:告诉你画面画出来长什么样。一次着色器改动、一个矩阵乘法的符号写反、一次混合状态的错误配置,都可能在调用序列完全正确的前提下把画面画错。要抓住这类回归,必须真的上 GPU 渲染一帧,再把这一帧和一份「已知正确」的图像逐像素比——这就是黄金图像测试(golden image test)。

黄金图像测试要同时满足两个看似矛盾的要求:跨运行稳定(同一份代码每次渲染结果一样,否则测试永远红),对回归敏感(代码改错了必须红)。难点在中间那条线:GPU 的浮点、驱动实现、甚至纹理采样的舍入都会带来一两个最低有效位(LSB)的抖动,如果判据是「完全相等」,测试会因为无关紧要的噪声而随机失败。本节的做法是把**「差异有多大」和「差异是否可接受」拆开**:先算真实的差异统计,再由一个显式的容差策略决定通过与否。

本节是 L2:渲染与取像素必须在 headless Chromium(经本地 http 服务)里真跑,命令行之外的 GPU 差异需在真实设备上手动复核。本机实测的数字——零差异、622 个差异像素、最大通道差 204——都来自下面这段代码的直接运行。

逐像素比对:为什么不能要求完全相等

比对工具本身不该碰浏览器,它是一个纯函数:给两块 RGBA8 缓冲,返回差异统计。这样它既能被浏览器渲染流程调用,也能被单元测试用合成数据喂。统计里给出四个量:差异像素数、差异像素占比、最大单通道差、平均绝对差——前两个回答「差得多不多」,后两个回答「差得狠不狠」。

// file: tools/pixel-diff.mjs
// tier: L1
// verify: node tools/pixel-diff.mjs
//
// helio 工程工具:逐像素图像比对。
// 黄金图像测试的判据不该是「完全相等」——不同 GPU/驱动/降采样实现会带来
// 一个到几个 LSB 的抖动。这里给出真实的差异统计(差异像素数、最大通道差、
// 平均绝对差),再由容差决定通过与否,并把「差在哪」写成可读报告。

/**
 * 比较两块 RGBA8 缓冲。
 * @param {Uint8Array|Uint8ClampedArray} golden
 * @param {Uint8Array|Uint8ClampedArray} candidate
 * @param {number} width
 * @param {number} height
 * @returns {{totalPixels:number,diffPixels:number,diffRatio:number,maxChannelDelta:number,meanAbsDelta:number}}
 */
export function diffRGBA(golden, candidate, width, height) {
  const totalPixels = width * height;
  if (golden.length !== candidate.length || golden.length !== totalPixels * 4) {
    throw new Error(`缓冲尺寸不匹配:golden=${golden.length} candidate=${candidate.length} 期望=${totalPixels * 4}`);
  }
  let diffPixels = 0;
  let maxChannelDelta = 0;
  let sumAbs = 0;
  for (let p = 0; p < totalPixels; p++) {
    const i = p * 4;
    let pixelDiffers = false;
    for (let c = 0; c < 4; c++) {
      const d = Math.abs(golden[i + c] - candidate[i + c]);
      if (d > 0) pixelDiffers = true;
      if (d > maxChannelDelta) maxChannelDelta = d;
      sumAbs += d;
    }
    if (pixelDiffers) diffPixels++;
  }
  return {
    totalPixels,
    diffPixels,
    diffRatio: diffPixels / totalPixels,
    maxChannelDelta,
    meanAbsDelta: sumAbs / (totalPixels * 4),
  };
}

/**
 * 按容差判定是否通过。默认:任何通道最多差 2,且差异像素占比不超过 0.5%。
 * @param {ReturnType<typeof diffRGBA>} stats
 * @param {{maxChannelDelta?:number,maxDiffRatio?:number}} [tol]
 */
export function verdict(stats, tol = {}) {
  const maxChannelDelta = tol.maxChannelDelta ?? 2;
  const maxDiffRatio = tol.maxDiffRatio ?? 0.005;
  const ok = stats.maxChannelDelta <= maxChannelDelta && stats.diffRatio <= maxDiffRatio;
  return { ok, maxChannelDelta, maxDiffRatio, reason: ok ? 'within-tolerance'
    : stats.maxChannelDelta > maxChannelDelta ? `通道差 ${stats.maxChannelDelta} > ${maxChannelDelta}`
    : `差异像素占比 ${(stats.diffRatio * 100).toFixed(3)}% > ${(maxDiffRatio * 100).toFixed(3)}%` };
}

// 直接运行时做一次自检:相同缓冲差为 0;改一个像素只影响 1 个像素。
if (import.meta.url === `file://${process.argv[1]}`) {
  const w = 4, h = 4;
  const a = new Uint8Array(w * h * 4).fill(128);
  const b = a.slice();
  const same = diffRGBA(a, b, w, h);
  b[4 * 5 + 1] = 131; // 第 5 个像素的 G 通道 +3
  const changed = diffRGBA(a, b, w, h);
  console.log('自检-相同:', JSON.stringify(same));
  console.log('自检-改一像素:', JSON.stringify(changed));
  console.log('判定-相同:', JSON.stringify(verdict(same)));
  console.log('判定-改一像素:', JSON.stringify(verdict(changed)));
}

node tools/pixel-diff.mjs 的自检输出(真实运行结果):相同缓冲 diffPixels=0;把一个像素的 G 通道加 3,得到 diffPixels=1 maxChannelDelta=3,容差判定因「通道差 3 > 2」而判红。注意这里 1 个像素的改动就被判为回归——这正是我们想要的敏感度,而容差参数给了你调松的余地。

可运行产物:headless Chromium 渲染固定场景

门禁脚本 golden-render.mjs 起一个本地 http 服务,把渲染页面喂给 headless Chromium。页面用 WebGL2 画一个固定的「岛 + 浪」双三角色块(antialias:false 关掉 MSAA,preserveDrawingBuffer:true 保证 readPixels 拿到内容),渲染三个变体:golden 与 candidate 是同一场景,perturbed 把整块图形右移 3 像素,用来模拟一次「看起来无害」的回归。像素经 readPixels 取回后交给 diffRGBA。

// file: gates/golden-render.mjs
// tier: L2
// verify: node gates/golden-render.mjs
//
// helio 渲染测试门禁:headless 黄金图像比对。
// 在 headless Chromium(本地 http + WebGL2)里渲染一个固定场景,
// 用 getImageData 取回像素,落 PNG 存档,再用 tools/pixel-diff.mjs
// 与「黄金」逐像素比。同一场景两次渲染应零差异;一个像素级改动应被抓出。
import http from 'node:http';
import { writeFileSync, mkdirSync } from 'node:fs';
import { chromium } from '/opt/homebrew/lib/node_modules/playwright/index.mjs';
import { diffRGBA, verdict } from '../tools/pixel-diff.mjs';

const W = 128, H = 128;

const PAGE = `<!doctype html><meta charset=utf-8>
<canvas id="c" width="${W}" height="${H}"></canvas>
<script>
window.__render = (variant) => {
  const cv = document.getElementById('c');
  const gl = cv.getContext('webgl2', { antialias: false, preserveDrawingBuffer: true, premultipliedAlpha: false });
  gl.viewport(0, 0, ${W}, ${H});
  gl.clearColor(0.10, 0.20, 0.30, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);

  // 顶点:两个三角形拼成《微光岛》的「岛 + 浪」色块。perturbed 变体整体右移 3px。
  const shift = variant === 'perturbed' ? 3 / ${W} : 0;
  const data = new Float32Array([
    -0.8 + shift, -0.8, 0.9, 0.2, 0.1,
     0.0 + shift,  0.8, 0.9, 0.2, 0.1,
     0.8 + shift, -0.8, 0.9, 0.2, 0.1,
    -0.6 + shift,  0.6, 0.2, 0.7, 0.9,
     0.6 + shift,  0.6, 0.2, 0.7, 0.9,
     0.0 + shift, -0.4, 0.2, 0.7, 0.9,
  ]);
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
  const vs = \`#version 300 es
    in vec2 aPos; in vec3 aCol; out vec3 vCol;
    void main() { vCol = aCol; gl_Position = vec4(aPos, 0.0, 1.0); }\`;
  const fs = \`#version 300 es
    precision highp float; in vec3 vCol; out vec4 o;
    void main() { o = vec4(vCol, 1.0); }\`;
  const prog = gl.createProgram();
  for (const [t, s] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
    const sh = gl.createShader(t); gl.shaderSource(sh, s); gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
    gl.attachShader(prog, sh);
  }
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
  gl.useProgram(prog);
  const stride = 5 * 4;
  const locPos = gl.getAttribLocation(prog, 'aPos');
  const locCol = gl.getAttribLocation(prog, 'aCol');
  gl.enableVertexAttribArray(locPos); gl.vertexAttribPointer(locPos, 2, gl.FLOAT, false, stride, 0);
  gl.enableVertexAttribArray(locCol); gl.vertexAttribPointer(locCol, 3, gl.FLOAT, false, stride, 2 * 4);
  gl.drawArrays(gl.TRIANGLES, 0, 6);

  const px = new Uint8Array(${W} * ${H} * 4);
  gl.readPixels(0, 0, ${W}, ${H}, gl.RGBA, gl.UNSIGNED_BYTE, px);
  return { rgba: Array.from(px), png: cv.toDataURL('image/png') };
};
</script>`;

const srv = http.createServer((_req, res) => {
  res.setHeader('content-type', 'text/html; charset=utf-8');
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
  res.end(PAGE);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${srv.address().port}/`;

const browser = await chromium.launch({
  headless: true,
  args: ['--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
});

const outDir = '/tmp/eb-eng/ch2/artifacts';
mkdirSync(outDir, { recursive: true });
let failures = 0;
const check = (name, ok, detail) => {
  if (!ok) failures++;
  console.log(`${ok ? 'PASS' : 'FAIL'}  ${name}  ${detail ?? ''}`);
};

try {
  const page = await browser.newPage({ viewport: { width: W, height: H } });
  await page.goto(url);
  const gl2 = await page.evaluate(() => !!document.getElementById('c').getContext('webgl2'));
  check('webgl2-available', gl2 === true, `webgl2=${gl2}`);

  const golden = await page.evaluate(() => window.__render('golden'));
  const candidate = await page.evaluate(() => window.__render('candidate'));
  const perturbed = await page.evaluate(() => window.__render('perturbed'));

  writeFileSync(`${outDir}/golden.png`, Buffer.from(golden.png.split(',')[1], 'base64'));
  writeFileSync(`${outDir}/perturbed.png`, Buffer.from(perturbed.png.split(',')[1], 'base64'));

  const g = Uint8Array.from(golden.rgba);
  const c = Uint8Array.from(candidate.rgba);
  const p = Uint8Array.from(perturbed.rgba);

  const sameStats = diffRGBA(g, c, W, H);
  check('identical-render=0diff', sameStats.diffPixels === 0,
    `diffPixels=${sameStats.diffPixels} maxDelta=${sameStats.maxChannelDelta}`);

  const pertStats = diffRGBA(g, p, W, H);
  const v = verdict(pertStats);
  check('perturbed-detected', v.ok === false,
    `diffPixels=${pertStats.diffPixels} ratio=${(pertStats.diffRatio * 100).toFixed(2)}% maxDelta=${pertStats.maxChannelDelta} mean=${pertStats.meanAbsDelta.toFixed(3)} -> ${v.reason}`);

  // 黄金样本的稳定性:golden 里应同时出现岛色与浪色(证明场景真的画上了)。
  const hasIsland = g.some((_, i) => i % 4 === 0 && g[i] > 200 && g[i + 1] < 90);
  const hasWave = g.some((_, i) => i % 4 === 0 && g[i] < 90 && g[i + 2] > 200);
  check('scene-has-both-blocks', hasIsland && hasWave, `island=${hasIsland} wave=${hasWave}`);

  writeFileSync(`${outDir}/golden-report.json`,
    JSON.stringify({ sameStats, pertStats, verdict: v }, null, 2));
} finally {
  await browser.close();
  srv.close();
}

console.log(`\n=== golden-render: ${failures === 0 ? 'ALL PASS' : failures + ' FAILED'} ===`);
process.exit(failures === 0 ? 0 : 1);

本机实跑输出(headless Chromium,128×128 画布,共 16384 像素):

PASS  webgl2-available  webgl2=true
PASS  identical-render=0diff  diffPixels=0 maxDelta=0
PASS  perturbed-detected  diffPixels=622 ratio=3.80% maxDelta=204 mean=2.042 -> 通道差 204 > 2
PASS  scene-has-both-blocks  island=true wave=true

=== golden-render: ALL PASS ===

四个数字值得逐一读:

  • identical-render=0diff:同一场景两次渲染,16384 个像素、65536 个通道,差异为 0。这是黄金图像测试能成立的前提——在本机的 WebGL2 实现下,固定场景是逐位稳定的,容差并非「为了掩盖噪声」,而是为了应付换机器、换驱动时的 LSB 抖动。
  • diffPixels=622 ratio=3.80%:一个 3 像素的平移,改动了 622 个像素(占 3.80%)。位移是连续的,边界上大量像素只是「从岛色变成背景色」这类一次性跳变。
  • maxDelta=204:边界处单个通道的最大跳变。这个值远超默认容差 2,所以判红。
  • mean=2.042:全图平均绝对差。它很小(因为绝大多数像素没动),说明只看平均值会漏掉局部回归——这正是为什么判据里既有平均值、又有「最大通道差」和「差异像素占比」两个硬阈值。

容差策略:把「抖动」与「回归」分开

上面四个量对应两种失败模式,容差要分别对待。抖动(jitter)是同一份代码在不同 GPU 上的一到几个 LSB 差异,表现为 maxChannelDelta 很小(≤ 2)、diffRatio 散布全图但很低;回归(regression)是代码改错,表现为某个通道整体偏移或一整块区域变化,maxChannelDelta 或 diffRatio 至少有一个明显超标。默认容差 maxChannelDelta=2, maxDiffRatio=0.5% 把两者分开:抖动被放过,回归被判红。

工程上有三条经验。其一,容差要显式、要能单点放宽——把 verdict 的阈值做成参数,某张图确实天生抖动大,就单独给它放宽,而不是全局调松。其二,黄金图像要归档而不是每次重生成——golden.png 存进仓库,改了渲染才由人审阅后手动更新,否则「测试总是通过」会变成自欺。其三,场景要小而确定——画布越小、越少依赖采样和滤波,越接近逐位稳定;上面 128×128 的纯色块就做到了零差异。

黄金图像抓住了「画出来对不对」,但它每跑一次都要真的上 GPU,慢且重。有一类回归比画面更底层、也更该在每次提交里跑:游戏逻辑的状态。下一节我们把「固定种子 + 输入回放 → 状态哈希」做出来,让一整局游戏的演化变成一串可在 Node 里逐帧比对的哈希。

阅读导航:上一节:2.1 内核单测与假对象 · 下一节:2.3 确定性回放与回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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