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>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import * as THREE from 'three';
|
||||
import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS } from './shader-contract.js';
|
||||
import { clampValue } from '../params/schema.js';
|
||||
|
||||
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
|
||||
|
||||
/**
|
||||
* Map a raw feature value through a response curve. `spike` squares the input so
|
||||
* beat-driven params punch rather than wobble; `smooth` rounds the shoulders so
|
||||
* slow features don't step.
|
||||
*/
|
||||
function applyResponse(value, response) {
|
||||
switch (response) {
|
||||
case 'spike': return value * value;
|
||||
case 'smooth': return value * value * (3 - 2 * value);
|
||||
case 'inverse': return 1 - value;
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Common surface for shader layers and 3D layers, so Compositor holds one type. */
|
||||
export class Layer {
|
||||
constructor({ module, params = {}, seed = 1, opacity = 1, blend = 'normal' }) {
|
||||
this.module = module;
|
||||
this.baseParams = { ...params };
|
||||
this.params = { ...params };
|
||||
this.seed = seed >>> 0;
|
||||
this.opacity = opacity;
|
||||
this.blend = blend;
|
||||
this.palette = [];
|
||||
}
|
||||
|
||||
setParams(params) {
|
||||
this.baseParams = { ...this.baseParams, ...params };
|
||||
return this;
|
||||
}
|
||||
|
||||
setPalette(colors) {
|
||||
this.palette = colors;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Resolve base params + reactive modulation into the values used this frame. */
|
||||
resolveParams(features) {
|
||||
const defs = this.module.params || {};
|
||||
const reactive = this.module.reactive || {};
|
||||
const out = this.params;
|
||||
|
||||
for (const name of Object.keys(defs)) out[name] = this.baseParams[name];
|
||||
|
||||
if (!features) return out;
|
||||
|
||||
for (const [name, r] of Object.entries(reactive)) {
|
||||
const def = defs[name];
|
||||
if (!def || def.type === 'palette') continue;
|
||||
const raw = features[r.feature];
|
||||
if (raw === undefined) continue;
|
||||
|
||||
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), r.response);
|
||||
const [lo, hi] = def.range || [0, 1];
|
||||
const span = hi - lo;
|
||||
const base = out[name] !== undefined ? out[name] : lo;
|
||||
|
||||
if (def.type === 'vec2') {
|
||||
out[name] = [
|
||||
clampValue(def, [base[0] + shaped * r.amount * span, 0])[0],
|
||||
clampValue(def, [0, base[1] + shaped * r.amount * span])[1],
|
||||
];
|
||||
} else if (def.type === 'bool') {
|
||||
out[name] = base;
|
||||
} else {
|
||||
out[name] = clampValue(def, base + shaped * r.amount * span);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
render() { throw new Error('Layer.render not implemented'); }
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
/** A fullscreen fragment-shader scene. The common case. */
|
||||
export class ShaderLayer extends Layer {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
const uniforms = {
|
||||
u_resolution: { value: new THREE.Vector2(1, 1) },
|
||||
u_aspect: { value: 1 },
|
||||
u_pixelScale: { value: 1 },
|
||||
u_time: { value: 0 },
|
||||
u_frame: { value: 0 },
|
||||
u_progress: { value: 0 },
|
||||
u_seed: { value: (this.seed % 100000) / 1000 },
|
||||
u_opacity: { value: 1 },
|
||||
u_colors: { value: Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1)) },
|
||||
u_colorCount: { value: 1 },
|
||||
u_prev: { value: null },
|
||||
u_hasPrev: { value: 0 },
|
||||
};
|
||||
for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
|
||||
|
||||
for (const [name, def] of Object.entries(this.module.params || {})) {
|
||||
if (!def.uniform || def.type === 'palette') continue;
|
||||
const v = this.baseParams[name];
|
||||
uniforms[def.uniform] = {
|
||||
value: def.type === 'vec2'
|
||||
? new THREE.Vector2(v ? v[0] : 0, v ? v[1] : 0)
|
||||
: def.type === 'bool' ? (v ? 1 : 0) : (v || 0),
|
||||
};
|
||||
}
|
||||
|
||||
this.uniforms = uniforms;
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
vertexShader: VERTEX_SHADER,
|
||||
fragmentShader: buildFragmentShader(this.module),
|
||||
uniforms,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
render(renderer, target, ctx) {
|
||||
const { timeline, features, prevTexture } = ctx;
|
||||
const u = this.uniforms;
|
||||
const w = target ? target.width : renderer.width;
|
||||
const h = target ? target.height : renderer.height;
|
||||
|
||||
u.u_resolution.value.set(w, h);
|
||||
u.u_aspect.value = w / h;
|
||||
u.u_pixelScale.value = h / 1080; // reference height; keeps 720p ≡ 4K
|
||||
u.u_time.value = timeline.time;
|
||||
u.u_frame.value = timeline.frame;
|
||||
u.u_progress.value = timeline.progress;
|
||||
u.u_opacity.value = this.opacity;
|
||||
|
||||
if (features) {
|
||||
for (const name of AUDIO_UNIFORMS) {
|
||||
const key = name.slice(2); // u_bandLow -> bandLow
|
||||
const v = features[key];
|
||||
u[name].value = v === undefined ? 0 : v;
|
||||
}
|
||||
}
|
||||
|
||||
const colors = this.palette || [];
|
||||
u.u_colorCount.value = Math.max(1, Math.min(8, colors.length));
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const c = colors[i % Math.max(1, colors.length)];
|
||||
if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
|
||||
}
|
||||
|
||||
u.u_prev.value = prevTexture || null;
|
||||
u.u_hasPrev.value = prevTexture ? 1 : 0;
|
||||
|
||||
const resolved = this.resolveParams(features);
|
||||
for (const [name, def] of Object.entries(this.module.params || {})) {
|
||||
if (!def.uniform || def.type === 'palette') continue;
|
||||
const target_u = u[def.uniform];
|
||||
const v = resolved[name];
|
||||
if (v === undefined) continue;
|
||||
if (def.type === 'vec2') target_u.value.set(v[0], v[1]);
|
||||
else if (def.type === 'bool') target_u.value = v ? 1 : 0;
|
||||
else target_u.value = v;
|
||||
}
|
||||
|
||||
renderer.blit(this.material, target);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer backed by a real three.js scene — particles, geometry, camera motion.
|
||||
* The module supplies build/update hooks; everything determinism-related (seeded
|
||||
* rng, fixed dt, explicit re-seed at section boundaries) is handled here so 3D
|
||||
* modules can't accidentally reintroduce wall-clock or Math.random.
|
||||
*/
|
||||
export class SceneLayer extends Layer {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.scene = new THREE.Scene();
|
||||
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
|
||||
this.camera.position.set(0, 0, 5);
|
||||
this.instance = this.module.build({
|
||||
scene: this.scene,
|
||||
camera: this.camera,
|
||||
seed: this.seed,
|
||||
params: this.baseParams,
|
||||
THREE,
|
||||
});
|
||||
}
|
||||
|
||||
render(renderer, target, ctx) {
|
||||
const { timeline, features } = ctx;
|
||||
const w = target ? target.width : renderer.width;
|
||||
const h = target ? target.height : renderer.height;
|
||||
if (this.camera.aspect !== w / h) {
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
const resolved = this.resolveParams(features);
|
||||
this.module.update({
|
||||
instance: this.instance,
|
||||
scene: this.scene,
|
||||
camera: this.camera,
|
||||
timeline,
|
||||
features: features || {},
|
||||
params: resolved,
|
||||
palette: this.palette,
|
||||
opacity: this.opacity,
|
||||
THREE,
|
||||
});
|
||||
renderer.renderScene(this.scene, this.camera, target, true);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach((m) => m.dispose());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createLayer(module, options) {
|
||||
if (module.kind === 'layer3d') return new SceneLayer({ module, ...options });
|
||||
return new ShaderLayer({ module, ...options });
|
||||
}
|
||||
Reference in New Issue
Block a user