Ten new scenes, with 'minimal' first: the family was empty, so intros and breakdowns fell through to flow/organic and every track opened at full density. Quiet sections now land on a restful family 48/48 times across 24 seeds, 28 of them minimal. New: Horizon Lines, Spectrum Sculpture, Slow Orb (minimal); Curl Flow (flow); Plasma Bloom, Metaballs (organic); Kaleido Tunnel, Moiré Grid (geometric); Ridge Terrain (structural); Scan Tear (glitch). Four real bugs, three of which the existing gates could not have caught: 1. SHADER PROGRAMS LINK ASYNCHRONOUSLY. three.js uses KHR_parallel_shader_compile, so draws against an unlinked program render wrong. The heaviest scene had its first TEN frames differ from every later render of the same frames. Preview hides this entirely; export renders each frame once, so those frames would ship broken. Added Engine.prime() — WebGLRenderer.compile() plus a discarded warm frame — and the exporter now primes before encoding. Rendering a throwaway frame and reading it back is NOT sufficient; measured, it left 3-5 frames wrong. 2. Moiré Grid declared a param on u_width, which the shader contract already uses for stereo width. GLSL redefinition, and the only symptom was a black frame. Lint now rejects any param uniform colliding with the contract. 3. Spectrum Sculpture strobed at 4 flashes/s. Two causes: rotation measured in turns meant bar-crossing frequency was bars x rate (82 bars put a slow-looking 0.12 turns/s at 10 Hz), and hard band-tier boundaries made every bar switch band simultaneously. Rotation is now in segment units so the rate IS the crossing frequency, bands interpolate, and the range is capped where the flash meter measures zero. 4. Particle Field was being chosen as a primary background despite being mostly empty by design. Scenes now declare role: 'accent'; those are never primary and are judged on variance rather than luminance. Three checks were themselves wrong and were rebuilt: mean-distance metrics unfairly fail sparse scenes for being tasteful rather than static, so "animates" and "no duplicates" now use max channel delta. PLAN.md §1 gains two refinements: programs must be primed before the first frame, and even same-machine the heaviest shaders vary by one LSB under differing GPU load — so the per-scene criterion is max delta <= 1 rather than an identical hash. A real bug scores in the tens there. Full suite 67/67 across all seven phases. Worst 4K frame 3.8ms, worst flash rate 0/s, worst determinism delta 1/255. Adds README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
5.1 KiB
JavaScript
140 lines
5.1 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);
|
|
}
|
|
|
|
/**
|
|
* Force a material's shader program to compile and link NOW.
|
|
*
|
|
* three.js links through KHR_parallel_shader_compile, so a freshly created
|
|
* material can be drawn with a program that is not ready yet, producing wrong
|
|
* frames until it is. Rendering a throwaway frame and reading it back does not
|
|
* reliably wait for the link; WebGLRenderer.compile() does.
|
|
*/
|
|
compileMaterial(material) {
|
|
const previous = this.quadMesh.material;
|
|
this.quadMesh.material = material;
|
|
this.gl.compile(this.quadScene, this.quadCamera);
|
|
this.quadMesh.material = previous;
|
|
}
|
|
|
|
/** Same, for a 3D layer's own scene. */
|
|
compileScene(scene, camera) {
|
|
this.gl.compile(scene, camera);
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|