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 确定性回放与回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。