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:
Dejvino
2026-08-05 10:59:30 +02:00
co-authored by Claude Opus 5
parent e8fb647f11
commit 7e31c19d6e
31 changed files with 3408 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import { validateModule } from '../params/schema.js';
import { nebula } from './shader/nebula.js';
/**
* The scene library. Families exist so the arc driver can choose by section
* character rather than at random — a breakdown never lands on a strobing glitch
* scene, and an intro never opens at full density.
*/
export const FAMILIES = {
flow: { label: 'Flow', energy: [0.0, 0.7] },
organic: { label: 'Organic', energy: [0.0, 0.8] },
minimal: { label: 'Minimal', energy: [0.0, 0.45] },
structural: { label: 'Structural', energy: [0.3, 0.9] },
geometric: { label: 'Geometric', energy: [0.4, 1.0] },
glitch: { label: 'Glitch', energy: [0.6, 1.0] },
};
const MODULES = [
nebula,
];
const errors = [];
for (const m of MODULES) {
const e = validateModule(m);
if (e.length) errors.push(...e);
if (m.family && !FAMILIES[m.family]) errors.push(`${m.name}: unknown family '${m.family}'`);
}
if (errors.length) {
// Fail loudly at import: a malformed scene must never reach the compositor,
// where the symptom would be a black frame with no explanation.
console.error('[registry] invalid scene modules:\n' + errors.join('\n'));
}
export const scenes = MODULES;
export const sceneErrors = errors;
export function sceneByName(name) {
return MODULES.find((m) => m.name === name) || null;
}
export function scenesInFamily(family) {
return MODULES.filter((m) => m.family === family);
}
export function familyNames() {
return Object.keys(FAMILIES);
}
export default scenes;
+59
View File
@@ -0,0 +1,59 @@
// Ported from party-stage's "Deep Nebula". Changes on port:
// - LED-grid mask removed (it existed to sell a screen inside a 3D room)
// - hardcoded vec3 colours replaced with palette lookups
// - magic numbers lifted into declared params
// - beat reactivity moved from a single u_beat multiply to the reactive block
export const nebula = {
name: 'Deep Nebula',
family: 'organic',
kind: 'fragment',
params: {
scale: { type: 'float', range: [4, 24], default: 12, uniform: 'u_scale', bias: 'density' },
swirl: { type: 'float', range: [0, 3], default: 1.0, uniform: 'u_swirl', bias: 'motion' },
rings: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_rings' },
glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },
speed: { type: 'float', range: [0.1, 1.5],default: 0.5, uniform: 'u_speed', bias: 'motion' },
depth: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_depth' },
palette: { type: 'palette', count: 4 },
},
reactive: {
glow: { feature: 'beat', amount: 0.35, response: 'spike' },
swirl: { feature: 'bandLow', amount: 0.20 },
rings: { feature: 'flux', amount: 0.25, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float r = length(p);
float a = atan(p.y, p.x);
float t = u_time * u_speed + u_seed;
// Layered drift, the "nebula" body.
float n = fbm(p * u_scale * 0.25 + vec2(t * 0.2, -t * 0.15), 5);
float n2 = fbm(p * u_scale * 0.6 - vec2(t * 0.1, t * 0.25) + n, 4);
float body = sin(r * u_scale - t * 2.0 + n * 6.0 * u_swirl) * 0.5 + 0.5;
body = mix(body, n2, u_depth);
vec3 col = mix(pal(0), pal(1), sat(body));
col = mix(col, pal(2), sat(n2 * n2) * u_depth);
// Concentric pulse, tied to onset energy rather than a fixed rate.
float ring = smoothstep(0.4, 0.5, abs(fract(r * 2.0 - t * 3.0) - 0.5));
col += pal(1) * ring * u_rings * 0.6;
// Core glow.
float edge = 1.0 - smoothstep(0.1, 0.9, r);
col += pal(3) * edge * u_glow * 0.5;
// Vignette the far field so the frame has a subject.
col *= 0.5 + 0.5 * (1.0 - smoothstep(0.6, 1.6, r));
return vec4(col, 1.0);
}
`,
};
export default nebula;