music-video-gen/flow-state/src/engine/Renderer.js
Dejvino 7e31c19d6e Phase 0: determinism spine
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>
2026-08-05 10:59:30 +02:00

120 lines
4.4 KiB
JavaScript

import * as THREE from 'three';
import { VERTEX_SHADER } from './shader-contract.js';
/**
* Thin wrapper over WebGLRenderer that provides the two primitives everything
* else is built from: allocate a render target, and run a fullscreen shader pass
* into one. Keeping this small matters — preview and export share it exactly,
* and any state that leaks between frames here would break determinism.
*/
export class Renderer {
constructor({ width = 1280, height = 720, canvas = null } = {}) {
this.gl = new THREE.WebGLRenderer({
canvas: canvas || undefined,
antialias: false, // we render through targets; MSAA here buys nothing
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
powerPreference: 'high-performance',
});
this.gl.autoClear = false;
this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
this.gl.setSize(width, height, false);
this.width = width;
this.height = height;
// Fullscreen quad rig, reused for every pass.
this.quadScene = new THREE.Scene();
this.quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
this.quadGeometry = new THREE.PlaneGeometry(2, 2);
this.quadMesh = new THREE.Mesh(this.quadGeometry, new THREE.MeshBasicMaterial());
this.quadMesh.frustumCulled = false;
this.quadScene.add(this.quadMesh);
this._readBuffer = null;
}
get canvas() {
return this.gl.domElement;
}
setSize(width, height) {
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.gl.setSize(width, height, false);
this._readBuffer = null;
}
createTarget(width = this.width, height = this.height, options = {}) {
const target = new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
type: options.float ? THREE.HalfFloatType : THREE.UnsignedByteType,
depthBuffer: options.depth === true,
stencilBuffer: false,
generateMipmaps: false,
});
target.texture.wrapS = THREE.ClampToEdgeWrapping;
target.texture.wrapT = THREE.ClampToEdgeWrapping;
// Deterministic initial contents: never inherit whatever was in GPU memory.
this.clear(target);
return target;
}
clear(target = null, r = 0, g = 0, b = 0, a = 1) {
const prev = this.gl.getClearColor(new THREE.Color());
const prevAlpha = this.gl.getClearAlpha();
this.gl.setRenderTarget(target);
this.gl.setClearColor(new THREE.Color(r, g, b), a);
this.gl.clear(true, true, true);
this.gl.setClearColor(prev, prevAlpha);
this.gl.setRenderTarget(null);
}
/** Run a fullscreen shader pass. target === null renders to the canvas. */
blit(material, target = null) {
this.quadMesh.material = material;
this.gl.setRenderTarget(target);
this.gl.clear(true, false, false);
this.gl.render(this.quadScene, this.quadCamera);
this.gl.setRenderTarget(null);
}
/** Render a real three.js scene (used by 3D layers). */
renderScene(scene, camera, target = null, clear = true) {
this.gl.setRenderTarget(target);
if (clear) this.gl.clear(true, true, true);
this.gl.render(scene, camera);
this.gl.setRenderTarget(null);
}
readPixels(target) {
const w = target ? target.width : this.width;
const h = target ? target.height : this.height;
const needed = w * h * 4;
if (!this._readBuffer || this._readBuffer.length !== needed) {
this._readBuffer = new Uint8Array(needed);
}
this.gl.readRenderTargetPixels(target, 0, 0, w, h, this._readBuffer);
return this._readBuffer;
}
dispose() {
this.quadGeometry.dispose();
this.gl.dispose();
}
}
/** Convenience for building the shader materials used by passes. */
export function makePassMaterial(fragmentShader, uniforms) {
return new THREE.ShaderMaterial({
vertexShader: VERTEX_SHADER,
fragmentShader,
uniforms,
depthTest: false,
depthWrite: false,
transparent: true,
});
}