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>
120 lines
4.4 KiB
JavaScript
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,
|
|
});
|
|
}
|