Engine core: Timeline (fixed dt, audio-mastered in realtime), seeded Rng, Renderer, Layer/ShaderLayer/SceneLayer, Compositor with blend modes, feedback and post chain. Shader scenes are compiled against a fixed uniform contract and define only `vec4 scene(vec2 uv, vec2 p)`, so adding a scene costs a shader plus a params block. Deep Nebula ported from party-stage as the first one. Gate passes, 7/7 in checks.html: - 300 frames rendered twice are bit-identical - a fresh Engine reproduces the same frames - simulated dropped frames change nothing (proves dt is fixed) - seek matches sequential playback - 320x180 vs 1280x720 agree within 0.010 (limit 0.06) - seeded rng reproducible, forked streams independent - compositor reset clears feedback history Static gates: no wall-clock or unseeded randomness in deterministic directories; scene schemas and shader sources agree in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
3.9 KiB
JavaScript
98 lines
3.9 KiB
JavaScript
// 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 };
|
|
}
|