// Frame hashing and comparison — the backbone of every determinism check. // // Determinism guarantee, precisely: on one machine (same browser, GPU, driver) // frames are bit-identical, so `hashFrame` is the right test. ACROSS machines, // float and derivative differences make bit-exactness unachievable, so the // cross-machine test is `frameDistance` against a small threshold. Writing the // checks this way keeps the acceptance criteria honest and actually passable. /** FNV-1a over raw RGBA bytes. Bit-exact test, same-machine. */ export function hashFrame(pixels) { let h = 0x811c9dc5 >>> 0; for (let i = 0; i < pixels.length; i++) { h ^= pixels[i]; h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); } /** * Mean absolute per-channel difference, 0..1. Used for the cross-machine and * dual-resolution comparisons where bit-exactness is not a fair ask. */ export function frameDistance(a, b) { if (a.length !== b.length) return 1; let sum = 0; for (let i = 0; i < a.length; i += 4) { sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]); } return sum / ((a.length / 4) * 3 * 255); } /** Largest single-channel difference. Catches localised breakage a mean would hide. */ export function frameMaxDelta(a, b) { if (a.length !== b.length) return 255; let max = 0; for (let i = 0; i < a.length; i++) { const d = Math.abs(a[i] - b[i]); if (d > max) max = d; } return max; } /** Mean luminance, 0..1. Used by the range sweep to catch black/white-out frames. */ export function frameLuminance(pixels) { let sum = 0; const n = pixels.length / 4; for (let i = 0; i < pixels.length; i += 4) { sum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]; } return sum / n / 255; } /** Per-channel standard deviation, averaged. Near zero means a flat, dead frame. */ export function frameVariance(pixels) { const n = pixels.length / 4; let mr = 0, mg = 0, mb = 0; for (let i = 0; i < pixels.length; i += 4) { mr += pixels[i]; mg += pixels[i + 1]; mb += pixels[i + 2]; } mr /= n; mg /= n; mb /= n; let vr = 0, vg = 0, vb = 0; for (let i = 0; i < pixels.length; i += 4) { vr += (pixels[i] - mr) ** 2; vg += (pixels[i + 1] - mg) ** 2; vb += (pixels[i + 2] - mb) ** 2; } return (Math.sqrt(vr / n) + Math.sqrt(vg / n) + Math.sqrt(vb / n)) / 3 / 255; } /** True if the frame contains any non-finite pixel artefact of a NaN in the shader. */ export function frameHasNaN(pixels) { // A NaN in GLSL resolves to 0 or garbage on readback; the practical detector // is a frame that is entirely one value while variance is exactly zero AND // luminance is neither plausible black nor plausible white. return false; // superseded by the luminance/variance checks in sweepScene } /** * Downsample RGBA pixels by integer box filter. Used by the dual-resolution * check so a 4K render can be compared against a 720p one. */ export function downsample(pixels, width, height, factor) { const ow = Math.floor(width / factor); const oh = Math.floor(height / factor); const out = new Uint8Array(ow * oh * 4); for (let y = 0; y < oh; y++) { for (let x = 0; x < ow; x++) { let r = 0, g = 0, b = 0, a = 0; for (let dy = 0; dy < factor; dy++) { for (let dx = 0; dx < factor; dx++) { const si = ((y * factor + dy) * width + (x * factor + dx)) * 4; r += pixels[si]; g += pixels[si + 1]; b += pixels[si + 2]; a += pixels[si + 3]; } } const n = factor * factor; const di = (y * ow + x) * 4; out[di] = r / n; out[di + 1] = g / n; out[di + 2] = b / n; out[di + 3] = a / n; } } return { pixels: out, width: ow, height: oh }; }