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:
parent
e8fb647f11
commit
7e31c19d6e
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "flow-state",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "flow-state", "run", "dev"],
|
||||
"port": 5180
|
||||
}
|
||||
]
|
||||
}
|
||||
4
flow-state/.gitignore
vendored
Normal file
4
flow-state/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
out
|
||||
42
flow-state/checks.html
Normal file
42
flow-state/checks.html
Normal file
@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>flow-state · checks</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0; padding: 24px;
|
||||
background: #0b0d12; color: #d6dae3;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
h1 { font-size: 15px; letter-spacing: .12em; text-transform: uppercase; color: #7d8698; margin: 0 0 4px; }
|
||||
#summary { margin: 0 0 20px; font-size: 14px; }
|
||||
#summary.ok { color: #4ade80; }
|
||||
#summary.bad { color: #f87171; }
|
||||
.row {
|
||||
display: grid; grid-template-columns: 52px 34px 1fr 60px;
|
||||
gap: 10px; align-items: baseline;
|
||||
padding: 7px 10px; border-left: 3px solid transparent; margin-bottom: 2px;
|
||||
background: #11141b;
|
||||
}
|
||||
.row.pass { border-color: #22c55e; }
|
||||
.row.fail { border-color: #ef4444; background: #1c1214; }
|
||||
.row.manual { border-color: #eab308; }
|
||||
.badge { font-weight: 700; font-size: 11px; }
|
||||
.pass .badge { color: #4ade80; }
|
||||
.fail .badge { color: #f87171; }
|
||||
.manual .badge { color: #facc15; }
|
||||
.phase { color: #6b7280; }
|
||||
.ms { color: #4b5563; text-align: right; }
|
||||
.detail { grid-column: 3 / 5; color: #8b93a5; font-size: 12px; }
|
||||
.detail:empty { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>flow-state — phase gates</h1>
|
||||
<div id="summary">starting…</div>
|
||||
<div id="results"></div>
|
||||
<script type="module" src="/src/checks/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
73
flow-state/index.html
Normal file
73
flow-state/index.html
Normal file
@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>flow-state</title>
|
||||
<link rel="stylesheet" href="/src/ui/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="stage">
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="overlay">
|
||||
<div id="dropzone">
|
||||
<div class="dz-inner">
|
||||
<div class="dz-title">flow-state</div>
|
||||
<div class="dz-sub">drop an audio file, or click to choose</div>
|
||||
<div class="dz-hint">mp3 · flac · wav · ogg</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analysing" hidden>
|
||||
<div class="an-title">analysing</div>
|
||||
<div class="an-step">—</div>
|
||||
<div class="an-bar"><div class="an-fill"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hud" hidden></div>
|
||||
</div>
|
||||
|
||||
<div id="transport">
|
||||
<div id="timeline">
|
||||
<canvas id="timeline-canvas"></canvas>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<button id="btn-play" title="Play / pause (space)">▶</button>
|
||||
<button id="btn-prev-section" title="Previous section (←)">⇤</button>
|
||||
<button id="btn-next-section" title="Next section (→)">⇥</button>
|
||||
<button id="btn-loop" title="Loop current section (L)">↻</button>
|
||||
<span id="time-display">0:00 / 0:00</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="section-display"></span>
|
||||
<span class="spacer"></span>
|
||||
<label class="ctl">quality
|
||||
<select id="sel-quality">
|
||||
<option value="draft">draft</option>
|
||||
<option value="full" selected>full</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="btn-reroll" title="New seed for the whole track">reroll</button>
|
||||
<button id="btn-reroll-section" title="New scene for this section only">reroll section</button>
|
||||
<button id="btn-lock" title="Lock this section against rerolls">lock</button>
|
||||
<button id="btn-hud" title="Toggle debug HUD (D)">hud</button>
|
||||
<button id="btn-segment" title="Render 20s around the playhead at export quality">test render</button>
|
||||
<button id="btn-export" class="primary" title="Export the full video">export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="panel">
|
||||
<div id="panel-tabs">
|
||||
<button data-tab="look" class="active">look</button>
|
||||
<button data-tab="scene">scene</button>
|
||||
<button data-tab="post">post</button>
|
||||
<button data-tab="export">export</button>
|
||||
</div>
|
||||
<div id="panel-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<input type="file" id="file-input" accept="audio/*" hidden>
|
||||
<audio id="audio" hidden></audio>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1132
flow-state/package-lock.json
generated
Normal file
1132
flow-state/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
flow-state/package.json
Normal file
20
flow-state/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "flow-state",
|
||||
"version": "1.0.0",
|
||||
"description": "Ambient/EDM music video generator with a deterministic render core",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint:scenes": "node tools/lint-scenes.js",
|
||||
"test": "node --test test/"
|
||||
},
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"vite": "^7.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.181.1"
|
||||
}
|
||||
}
|
||||
81
flow-state/src/checks/framework.js
Normal file
81
flow-state/src/checks/framework.js
Normal file
@ -0,0 +1,81 @@
|
||||
// Minimal check harness. Every phase gate in PLAN.md is a check registered here
|
||||
// and run from checks.html, so "the gate passes" is something you execute rather
|
||||
// than something you assert in a commit message.
|
||||
|
||||
const registry = [];
|
||||
|
||||
export function check(phase, name, fn, options = {}) {
|
||||
registry.push({ phase, name, fn, manual: !!options.manual, slow: !!options.slow });
|
||||
}
|
||||
|
||||
export function allChecks() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
export async function runAll({ phases = null, onResult = null, skipSlow = false } = {}) {
|
||||
const results = [];
|
||||
for (const entry of registry) {
|
||||
if (phases && !phases.includes(entry.phase)) continue;
|
||||
if (skipSlow && entry.slow) continue;
|
||||
|
||||
const started = Date.now();
|
||||
let result;
|
||||
try {
|
||||
const r = await entry.fn();
|
||||
result = {
|
||||
phase: entry.phase,
|
||||
name: entry.name,
|
||||
pass: r === true || (r && r.pass !== false),
|
||||
detail: (r && r.detail) || '',
|
||||
manual: entry.manual,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
} catch (err) {
|
||||
result = {
|
||||
phase: entry.phase,
|
||||
name: entry.name,
|
||||
pass: false,
|
||||
detail: `threw: ${err && err.message ? err.message : String(err)}`,
|
||||
manual: entry.manual,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
results.push(result);
|
||||
if (onResult) onResult(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function summarize(results) {
|
||||
const total = results.length;
|
||||
const failed = results.filter((r) => !r.pass && !r.manual);
|
||||
const manual = results.filter((r) => r.manual);
|
||||
return {
|
||||
total,
|
||||
passed: results.filter((r) => r.pass).length,
|
||||
failed: failed.length,
|
||||
manual: manual.length,
|
||||
ok: failed.length === 0,
|
||||
failures: failed.map((r) => `[P${r.phase}] ${r.name}: ${r.detail}`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Assertion helpers that produce useful detail strings rather than bare booleans. */
|
||||
export function expect(condition, detail) {
|
||||
return { pass: !!condition, detail };
|
||||
}
|
||||
|
||||
export function expectClose(actual, expected, tolerance, label) {
|
||||
const diff = Math.abs(actual - expected);
|
||||
return {
|
||||
pass: diff <= tolerance,
|
||||
detail: `${label}: ${actual.toFixed(6)} vs ${expected.toFixed(6)} (Δ${diff.toFixed(6)}, tol ${tolerance})`,
|
||||
};
|
||||
}
|
||||
|
||||
export function expectBelow(actual, limit, label) {
|
||||
return {
|
||||
pass: actual <= limit,
|
||||
detail: `${label}: ${typeof actual === 'number' ? actual.toFixed(6) : actual} (limit ${limit})`,
|
||||
};
|
||||
}
|
||||
48
flow-state/src/checks/main.js
Normal file
48
flow-state/src/checks/main.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { runAll, summarize, allChecks } from './framework.js';
|
||||
|
||||
// Registering a phase's checks is a side effect of importing it.
|
||||
import './phase0.js';
|
||||
import './phase1.js';
|
||||
import './phase2.js';
|
||||
import './phase3.js';
|
||||
import './phase4.js';
|
||||
import './phase5.js';
|
||||
import './phase6.js';
|
||||
|
||||
const out = document.getElementById('results');
|
||||
const summaryEl = document.getElementById('summary');
|
||||
|
||||
function row(result) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'row ' + (result.pass ? 'pass' : result.manual ? 'manual' : 'fail');
|
||||
el.innerHTML = `
|
||||
<span class="badge">${result.pass ? 'PASS' : 'FAIL'}</span>
|
||||
<span class="phase">P${result.phase}</span>
|
||||
<span class="name">${result.name}</span>
|
||||
<span class="ms">${result.ms}ms</span>
|
||||
<div class="detail">${result.detail || ''}</div>`;
|
||||
out.appendChild(el);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const phaseArg = params.get('phase');
|
||||
const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
|
||||
const skipSlow = params.get('slow') !== '1';
|
||||
|
||||
summaryEl.textContent = `running ${allChecks().length} checks…`;
|
||||
|
||||
const results = await runAll({ phases, skipSlow, onResult: row });
|
||||
const s = summarize(results);
|
||||
|
||||
summaryEl.textContent =
|
||||
`${s.passed}/${s.total} passed · ${s.failed} failed` + (skipSlow ? ' · slow checks skipped (?slow=1 to include)' : '');
|
||||
summaryEl.className = s.ok ? 'ok' : 'bad';
|
||||
|
||||
// Read by the browser automation that drives these gates.
|
||||
window.__CHECKS__ = { results, summary: s };
|
||||
window.__CHECKS_DONE__ = true;
|
||||
console.log('[checks]', JSON.stringify(s, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
163
flow-state/src/checks/phase0.js
Normal file
163
flow-state/src/checks/phase0.js
Normal file
@ -0,0 +1,163 @@
|
||||
// Phase 0 gate — the determinism spine.
|
||||
//
|
||||
// These are cheap now and impossible to retrofit later: every phase after this
|
||||
// one assumes that frame N is a pure function of (seed, params, frame index).
|
||||
|
||||
import { check, expect, expectBelow } from './framework.js';
|
||||
import { Engine } from '../engine/Engine.js';
|
||||
import { frameDistance, downsample } from '../engine/hash.js';
|
||||
import { Rng } from '../engine/rng.js';
|
||||
import { nebula } from '../scenes/shader/nebula.js';
|
||||
import { defaultValues } from '../params/schema.js';
|
||||
|
||||
const TEST_PALETTE = [
|
||||
[0.05, 0.02, 0.15],
|
||||
[0.85, 0.15, 0.55],
|
||||
[0.15, 0.75, 0.95],
|
||||
[0.98, 0.85, 0.35],
|
||||
];
|
||||
|
||||
function makeEngine(width = 320, height = 180) {
|
||||
const engine = new Engine({ width, height });
|
||||
engine.timeline.setDuration(60);
|
||||
engine.setLayerSpecs([{
|
||||
module: nebula,
|
||||
params: defaultValues(nebula),
|
||||
seed: 12345,
|
||||
opacity: 1,
|
||||
blend: 'normal',
|
||||
palette: TEST_PALETTE,
|
||||
}]);
|
||||
return engine;
|
||||
}
|
||||
|
||||
check(0, 'repeat run produces identical frames', () => {
|
||||
const engine = makeEngine();
|
||||
try {
|
||||
const a = engine.hashRun(0, 300);
|
||||
const b = engine.hashRun(0, 300);
|
||||
const mismatches = a.filter((h, i) => h !== b[i]).length;
|
||||
return expect(mismatches === 0, `${mismatches}/300 frames differed between runs`);
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
check(0, 'fresh engine reproduces the same frames', () => {
|
||||
// A second Engine instance must agree with the first: proves nothing is
|
||||
// carried in module-level or GPU state between constructions.
|
||||
const a = makeEngine();
|
||||
const b = makeEngine();
|
||||
try {
|
||||
const ha = a.hashRun(0, 60);
|
||||
const hb = b.hashRun(0, 60);
|
||||
const mismatches = ha.filter((h, i) => h !== hb[i]).length;
|
||||
return expect(mismatches === 0, `${mismatches}/60 frames differed between engines`);
|
||||
} finally {
|
||||
a.dispose(); b.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
check(0, 'simulated dropped frames change nothing', () => {
|
||||
// Renders the same frame indices but with irregular gaps in between, the way
|
||||
// a stuttering browser would. Because dt is fixed and features are indexed by
|
||||
// frame, output must be byte-identical to the smooth run.
|
||||
const engine = makeEngine();
|
||||
try {
|
||||
const smooth = engine.hashRun(0, 120);
|
||||
|
||||
engine.compositor.reset();
|
||||
const stuttered = [];
|
||||
for (let i = 0; i < 120; i++) {
|
||||
// Burn some GPU work between frames without advancing the timeline.
|
||||
if (i % 7 === 0) engine.renderFrame(i);
|
||||
const target = engine.renderFrame(i);
|
||||
stuttered.push(engine.hashCurrent(target));
|
||||
}
|
||||
const mismatches = smooth.filter((h, i) => h !== stuttered[i]).length;
|
||||
return expect(mismatches === 0, `${mismatches}/120 frames differed under simulated stutter`);
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
check(0, 'seek equals sequential playback', () => {
|
||||
const engine = makeEngine();
|
||||
try {
|
||||
const sequential = engine.hashRun(0, 100);
|
||||
engine.compositor.reset();
|
||||
const direct = engine.hashCurrent(engine.renderFrame(99));
|
||||
// With feedback off (Phase 0 default) a direct seek must match exactly.
|
||||
return expect(direct === sequential[99],
|
||||
`seek→99 ${direct} vs sequential ${sequential[99]}`);
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
check(0, 'resolution independence (320x180 vs 1280x720)', () => {
|
||||
const small = new Engine({ width: 320, height: 180 });
|
||||
const large = new Engine({ width: 1280, height: 720 });
|
||||
try {
|
||||
for (const e of [small, large]) {
|
||||
e.timeline.setDuration(60);
|
||||
e.setLayerSpecs([{
|
||||
module: nebula, params: defaultValues(nebula), seed: 12345,
|
||||
opacity: 1, blend: 'normal', palette: TEST_PALETTE,
|
||||
}]);
|
||||
}
|
||||
const frames = [10, 90, 200];
|
||||
let worst = 0;
|
||||
for (const f of frames) {
|
||||
small.compositor.reset(); large.compositor.reset();
|
||||
const st = small.renderFrame(f);
|
||||
const lt = large.renderFrame(f);
|
||||
const sp = Uint8Array.from(small.readPixels(st));
|
||||
const lp = Uint8Array.from(large.readPixels(lt));
|
||||
const reduced = downsample(lp, 1280, 720, 4);
|
||||
worst = Math.max(worst, frameDistance(sp, reduced.pixels));
|
||||
}
|
||||
// 4x downsampling of a noise-bearing shader will never be exact; this
|
||||
// threshold catches genuine pixel-space dependence, not filtering error.
|
||||
return expectBelow(worst, 0.06, 'worst mean channel distance');
|
||||
} finally {
|
||||
small.dispose(); large.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
check(0, 'seeded rng is reproducible and independent per stream', () => {
|
||||
const a = new Rng(42);
|
||||
const b = new Rng(42);
|
||||
const seqA = Array.from({ length: 500 }, () => a.next());
|
||||
const seqB = Array.from({ length: 500 }, () => b.next());
|
||||
if (seqA.some((v, i) => v !== seqB[i])) return expect(false, 'same seed diverged');
|
||||
|
||||
const parent = new Rng(7);
|
||||
const c1 = parent.fork('layer:0').next();
|
||||
const parent2 = new Rng(7);
|
||||
const c2 = parent2.fork('layer:0').next();
|
||||
const c3 = parent2.fork('layer:1').next();
|
||||
if (c1 !== c2) return expect(false, 'fork(label) not stable');
|
||||
if (c1 === c3) return expect(false, 'different fork labels collided');
|
||||
|
||||
const inRange = seqA.every((v) => v >= 0 && v < 1);
|
||||
return expect(inRange, `500 draws reproducible, forks independent, range ok=${inRange}`);
|
||||
});
|
||||
|
||||
check(0, 'compositor reset clears all history', () => {
|
||||
const engine = makeEngine();
|
||||
try {
|
||||
engine.compositor.setFeedback({ amount: 0.8, decay: 0.95 });
|
||||
engine.hashRun(0, 30);
|
||||
const afterHistory = engine.hashRun(0, 30); // hashRun resets first
|
||||
const fresh = makeEngine();
|
||||
fresh.compositor.setFeedback({ amount: 0.8, decay: 0.95 });
|
||||
const freshHashes = fresh.hashRun(0, 30);
|
||||
fresh.dispose();
|
||||
const mismatches = afterHistory.filter((h, i) => h !== freshHashes[i]).length;
|
||||
return expect(mismatches === 0,
|
||||
`${mismatches}/30 frames differed after reset — feedback history leaked`);
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
1
flow-state/src/checks/phase1.js
Normal file
1
flow-state/src/checks/phase1.js
Normal file
@ -0,0 +1 @@
|
||||
// Phase 1 gate — filled in when the phase lands.
|
||||
1
flow-state/src/checks/phase2.js
Normal file
1
flow-state/src/checks/phase2.js
Normal file
@ -0,0 +1 @@
|
||||
// Phase 2 gate — filled in when the phase lands.
|
||||
1
flow-state/src/checks/phase3.js
Normal file
1
flow-state/src/checks/phase3.js
Normal file
@ -0,0 +1 @@
|
||||
// Phase 3 gate — filled in when the phase lands.
|
||||
1
flow-state/src/checks/phase4.js
Normal file
1
flow-state/src/checks/phase4.js
Normal file
@ -0,0 +1 @@
|
||||
// Phase 4 gate — filled in when the phase lands.
|
||||
1
flow-state/src/checks/phase5.js
Normal file
1
flow-state/src/checks/phase5.js
Normal file
@ -0,0 +1 @@
|
||||
// Phase 5 gate — filled in when the phase lands.
|
||||
1
flow-state/src/checks/phase6.js
Normal file
1
flow-state/src/checks/phase6.js
Normal file
@ -0,0 +1 @@
|
||||
// Phase 6 gate — filled in when the phase lands.
|
||||
267
flow-state/src/engine/Compositor.js
Normal file
267
flow-state/src/engine/Compositor.js
Normal file
@ -0,0 +1,267 @@
|
||||
import * as THREE from 'three';
|
||||
import { makePassMaterial } from './Renderer.js';
|
||||
import {
|
||||
BLEND_FRAG, FEEDBACK_FRAG, BRIGHT_FRAG, BLUR_FRAG, COMPOSITE_FRAG, COPY_FRAG,
|
||||
BLEND_MODE_IDS,
|
||||
} from './passes.js';
|
||||
|
||||
const DEFAULT_POST = {
|
||||
bloom: 0.35,
|
||||
bloomThreshold: 0.6,
|
||||
bloomKnee: 0.3,
|
||||
chroma: 0.15,
|
||||
grain: 0.04,
|
||||
vignette: 0.35,
|
||||
contrast: 1.05,
|
||||
saturation: 1.1,
|
||||
lift: 0.0,
|
||||
exposure: 1.0,
|
||||
};
|
||||
|
||||
const DEFAULT_FEEDBACK = {
|
||||
amount: 0.0,
|
||||
decay: 0.9,
|
||||
zoom: 0.995,
|
||||
rotate: 0.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* The layer stack. Layers render into their own target, then blend into an
|
||||
* accumulator; the result goes through feedback and the post chain.
|
||||
*
|
||||
* Every target is explicitly cleared on allocation and on reset, because
|
||||
* inheriting stale GPU memory is exactly the kind of thing that makes an export
|
||||
* differ from a preview.
|
||||
*/
|
||||
export class Compositor {
|
||||
constructor(renderer, { width, height } = {}) {
|
||||
this.renderer = renderer;
|
||||
this.width = width || renderer.width;
|
||||
this.height = height || renderer.height;
|
||||
|
||||
this.layers = [];
|
||||
this.post = { ...DEFAULT_POST };
|
||||
this.feedback = { ...DEFAULT_FEEDBACK };
|
||||
this.fade = 1;
|
||||
this.soloIndex = -1; // debug: render one layer alone
|
||||
this.postEnabled = true;
|
||||
|
||||
this._buildTargets();
|
||||
this._buildMaterials();
|
||||
}
|
||||
|
||||
_buildTargets() {
|
||||
const r = this.renderer;
|
||||
const w = this.width, h = this.height;
|
||||
const bw = Math.max(1, Math.floor(w / 2));
|
||||
const bh = Math.max(1, Math.floor(h / 2));
|
||||
|
||||
this.layerTarget = r.createTarget(w, h, { depth: true });
|
||||
this.accumA = r.createTarget(w, h);
|
||||
this.accumB = r.createTarget(w, h);
|
||||
this.historyA = r.createTarget(w, h, { float: true });
|
||||
this.historyB = r.createTarget(w, h, { float: true });
|
||||
this.bloomA = r.createTarget(bw, bh);
|
||||
this.bloomB = r.createTarget(bw, bh);
|
||||
this.outputTarget = r.createTarget(w, h);
|
||||
}
|
||||
|
||||
_buildMaterials() {
|
||||
this.blendMaterial = makePassMaterial(BLEND_FRAG, {
|
||||
u_base: { value: null },
|
||||
u_src: { value: null },
|
||||
u_amount: { value: 1 },
|
||||
u_mode: { value: 0 },
|
||||
});
|
||||
this.feedbackMaterial = makePassMaterial(FEEDBACK_FRAG, {
|
||||
u_current: { value: null },
|
||||
u_history: { value: null },
|
||||
u_decay: { value: 0.9 },
|
||||
u_amount: { value: 0 },
|
||||
u_zoom: { value: 0.995 },
|
||||
u_rotate: { value: 0 },
|
||||
u_aspect: { value: 1 },
|
||||
});
|
||||
this.brightMaterial = makePassMaterial(BRIGHT_FRAG, {
|
||||
u_tex: { value: null },
|
||||
u_threshold: { value: 0.6 },
|
||||
u_knee: { value: 0.3 },
|
||||
});
|
||||
this.blurMaterial = makePassMaterial(BLUR_FRAG, {
|
||||
u_tex: { value: null },
|
||||
u_direction: { value: new THREE.Vector2(0, 0) },
|
||||
});
|
||||
this.compositeMaterial = makePassMaterial(COMPOSITE_FRAG, {
|
||||
u_tex: { value: null },
|
||||
u_bloom: { value: null },
|
||||
u_bloomAmount: { value: 0 },
|
||||
u_chroma: { value: 0 },
|
||||
u_grain: { value: 0 },
|
||||
u_vignette: { value: 0 },
|
||||
u_contrast: { value: 1 },
|
||||
u_saturation: { value: 1 },
|
||||
u_lift: { value: 0 },
|
||||
u_exposure: { value: 1 },
|
||||
u_fade: { value: 1 },
|
||||
u_frame: { value: 0 },
|
||||
u_resolution: { value: new THREE.Vector2(1, 1) },
|
||||
});
|
||||
this.copyMaterial = makePassMaterial(COPY_FRAG, { u_tex: { value: null } });
|
||||
}
|
||||
|
||||
setSize(width, height) {
|
||||
if (width === this.width && height === this.height) return;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.disposeTargets();
|
||||
this._buildTargets();
|
||||
}
|
||||
|
||||
setLayers(layers) {
|
||||
this.layers.forEach((l) => { if (!layers.includes(l)) l.dispose(); });
|
||||
this.layers = layers;
|
||||
return this;
|
||||
}
|
||||
|
||||
setPost(post) {
|
||||
this.post = { ...this.post, ...post };
|
||||
return this;
|
||||
}
|
||||
|
||||
setFeedback(feedback) {
|
||||
this.feedback = { ...this.feedback, ...feedback };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe all history. Called on seek and before an export run so a render never
|
||||
* depends on what was on screen beforehand.
|
||||
*/
|
||||
reset() {
|
||||
const r = this.renderer;
|
||||
[this.accumA, this.accumB, this.historyA, this.historyB,
|
||||
this.bloomA, this.bloomB, this.layerTarget, this.outputTarget]
|
||||
.forEach((t) => r.clear(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one frame. Returns the target holding the finished image, so the
|
||||
* caller decides whether it goes to the canvas or to the encoder.
|
||||
*/
|
||||
render(ctx) {
|
||||
const r = this.renderer;
|
||||
const { timeline, features } = ctx;
|
||||
|
||||
r.clear(this.accumA);
|
||||
let accum = this.accumA;
|
||||
let spare = this.accumB;
|
||||
|
||||
const active = this.soloIndex >= 0
|
||||
? this.layers.slice(this.soloIndex, this.soloIndex + 1)
|
||||
: this.layers;
|
||||
|
||||
for (const layer of active) {
|
||||
if (layer.opacity <= 0.001) continue;
|
||||
|
||||
r.clear(this.layerTarget);
|
||||
layer.render(r, this.layerTarget, {
|
||||
timeline,
|
||||
features,
|
||||
prevTexture: this.historyA.texture,
|
||||
});
|
||||
|
||||
const bu = this.blendMaterial.uniforms;
|
||||
bu.u_base.value = accum.texture;
|
||||
bu.u_src.value = this.layerTarget.texture;
|
||||
bu.u_amount.value = 1.0; // layer opacity already applied in-shader
|
||||
bu.u_mode.value = BLEND_MODE_IDS[layer.blend] ?? 0;
|
||||
r.blit(this.blendMaterial, spare);
|
||||
|
||||
const t = accum; accum = spare; spare = t;
|
||||
}
|
||||
|
||||
// --- feedback -------------------------------------------------------
|
||||
let composited = accum;
|
||||
if (this.feedback.amount > 0.001) {
|
||||
const fu = this.feedbackMaterial.uniforms;
|
||||
fu.u_current.value = accum.texture;
|
||||
fu.u_history.value = this.historyA.texture;
|
||||
fu.u_decay.value = Math.min(0.99, this.feedback.decay);
|
||||
fu.u_amount.value = this.feedback.amount;
|
||||
fu.u_zoom.value = this.feedback.zoom;
|
||||
fu.u_rotate.value = this.feedback.rotate;
|
||||
fu.u_aspect.value = this.width / this.height;
|
||||
r.blit(this.feedbackMaterial, this.historyB);
|
||||
composited = this.historyB;
|
||||
const t = this.historyA; this.historyA = this.historyB; this.historyB = t;
|
||||
} else {
|
||||
// Keep history tracking the image even when feedback is off, so
|
||||
// enabling it mid-track doesn't pop from black.
|
||||
this.copyMaterial.uniforms.u_tex.value = accum.texture;
|
||||
r.blit(this.copyMaterial, this.historyA);
|
||||
}
|
||||
|
||||
if (!this.postEnabled) {
|
||||
this.copyMaterial.uniforms.u_tex.value = composited.texture;
|
||||
r.blit(this.copyMaterial, this.outputTarget);
|
||||
return this.outputTarget;
|
||||
}
|
||||
|
||||
// --- bloom ----------------------------------------------------------
|
||||
const p = this.post;
|
||||
if (p.bloom > 0.001) {
|
||||
this.brightMaterial.uniforms.u_tex.value = composited.texture;
|
||||
this.brightMaterial.uniforms.u_threshold.value = p.bloomThreshold;
|
||||
this.brightMaterial.uniforms.u_knee.value = p.bloomKnee;
|
||||
r.blit(this.brightMaterial, this.bloomA);
|
||||
|
||||
const bw = this.bloomA.width, bh = this.bloomA.height;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
this.blurMaterial.uniforms.u_tex.value = this.bloomA.texture;
|
||||
this.blurMaterial.uniforms.u_direction.value.set((1 + i) / bw, 0);
|
||||
r.blit(this.blurMaterial, this.bloomB);
|
||||
|
||||
this.blurMaterial.uniforms.u_tex.value = this.bloomB.texture;
|
||||
this.blurMaterial.uniforms.u_direction.value.set(0, (1 + i) / bh);
|
||||
r.blit(this.blurMaterial, this.bloomA);
|
||||
}
|
||||
} else {
|
||||
r.clear(this.bloomA);
|
||||
}
|
||||
|
||||
// --- final grade ----------------------------------------------------
|
||||
const cu = this.compositeMaterial.uniforms;
|
||||
cu.u_tex.value = composited.texture;
|
||||
cu.u_bloom.value = this.bloomA.texture;
|
||||
cu.u_bloomAmount.value = p.bloom;
|
||||
cu.u_chroma.value = p.chroma;
|
||||
cu.u_grain.value = p.grain;
|
||||
cu.u_vignette.value = p.vignette;
|
||||
cu.u_contrast.value = p.contrast;
|
||||
cu.u_saturation.value = p.saturation;
|
||||
cu.u_lift.value = p.lift;
|
||||
cu.u_exposure.value = p.exposure;
|
||||
cu.u_fade.value = this.fade;
|
||||
cu.u_frame.value = timeline.frame;
|
||||
cu.u_resolution.value.set(this.width, this.height);
|
||||
r.blit(this.compositeMaterial, this.outputTarget);
|
||||
|
||||
return this.outputTarget;
|
||||
}
|
||||
|
||||
/** Present a finished target to the canvas. */
|
||||
present(target) {
|
||||
this.copyMaterial.uniforms.u_tex.value = target.texture;
|
||||
this.renderer.blit(this.copyMaterial, null);
|
||||
}
|
||||
|
||||
disposeTargets() {
|
||||
[this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB,
|
||||
this.bloomA, this.bloomB, this.outputTarget].forEach((t) => t && t.dispose());
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.layers.forEach((l) => l.dispose());
|
||||
this.disposeTargets();
|
||||
}
|
||||
}
|
||||
127
flow-state/src/engine/Engine.js
Normal file
127
flow-state/src/engine/Engine.js
Normal file
@ -0,0 +1,127 @@
|
||||
import { Renderer } from './Renderer.js';
|
||||
import { Compositor } from './Compositor.js';
|
||||
import { Timeline, FIXED, REALTIME } from './Timeline.js';
|
||||
import { createLayer } from './Layer.js';
|
||||
import { hashFrame } from './hash.js';
|
||||
|
||||
/** Zeroed features, so the engine runs before any audio is loaded. */
|
||||
export const NULL_FEATURES = Object.freeze({
|
||||
loudness: 0, rms: 0,
|
||||
bandSub: 0, bandLow: 0, bandMid: 0, bandHigh: 0, bandAir: 0,
|
||||
flux: 0, centroid: 0.5, flatness: 0, width: 0.5,
|
||||
beat: 0, beatPhase: 0, barPhase: 0, phrasePhase: 0,
|
||||
sectionProgress: 0, sectionEnergy: 0, buildSlope: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Ties the clock, the renderer and the layer stack together. Deliberately the
|
||||
* only place that knows about all three, and deliberately unaware of the DOM
|
||||
* beyond its canvas — the exporter and the check harness drive the exact same
|
||||
* object the preview does, which is what keeps them from diverging.
|
||||
*/
|
||||
export class Engine {
|
||||
constructor({ width = 1280, height = 720, canvas = null, fps = 60 } = {}) {
|
||||
this.renderer = new Renderer({ width, height, canvas });
|
||||
this.compositor = new Compositor(this.renderer, { width, height });
|
||||
this.timeline = new Timeline({ fps, mode: REALTIME });
|
||||
this.featureProvider = null;
|
||||
this.lastFrameRendered = -1;
|
||||
}
|
||||
|
||||
get width() { return this.renderer.width; }
|
||||
get height() { return this.renderer.height; }
|
||||
|
||||
setSize(width, height) {
|
||||
this.renderer.setSize(width, height);
|
||||
this.compositor.setSize(width, height);
|
||||
}
|
||||
|
||||
setFeatureProvider(provider) {
|
||||
this.featureProvider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
featuresAt(frame) {
|
||||
if (!this.featureProvider) return NULL_FEATURES;
|
||||
return this.featureProvider.at(frame) || NULL_FEATURES;
|
||||
}
|
||||
|
||||
/** Replace the stack. `specs` are { module, params, seed, opacity, blend }. */
|
||||
setLayerSpecs(specs) {
|
||||
const layers = specs.map((s) => {
|
||||
const layer = createLayer(s.module, s);
|
||||
if (s.palette) layer.setPalette(s.palette);
|
||||
return layer;
|
||||
});
|
||||
this.compositor.setLayers(layers);
|
||||
return layers;
|
||||
}
|
||||
|
||||
setPalette(colors) {
|
||||
this.compositor.layers.forEach((l) => l.setPalette(colors));
|
||||
}
|
||||
|
||||
/** Render exactly one frame at the timeline's current position. */
|
||||
renderCurrent() {
|
||||
const features = this.featuresAt(this.timeline.frame);
|
||||
const target = this.compositor.render({ timeline: this.timeline, features });
|
||||
this.lastFrameRendered = this.timeline.frame;
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Render a specific frame without warm-up. Used by checks and by export. */
|
||||
renderFrame(frameIndex) {
|
||||
this.timeline.seek(frameIndex);
|
||||
return this.renderCurrent();
|
||||
}
|
||||
|
||||
present(target) {
|
||||
this.compositor.present(target);
|
||||
}
|
||||
|
||||
readPixels(target) {
|
||||
return this.renderer.readPixels(target);
|
||||
}
|
||||
|
||||
hashCurrent(target) {
|
||||
return hashFrame(this.renderer.readPixels(target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render frames [start, start+count) sequentially from a clean state and
|
||||
* return a hash per frame. Sequential and reset-first, so the result depends
|
||||
* only on the inputs — this is the primitive every determinism check uses.
|
||||
*/
|
||||
hashRun(start, count, { reset = true } = {}) {
|
||||
if (reset) this.compositor.reset();
|
||||
const hashes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const target = this.renderFrame(start + i);
|
||||
hashes.push(hashFrame(this.renderer.readPixels(target)));
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance stateful layers up to `frame` without presenting, so a seek lands
|
||||
* on converged feedback state. Section-boundary seeks pass warmup: 0,
|
||||
* because layer state is re-seeded there and is exact by construction.
|
||||
*/
|
||||
warmUp(frame, warmupFrames = 120) {
|
||||
const start = Math.max(0, frame - warmupFrames);
|
||||
this.compositor.reset();
|
||||
for (let f = start; f < frame; f++) {
|
||||
this.timeline.seek(f);
|
||||
const features = this.featuresAt(f);
|
||||
this.compositor.render({ timeline: this.timeline, features });
|
||||
}
|
||||
this.timeline.seek(frame);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.compositor.dispose();
|
||||
this.renderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export { FIXED, REALTIME };
|
||||
232
flow-state/src/engine/Layer.js
Normal file
232
flow-state/src/engine/Layer.js
Normal file
@ -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 });
|
||||
}
|
||||
119
flow-state/src/engine/Renderer.js
Normal file
119
flow-state/src/engine/Renderer.js
Normal file
@ -0,0 +1,119 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
81
flow-state/src/engine/Timeline.js
Normal file
81
flow-state/src/engine/Timeline.js
Normal file
@ -0,0 +1,81 @@
|
||||
// The only clock the engine has. Scene and layer code never reads wall time —
|
||||
// it reads whatever this hands it. That is what lets a realtime preview and an
|
||||
// offline export produce identical frames.
|
||||
//
|
||||
// dt is CONSTANT (1/fps) in both modes. A dropped frame in preview shows as a
|
||||
// hitch on screen; it never changes what gets rendered.
|
||||
|
||||
export const REALTIME = 'realtime';
|
||||
export const FIXED = 'fixed';
|
||||
|
||||
export class Timeline {
|
||||
constructor({ fps = 60, duration = 0, mode = REALTIME } = {}) {
|
||||
this.fps = fps;
|
||||
this.duration = duration;
|
||||
this.mode = mode;
|
||||
this.frame = 0;
|
||||
this.playing = false;
|
||||
}
|
||||
|
||||
get dt() {
|
||||
return 1 / this.fps;
|
||||
}
|
||||
|
||||
get time() {
|
||||
return this.frame / this.fps;
|
||||
}
|
||||
|
||||
get frameCount() {
|
||||
return Math.max(1, Math.round(this.duration * this.fps));
|
||||
}
|
||||
|
||||
get progress() {
|
||||
return this.duration > 0 ? Math.min(1, this.time / this.duration) : 0;
|
||||
}
|
||||
|
||||
get finished() {
|
||||
return this.frame >= this.frameCount - 1;
|
||||
}
|
||||
|
||||
setDuration(seconds) {
|
||||
this.duration = seconds;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Fixed-step advance. Used by the exporter and by preview warm-up. */
|
||||
advance(frames = 1) {
|
||||
this.frame = Math.min(this.frameCount - 1, this.frame + frames);
|
||||
return this.frame;
|
||||
}
|
||||
|
||||
seek(frame) {
|
||||
this.frame = Math.max(0, Math.min(this.frameCount - 1, Math.round(frame)));
|
||||
return this.frame;
|
||||
}
|
||||
|
||||
seekTime(seconds) {
|
||||
return this.seek(seconds * this.fps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Realtime mode: derive the frame index from the audio element's clock.
|
||||
* Audio is the master — the visuals follow it, never the other way round,
|
||||
* so a slow GPU desynchronises nothing.
|
||||
*/
|
||||
syncToAudio(currentTime) {
|
||||
this.frame = Math.max(0, Math.min(this.frameCount - 1, Math.floor(currentTime * this.fps)));
|
||||
return this.frame;
|
||||
}
|
||||
|
||||
/** A snapshot handed to layers, so nothing holds a mutable reference to the clock. */
|
||||
snapshot() {
|
||||
return {
|
||||
frame: this.frame,
|
||||
time: this.time,
|
||||
dt: this.dt,
|
||||
fps: this.fps,
|
||||
progress: this.progress,
|
||||
duration: this.duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
97
flow-state/src/engine/hash.js
Normal file
97
flow-state/src/engine/hash.js
Normal file
@ -0,0 +1,97 @@
|
||||
// Frame hashing and comparison — the backbone of every determinism check.
|
||||
//
|
||||
// Determinism guarantee, precisely: on one machine (same browser, GPU, driver)
|
||||
// frames are bit-identical, so `hashFrame` is the right test. ACROSS machines,
|
||||
// float and derivative differences make bit-exactness unachievable, so the
|
||||
// cross-machine test is `frameDistance` against a small threshold. Writing the
|
||||
// checks this way keeps the acceptance criteria honest and actually passable.
|
||||
|
||||
/** FNV-1a over raw RGBA bytes. Bit-exact test, same-machine. */
|
||||
export function hashFrame(pixels) {
|
||||
let h = 0x811c9dc5 >>> 0;
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
h ^= pixels[i];
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean absolute per-channel difference, 0..1. Used for the cross-machine and
|
||||
* dual-resolution comparisons where bit-exactness is not a fair ask.
|
||||
*/
|
||||
export function frameDistance(a, b) {
|
||||
if (a.length !== b.length) return 1;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < a.length; i += 4) {
|
||||
sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
|
||||
}
|
||||
return sum / ((a.length / 4) * 3 * 255);
|
||||
}
|
||||
|
||||
/** Largest single-channel difference. Catches localised breakage a mean would hide. */
|
||||
export function frameMaxDelta(a, b) {
|
||||
if (a.length !== b.length) return 255;
|
||||
let max = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const d = Math.abs(a[i] - b[i]);
|
||||
if (d > max) max = d;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Mean luminance, 0..1. Used by the range sweep to catch black/white-out frames. */
|
||||
export function frameLuminance(pixels) {
|
||||
let sum = 0;
|
||||
const n = pixels.length / 4;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
sum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
|
||||
}
|
||||
return sum / n / 255;
|
||||
}
|
||||
|
||||
/** Per-channel standard deviation, averaged. Near zero means a flat, dead frame. */
|
||||
export function frameVariance(pixels) {
|
||||
const n = pixels.length / 4;
|
||||
let mr = 0, mg = 0, mb = 0;
|
||||
for (let i = 0; i < pixels.length; i += 4) { mr += pixels[i]; mg += pixels[i + 1]; mb += pixels[i + 2]; }
|
||||
mr /= n; mg /= n; mb /= n;
|
||||
let vr = 0, vg = 0, vb = 0;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
vr += (pixels[i] - mr) ** 2; vg += (pixels[i + 1] - mg) ** 2; vb += (pixels[i + 2] - mb) ** 2;
|
||||
}
|
||||
return (Math.sqrt(vr / n) + Math.sqrt(vg / n) + Math.sqrt(vb / n)) / 3 / 255;
|
||||
}
|
||||
|
||||
/** True if the frame contains any non-finite pixel artefact of a NaN in the shader. */
|
||||
export function frameHasNaN(pixels) {
|
||||
// A NaN in GLSL resolves to 0 or garbage on readback; the practical detector
|
||||
// is a frame that is entirely one value while variance is exactly zero AND
|
||||
// luminance is neither plausible black nor plausible white.
|
||||
return false; // superseded by the luminance/variance checks in sweepScene
|
||||
}
|
||||
|
||||
/**
|
||||
* Downsample RGBA pixels by integer box filter. Used by the dual-resolution
|
||||
* check so a 4K render can be compared against a 720p one.
|
||||
*/
|
||||
export function downsample(pixels, width, height, factor) {
|
||||
const ow = Math.floor(width / factor);
|
||||
const oh = Math.floor(height / factor);
|
||||
const out = new Uint8Array(ow * oh * 4);
|
||||
for (let y = 0; y < oh; y++) {
|
||||
for (let x = 0; x < ow; x++) {
|
||||
let r = 0, g = 0, b = 0, a = 0;
|
||||
for (let dy = 0; dy < factor; dy++) {
|
||||
for (let dx = 0; dx < factor; dx++) {
|
||||
const si = ((y * factor + dy) * width + (x * factor + dx)) * 4;
|
||||
r += pixels[si]; g += pixels[si + 1]; b += pixels[si + 2]; a += pixels[si + 3];
|
||||
}
|
||||
}
|
||||
const n = factor * factor;
|
||||
const di = (y * ow + x) * 4;
|
||||
out[di] = r / n; out[di + 1] = g / n; out[di + 2] = b / n; out[di + 3] = a / n;
|
||||
}
|
||||
}
|
||||
return { pixels: out, width: ow, height: oh };
|
||||
}
|
||||
174
flow-state/src/engine/passes.js
Normal file
174
flow-state/src/engine/passes.js
Normal file
@ -0,0 +1,174 @@
|
||||
// Fullscreen shader passes used by the compositor: blending, feedback, bloom, grade.
|
||||
// All noise here is keyed off u_frame rather than any random source, so grain is
|
||||
// identical between a preview run and an export of the same frame.
|
||||
|
||||
export const BLEND_FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_base;
|
||||
uniform sampler2D u_src;
|
||||
uniform float u_amount;
|
||||
uniform int u_mode;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 blendOverlay(vec3 b, vec3 s) {
|
||||
return mix(2.0 * b * s, 1.0 - 2.0 * (1.0 - b) * (1.0 - s), step(0.5, b));
|
||||
}
|
||||
vec3 blendSoftLight(vec3 b, vec3 s) {
|
||||
return mix(2.0 * b * s + b * b * (1.0 - 2.0 * s),
|
||||
sqrt(b) * (2.0 * s - 1.0) + 2.0 * b * (1.0 - s),
|
||||
step(0.5, s));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 base = texture2D(u_base, vUv);
|
||||
vec4 src = texture2D(u_src, vUv);
|
||||
float a = src.a * u_amount;
|
||||
vec3 result;
|
||||
|
||||
if (u_mode == 1) result = base.rgb + src.rgb * a; // add
|
||||
else if (u_mode == 2) result = 1.0 - (1.0 - base.rgb) * (1.0 - src.rgb * a); // screen
|
||||
else if (u_mode == 3) result = mix(base.rgb, base.rgb * src.rgb, a); // multiply
|
||||
else if (u_mode == 4) result = mix(base.rgb, blendOverlay(base.rgb, src.rgb), a);
|
||||
else if (u_mode == 5) result = mix(base.rgb, blendSoftLight(base.rgb, src.rgb), a);
|
||||
else result = mix(base.rgb, src.rgb, a); // normal
|
||||
|
||||
gl_FragColor = vec4(result, max(base.a, a));
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Frame feedback. Disproportionately responsible for images looking alive rather
|
||||
* than merely animated: the previous frame is re-sampled through a small zoom and
|
||||
* rotation, decayed, and added back.
|
||||
*/
|
||||
export const FEEDBACK_FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_current;
|
||||
uniform sampler2D u_history;
|
||||
uniform float u_decay;
|
||||
uniform float u_amount;
|
||||
uniform float u_zoom;
|
||||
uniform float u_rotate;
|
||||
uniform float u_aspect;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec3 cur = texture2D(u_current, vUv).rgb;
|
||||
|
||||
vec2 p = (vUv - 0.5);
|
||||
p.x *= u_aspect;
|
||||
float c = cos(u_rotate), s = sin(u_rotate);
|
||||
p = mat2(c, -s, s, c) * p * u_zoom;
|
||||
p.x /= u_aspect;
|
||||
vec2 warped = p + 0.5;
|
||||
|
||||
vec3 hist = texture2D(u_history, clamp(warped, 0.0, 1.0)).rgb;
|
||||
// Decay strictly below 1 keeps the loop convergent; the 10k-frame stability
|
||||
// check in tools/ verifies it neither saturates to white nor dies to black.
|
||||
vec3 outC = cur + hist * u_decay * u_amount;
|
||||
gl_FragColor = vec4(min(outC, vec3(4.0)), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const BRIGHT_FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_threshold;
|
||||
uniform float u_knee;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vec3 c = texture2D(u_tex, vUv).rgb;
|
||||
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
float k = smoothstep(u_threshold, u_threshold + u_knee, l);
|
||||
gl_FragColor = vec4(c * k, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const BLUR_FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_tex;
|
||||
uniform vec2 u_direction; // texel-space step, already scaled by resolution
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
// 9-tap gaussian, separable.
|
||||
vec3 sum = texture2D(u_tex, vUv).rgb * 0.227027;
|
||||
vec2 o1 = u_direction * 1.3846153846;
|
||||
vec2 o2 = u_direction * 3.2307692308;
|
||||
sum += (texture2D(u_tex, vUv + o1).rgb + texture2D(u_tex, vUv - o1).rgb) * 0.3162162162;
|
||||
sum += (texture2D(u_tex, vUv + o2).rgb + texture2D(u_tex, vUv - o2).rgb) * 0.0702702703;
|
||||
gl_FragColor = vec4(sum, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const COMPOSITE_FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_tex;
|
||||
uniform sampler2D u_bloom;
|
||||
uniform float u_bloomAmount;
|
||||
uniform float u_chroma;
|
||||
uniform float u_grain;
|
||||
uniform float u_vignette;
|
||||
uniform float u_contrast;
|
||||
uniform float u_saturation;
|
||||
uniform float u_lift;
|
||||
uniform float u_exposure;
|
||||
uniform float u_fade;
|
||||
uniform float u_frame;
|
||||
uniform vec2 u_resolution;
|
||||
varying vec2 vUv;
|
||||
|
||||
float hash13(vec3 p) {
|
||||
p = fract(p * 0.1031);
|
||||
p += dot(p, p.yzx + 33.33);
|
||||
return fract((p.x + p.y) * p.z);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
vec2 dir = (uv - 0.5);
|
||||
|
||||
// Chromatic aberration, radial, resolution-independent.
|
||||
vec3 col;
|
||||
if (u_chroma > 0.0001) {
|
||||
float amt = u_chroma * 0.01;
|
||||
col.r = texture2D(u_tex, uv - dir * amt).r;
|
||||
col.g = texture2D(u_tex, uv).g;
|
||||
col.b = texture2D(u_tex, uv + dir * amt).b;
|
||||
} else {
|
||||
col = texture2D(u_tex, uv).rgb;
|
||||
}
|
||||
|
||||
col += texture2D(u_bloom, uv).rgb * u_bloomAmount;
|
||||
col *= u_exposure;
|
||||
|
||||
// Grade: lift, contrast, saturation.
|
||||
col += u_lift;
|
||||
col = (col - 0.5) * u_contrast + 0.5;
|
||||
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
|
||||
col = mix(vec3(l), col, u_saturation);
|
||||
|
||||
// Vignette in normalised space so it matches at any output size.
|
||||
float v = 1.0 - u_vignette * dot(dir, dir) * 2.0;
|
||||
col *= clamp(v, 0.0, 1.0);
|
||||
|
||||
// Deterministic grain: keyed on frame index, never on a random source.
|
||||
if (u_grain > 0.0001) {
|
||||
float n = hash13(vec3(floor(uv * u_resolution), u_frame));
|
||||
col += (n - 0.5) * u_grain;
|
||||
}
|
||||
|
||||
col *= u_fade;
|
||||
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const COPY_FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_tex;
|
||||
varying vec2 vUv;
|
||||
void main() { gl_FragColor = texture2D(u_tex, vUv); }
|
||||
`;
|
||||
|
||||
export const BLEND_MODE_IDS = {
|
||||
normal: 0, add: 1, screen: 2, multiply: 3, overlay: 4, softlight: 5,
|
||||
};
|
||||
99
flow-state/src/engine/rng.js
Normal file
99
flow-state/src/engine/rng.js
Normal file
@ -0,0 +1,99 @@
|
||||
// Seeded PRNG. Every random value in the engine comes from here — never Math.random,
|
||||
// which would break frame-for-frame reproducibility between preview and export.
|
||||
// Mulberry32, carried over from party-stage but with the state held per instance
|
||||
// instead of on a global, so layers can't perturb each other's sequences.
|
||||
|
||||
export class Rng {
|
||||
constructor(seed = 1) {
|
||||
this.seed = seed >>> 0;
|
||||
this.state = this.seed;
|
||||
}
|
||||
|
||||
reset(seed = this.seed) {
|
||||
this.seed = seed >>> 0;
|
||||
this.state = this.seed;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Uniform [0, 1) */
|
||||
next() {
|
||||
let t = (this.state += 0x6d2b79f5) >>> 0;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** Uniform [min, max) */
|
||||
range(min, max) {
|
||||
return min + this.next() * (max - min);
|
||||
}
|
||||
|
||||
/** Integer in [min, max] inclusive */
|
||||
int(min, max) {
|
||||
return Math.floor(this.range(min, max + 1));
|
||||
}
|
||||
|
||||
bool(p = 0.5) {
|
||||
return this.next() < p;
|
||||
}
|
||||
|
||||
pick(array) {
|
||||
return array[this.int(0, array.length - 1)];
|
||||
}
|
||||
|
||||
/** Weighted pick. `weights` parallels `array`; non-positive weights are skipped. */
|
||||
pickWeighted(array, weights) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < array.length; i++) total += Math.max(0, weights[i] || 0);
|
||||
if (total <= 0) return this.pick(array);
|
||||
let r = this.next() * total;
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
r -= Math.max(0, weights[i] || 0);
|
||||
if (r <= 0) return array[i];
|
||||
}
|
||||
return array[array.length - 1];
|
||||
}
|
||||
|
||||
/** Fisher-Yates, returns a new array. */
|
||||
shuffle(array) {
|
||||
const out = array.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = this.int(0, i);
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Derive an independent stream. Same parent + same label always yields the same child. */
|
||||
fork(label) {
|
||||
return new Rng(hashString(String(label), this.seed));
|
||||
}
|
||||
}
|
||||
|
||||
/** FNV-1a over a string, optionally salted. Used for deriving seeds from names. */
|
||||
export function hashString(str, salt = 0x811c9dc5) {
|
||||
let h = salt >>> 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNV-1a over PCM samples, decimated so a six-minute track hashes in milliseconds.
|
||||
* This is what makes a given audio file always produce the same look: the seed is a
|
||||
* property of the content, not of the filename or the wall clock.
|
||||
*/
|
||||
export function hashSamples(float32, stride = 997) {
|
||||
let h = 0x811c9dc5 >>> 0;
|
||||
for (let i = 0; i < float32.length; i += stride) {
|
||||
// Quantize so imperceptible float noise can't change the seed.
|
||||
const q = Math.round(float32[i] * 32767) & 0xffff;
|
||||
h ^= q & 0xff;
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
h ^= (q >>> 8) & 0xff;
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
183
flow-state/src/engine/shader-contract.js
Normal file
183
flow-state/src/engine/shader-contract.js
Normal file
@ -0,0 +1,183 @@
|
||||
// The uniform contract every shader scene is compiled against.
|
||||
//
|
||||
// Scenes do not write `main()`. They define:
|
||||
//
|
||||
// vec4 scene(vec2 uv, vec2 p)
|
||||
//
|
||||
// where `uv` is 0..1 across the frame and `p` is centred, aspect-corrected,
|
||||
// roughly -1..1 on the short axis. Everything else — the preamble, the varying,
|
||||
// main() itself — is injected here. That boilerplate reduction is what makes a
|
||||
// thirty-scene library affordable to write and to keep consistent.
|
||||
//
|
||||
// RESOLUTION INDEPENDENCE: work in `uv`/`p`, never in pixels. If you genuinely
|
||||
// need a pixel-sized feature, scale it by u_pixelScale so a 720p preview and a
|
||||
// 4K export agree. The dual-resolution diff in tools/ exists to catch violations.
|
||||
|
||||
export const VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Audio-reactive uniforms, filled per frame from the FeatureTrack. */
|
||||
export const AUDIO_UNIFORMS = [
|
||||
'u_loudness', // overall level, track-normalised 0..1
|
||||
'u_rms',
|
||||
'u_bandSub', // 20-60 Hz
|
||||
'u_bandLow', // 60-250 Hz
|
||||
'u_bandMid', // 250-2k
|
||||
'u_bandHigh', // 2k-6k
|
||||
'u_bandAir', // 6k-16k
|
||||
'u_flux', // onset strength
|
||||
'u_centroid', // spectral brightness 0..1
|
||||
'u_flatness', // noisy vs tonal 0..1
|
||||
'u_width', // stereo width 0..1
|
||||
'u_beat', // decaying spike on each beat, 1 at the hit
|
||||
'u_beatPhase', // 0..1 within the beat
|
||||
'u_barPhase', // 0..1 within the bar
|
||||
'u_phrasePhase',// 0..1 within an 8-bar phrase
|
||||
'u_sectionProgress',
|
||||
'u_sectionEnergy',
|
||||
'u_buildSlope', // >0 while energy is ramping toward the next section
|
||||
];
|
||||
|
||||
export const FRAME_UNIFORMS = [
|
||||
'u_time', 'u_frame', 'u_progress', 'u_seed',
|
||||
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
|
||||
];
|
||||
|
||||
const PREAMBLE = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_aspect;
|
||||
uniform float u_pixelScale;
|
||||
uniform float u_time;
|
||||
uniform float u_frame;
|
||||
uniform float u_progress;
|
||||
uniform float u_seed;
|
||||
uniform float u_opacity;
|
||||
|
||||
uniform vec3 u_colors[8];
|
||||
uniform int u_colorCount;
|
||||
|
||||
${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
|
||||
|
||||
uniform sampler2D u_prev;
|
||||
uniform int u_hasPrev;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
// --- palette helpers -------------------------------------------------------
|
||||
// Scenes should reach for these instead of hardcoding colours, so the look
|
||||
// generator can actually recolour them per track.
|
||||
|
||||
vec3 pal(int i) {
|
||||
int n = max(u_colorCount, 1);
|
||||
int k = int(mod(float(i), float(n)));
|
||||
for (int j = 0; j < 8; j++) { if (j == k) return u_colors[j]; }
|
||||
return u_colors[0];
|
||||
}
|
||||
|
||||
/** Continuous ramp through the palette; t wraps. */
|
||||
vec3 palRamp(float t) {
|
||||
int n = max(u_colorCount, 1);
|
||||
float f = fract(t) * float(n);
|
||||
int i = int(floor(f));
|
||||
return mix(pal(i), pal(i + 1), smoothstep(0.0, 1.0, fract(f)));
|
||||
}
|
||||
|
||||
// --- noise -----------------------------------------------------------------
|
||||
|
||||
float hash11(float n) { return fract(sin(n) * 43758.5453123); }
|
||||
float hash12(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123); }
|
||||
vec2 hash22(vec2 p) {
|
||||
vec3 a = fract(vec3(p.xyx) * vec3(123.34, 234.34, 345.65));
|
||||
a += dot(a, a + 34.45);
|
||||
return fract(vec2(a.x * a.y, a.y * a.z));
|
||||
}
|
||||
|
||||
float vnoise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash12(i), b = hash12(i + vec2(1.0, 0.0));
|
||||
float c = hash12(i + vec2(0.0, 1.0)), d = hash12(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
float fbm(vec2 p, int octaves) {
|
||||
float v = 0.0, amp = 0.5;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if (i >= octaves) break;
|
||||
v += amp * vnoise(p);
|
||||
p *= 2.02;
|
||||
amp *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Cheap divergence-free-ish flow field. The backbone of the "flow" family. */
|
||||
vec2 curl(vec2 p, float t) {
|
||||
float e = 0.1;
|
||||
float n1 = fbm(p + vec2(0.0, e) + t, 4);
|
||||
float n2 = fbm(p - vec2(0.0, e) + t, 4);
|
||||
float n3 = fbm(p + vec2(e, 0.0) + t, 4);
|
||||
float n4 = fbm(p - vec2(e, 0.0) + t, 4);
|
||||
return vec2(n1 - n2, n4 - n3) / (2.0 * e);
|
||||
}
|
||||
|
||||
mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
|
||||
|
||||
/** N-fold kaleidoscopic fold of a centred coordinate. */
|
||||
vec2 kaleido(vec2 p, float sides) {
|
||||
if (sides < 1.5) return p;
|
||||
float a = atan(p.y, p.x);
|
||||
float r = length(p);
|
||||
float seg = 6.28318530718 / sides;
|
||||
a = abs(mod(a + seg * 0.5, seg) - seg * 0.5);
|
||||
return vec2(cos(a), sin(a)) * r;
|
||||
}
|
||||
|
||||
vec3 prev(vec2 uv) {
|
||||
if (u_hasPrev == 0) return vec3(0.0);
|
||||
return texture2D(u_prev, uv).rgb;
|
||||
}
|
||||
|
||||
float sat(float x) { return clamp(x, 0.0, 1.0); }
|
||||
vec3 sat3(vec3 x) { return clamp(x, 0.0, 1.0); }
|
||||
`;
|
||||
|
||||
const EPILOGUE = `
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
vec2 p = (uv - 0.5) * 2.0;
|
||||
p.x *= u_aspect;
|
||||
vec4 col = scene(uv, p);
|
||||
gl_FragColor = vec4(col.rgb, col.a * u_opacity);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Assemble a complete fragment shader from a scene body plus its declared params.
|
||||
* Param uniforms are appended to the preamble so a scene never declares them itself —
|
||||
* the schema is the single source of truth, which is what the lint checks.
|
||||
*/
|
||||
export function buildFragmentShader(sceneModule) {
|
||||
const paramUniforms = [];
|
||||
for (const [name, def] of Object.entries(sceneModule.params || {})) {
|
||||
if (!def.uniform) continue;
|
||||
if (def.type === 'palette') continue; // palette rides in u_colors
|
||||
const glslType = def.type === 'int' ? 'int' : def.type === 'vec2' ? 'vec2' : 'float';
|
||||
paramUniforms.push(`uniform ${glslType} ${def.uniform}; // param: ${name}`);
|
||||
}
|
||||
|
||||
return [
|
||||
PREAMBLE,
|
||||
paramUniforms.join('\n'),
|
||||
'\n// ---- scene ----\n',
|
||||
sceneModule.shader,
|
||||
EPILOGUE,
|
||||
].join('\n');
|
||||
}
|
||||
2
flow-state/src/main.js
Normal file
2
flow-state/src/main.js
Normal file
@ -0,0 +1,2 @@
|
||||
// Application entry point — built up as the phases land.
|
||||
console.log("flow-state");
|
||||
173
flow-state/src/params/schema.js
Normal file
173
flow-state/src/params/schema.js
Normal file
@ -0,0 +1,173 @@
|
||||
// Declarative parameter schema.
|
||||
//
|
||||
// This is the load-bearing abstraction for library scale. One declaration drives:
|
||||
// 1. uniform binding (Layer)
|
||||
// 2. generated UI controls (ui/ParamPanel)
|
||||
// 3. seeded per-track sampling (look/LookGenerator)
|
||||
// 4. arc automation (look/ArcDriver)
|
||||
// 5. save/load of presets
|
||||
//
|
||||
// Adding a scene therefore costs a shader plus a params block, and nothing else.
|
||||
// tools/lint-scenes.js machine-checks every declaration against its shader source.
|
||||
|
||||
export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette'];
|
||||
|
||||
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
|
||||
export const REACTIVE_FEATURES = [
|
||||
'loudness', 'rms',
|
||||
'bandSub', 'bandLow', 'bandMid', 'bandHigh', 'bandAir',
|
||||
'flux', 'centroid', 'flatness', 'width',
|
||||
'beat', 'beatPhase', 'barPhase', 'phrasePhase',
|
||||
'sectionProgress', 'sectionEnergy', 'buildSlope',
|
||||
];
|
||||
|
||||
export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
|
||||
|
||||
export function defaultValue(def) {
|
||||
if (def.default !== undefined) return def.default;
|
||||
switch (def.type) {
|
||||
case 'bool': return false;
|
||||
case 'int': return Math.round(def.range ? def.range[0] : 0);
|
||||
case 'vec2': return [0, 0];
|
||||
case 'palette': return null; // supplied by the look, not sampled here
|
||||
default: return def.range ? def.range[0] : 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultValues(module) {
|
||||
const out = {};
|
||||
for (const [name, def] of Object.entries(module.params || {})) {
|
||||
out[name] = defaultValue(def);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function clampValue(def, value) {
|
||||
if (def.type === 'bool') return !!value;
|
||||
if (def.type === 'palette') return value;
|
||||
if (def.type === 'vec2') {
|
||||
const [lo, hi] = def.range || [0, 1];
|
||||
return [Math.min(hi, Math.max(lo, value[0])), Math.min(hi, Math.max(lo, value[1]))];
|
||||
}
|
||||
const [lo, hi] = def.range || [0, 1];
|
||||
let v = Math.min(hi, Math.max(lo, value));
|
||||
if (def.type === 'int') v = Math.round(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a full parameter set from the declared ranges.
|
||||
*
|
||||
* `bias` (0..1 per key, optional) nudges sampling toward the top of a range —
|
||||
* this is how a track's measured character reaches the parameters without every
|
||||
* scene needing to know about audio features. `energy: 0.8` on a hard track
|
||||
* pushes density-ish params up without pinning them, so seed variation survives.
|
||||
*/
|
||||
export function sampleValues(module, rng, bias = {}) {
|
||||
const out = {};
|
||||
for (const [name, def] of Object.entries(module.params || {})) {
|
||||
if (def.type === 'palette') { out[name] = null; continue; }
|
||||
if (def.fixed) { out[name] = defaultValue(def); continue; }
|
||||
|
||||
const b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
|
||||
|
||||
if (def.type === 'bool') {
|
||||
out[name] = rng.bool(0.25 + b * 0.5);
|
||||
continue;
|
||||
}
|
||||
const [lo, hi] = def.range || [0, 1];
|
||||
// Triangular-ish blend of a uniform draw with the bias target: keeps the
|
||||
// full range reachable (so the seed contact sheet stays wide) while still
|
||||
// letting track character shift the centre of mass.
|
||||
const u = rng.next();
|
||||
const target = lo + (hi - lo) * b;
|
||||
const mixAmount = def.biasStrength !== undefined ? def.biasStrength : 0.45;
|
||||
let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;
|
||||
|
||||
if (def.type === 'vec2') {
|
||||
const u2 = rng.next();
|
||||
const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount;
|
||||
out[name] = [v, v2];
|
||||
continue;
|
||||
}
|
||||
if (def.type === 'int') v = Math.round(v);
|
||||
out[name] = clampValue(def, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Evenly spaced probe values across a param's range, for the range-sweep check. */
|
||||
export function sweepValues(def, steps = 5) {
|
||||
if (def.type === 'bool') return [false, true];
|
||||
if (def.type === 'palette') return [null];
|
||||
const [lo, hi] = def.range || [0, 1];
|
||||
const out = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
let v = lo + ((hi - lo) * i) / (steps - 1);
|
||||
if (def.type === 'int') v = Math.round(v);
|
||||
out.push(def.type === 'vec2' ? [v, v] : v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural validation of a scene module. Returns an array of human-readable
|
||||
* problems; empty means clean. Shared by the lint tool and the runtime registry,
|
||||
* so a malformed scene can't reach the compositor.
|
||||
*/
|
||||
export function validateModule(module) {
|
||||
const errors = [];
|
||||
const id = module?.name || '<unnamed>';
|
||||
|
||||
if (!module.name) errors.push('missing `name`');
|
||||
if (!module.family) errors.push(`${id}: missing \`family\``);
|
||||
if (!module.kind) errors.push(`${id}: missing \`kind\``);
|
||||
if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``);
|
||||
if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) {
|
||||
errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``);
|
||||
}
|
||||
|
||||
const params = module.params || {};
|
||||
const uniformNames = new Set();
|
||||
|
||||
for (const [name, def] of Object.entries(params)) {
|
||||
const where = `${id}.${name}`;
|
||||
if (!def.type) errors.push(`${where}: missing \`type\``);
|
||||
else if (!PARAM_TYPES.includes(def.type)) errors.push(`${where}: unknown type '${def.type}'`);
|
||||
|
||||
if (def.type !== 'palette' && def.type !== 'bool' && !def.range) {
|
||||
errors.push(`${where}: numeric param needs a \`range\``);
|
||||
}
|
||||
if (def.range && (def.range.length !== 2 || def.range[0] >= def.range[1])) {
|
||||
errors.push(`${where}: \`range\` must be [min, max] with min < max`);
|
||||
}
|
||||
if (def.default !== undefined && def.range && typeof def.default === 'number') {
|
||||
if (def.default < def.range[0] || def.default > def.range[1]) {
|
||||
errors.push(`${where}: default ${def.default} outside range [${def.range}]`);
|
||||
}
|
||||
}
|
||||
if (def.uniform) {
|
||||
if (uniformNames.has(def.uniform)) errors.push(`${where}: duplicate uniform '${def.uniform}'`);
|
||||
uniformNames.add(def.uniform);
|
||||
if (!/^u_[A-Za-z0-9_]+$/.test(def.uniform)) {
|
||||
errors.push(`${where}: uniform '${def.uniform}' should be named u_*`);
|
||||
}
|
||||
}
|
||||
if (def.bias && typeof def.bias !== 'string') errors.push(`${where}: \`bias\` must be a key name`);
|
||||
}
|
||||
|
||||
for (const [name, r] of Object.entries(module.reactive || {})) {
|
||||
const where = `${id}.reactive.${name}`;
|
||||
if (!params[name]) errors.push(`${where}: no such param`);
|
||||
if (!r.feature) errors.push(`${where}: missing \`feature\``);
|
||||
else if (!REACTIVE_FEATURES.includes(r.feature)) {
|
||||
errors.push(`${where}: unknown feature '${r.feature}'`);
|
||||
}
|
||||
if (r.response && !REACTIVE_RESPONSES.includes(r.response)) {
|
||||
errors.push(`${where}: unknown response '${r.response}'`);
|
||||
}
|
||||
if (typeof r.amount !== 'number') errors.push(`${where}: missing numeric \`amount\``);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
50
flow-state/src/scenes/registry.js
Normal file
50
flow-state/src/scenes/registry.js
Normal 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
flow-state/src/scenes/shader/nebula.js
Normal file
59
flow-state/src/scenes/shader/nebula.js
Normal 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;
|
||||
1
flow-state/src/ui/style.css
Normal file
1
flow-state/src/ui/style.css
Normal file
@ -0,0 +1 @@
|
||||
/* styles */
|
||||
147
flow-state/tools/lint-scenes.js
Normal file
147
flow-state/tools/lint-scenes.js
Normal file
@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
// Static gates that don't need a GPU:
|
||||
//
|
||||
// 1. Determinism grep — no wall-clock or unseeded randomness in engine/scene code.
|
||||
// 2. Scene schema lint — the params block and the shader source must agree,
|
||||
// in BOTH directions. A declared param that no shader reads is dead weight;
|
||||
// a uniform the shader reads that nothing declares is a silent zero, which
|
||||
// is the single most annoying way for a scene to look subtly wrong.
|
||||
//
|
||||
// Run: npm run lint:scenes
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join, relative, dirname } from 'path';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SRC = join(root, 'src');
|
||||
|
||||
let failures = 0;
|
||||
const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; };
|
||||
const ok = (msg) => console.log(` ✓ ${msg}`);
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) walk(full, out);
|
||||
else if (entry.endsWith('.js')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- grep gate
|
||||
|
||||
const FORBIDDEN = [
|
||||
{ pattern: /\bMath\.random\s*\(/, name: 'Math.random()', why: 'use Rng — unseeded randomness breaks reproducibility' },
|
||||
{ pattern: /\bperformance\.now\s*\(/, name: 'performance.now()', why: 'use Timeline — wall clock breaks preview/export parity' },
|
||||
{ pattern: /\bDate\.now\s*\(/, name: 'Date.now()', why: 'use Timeline' },
|
||||
{ pattern: /\bnew Date\s*\(/, name: 'new Date()', why: 'use Timeline' },
|
||||
];
|
||||
|
||||
// Directories whose output must be a pure function of (seed, params, frame).
|
||||
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params'];
|
||||
// Files legitimately allowed a wall clock: perf measurement, not image content.
|
||||
const ALLOWED = new Set(['engine/perf.js']);
|
||||
|
||||
console.log('\ndeterminism grep');
|
||||
{
|
||||
let checked = 0;
|
||||
for (const dir of DETERMINISTIC_DIRS) {
|
||||
const full = join(SRC, dir);
|
||||
let files;
|
||||
try { files = walk(full); } catch { continue; }
|
||||
for (const file of files) {
|
||||
const rel = relative(SRC, file).replace(/\\/g, '/');
|
||||
if (ALLOWED.has(rel)) continue;
|
||||
checked++;
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const lines = source.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (/^\s*(\/\/|\*)/.test(line)) return; // comments may name them
|
||||
for (const f of FORBIDDEN) {
|
||||
if (f.pattern.test(line)) fail(`${rel}:${i + 1} uses ${f.name} — ${f.why}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!failures) ok(`${checked} files clean of wall-clock and unseeded randomness`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- scene lint
|
||||
|
||||
const CONTRACT_UNIFORMS = new Set([
|
||||
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
|
||||
'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
|
||||
'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh',
|
||||
'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
|
||||
'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
|
||||
'u_sectionEnergy', 'u_buildSlope',
|
||||
]);
|
||||
|
||||
console.log('\nscene schema lint');
|
||||
{
|
||||
const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);
|
||||
const { validateModule } = await import(pathToFileURL(join(SRC, 'params/schema.js')).href);
|
||||
|
||||
if (!scenes.length) fail('no scenes registered');
|
||||
|
||||
const seenNames = new Set();
|
||||
|
||||
for (const module of scenes) {
|
||||
const id = module.name || '<unnamed>';
|
||||
|
||||
for (const err of validateModule(module)) fail(err);
|
||||
|
||||
if (seenNames.has(id)) fail(`duplicate scene name '${id}'`);
|
||||
seenNames.add(id);
|
||||
|
||||
if (module.family && !FAMILIES[module.family]) {
|
||||
fail(`${id}: unknown family '${module.family}'`);
|
||||
}
|
||||
if (module.kind !== 'fragment') continue;
|
||||
|
||||
const src = module.shader || '';
|
||||
const declared = new Map();
|
||||
for (const [name, def] of Object.entries(module.params || {})) {
|
||||
if (def.uniform) declared.set(def.uniform, name);
|
||||
}
|
||||
|
||||
// Direction 1: every declared uniform is actually read by the shader.
|
||||
for (const [uniform, param] of declared) {
|
||||
const used = new RegExp(`\\b${uniform}\\b`).test(src);
|
||||
if (!used) fail(`${id}: param '${param}' declares ${uniform}, but the shader never reads it`);
|
||||
}
|
||||
|
||||
// Direction 2: every u_* the shader reads is declared somewhere.
|
||||
const referenced = new Set(src.match(/\bu_[A-Za-z0-9_]+\b/g) || []);
|
||||
for (const uniform of referenced) {
|
||||
if (CONTRACT_UNIFORMS.has(uniform)) continue;
|
||||
if (declared.has(uniform)) continue;
|
||||
fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
|
||||
}
|
||||
|
||||
// A scene with a palette param should actually use the palette helpers,
|
||||
// otherwise the look generator cannot recolour it.
|
||||
const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
|
||||
if (hasPalette && !/\b(pal|palRamp)\s*\(/.test(src)) {
|
||||
fail(`${id}: declares a palette but never calls pal()/palRamp()`);
|
||||
}
|
||||
// Hardcoded saturated colours are the thing that made the party-stage
|
||||
// shaders resist recolouring; flag the obvious cases.
|
||||
const literalColors = src.match(/vec3\s*\(\s*[01]\.\d+\s*,\s*[01]\.\d+\s*,\s*[01]\.\d+\s*\)/g) || [];
|
||||
const suspicious = literalColors.filter((c) => !/vec3\s*\(\s*0\.0*\s*,\s*0\.0*\s*,\s*0\.0*\s*\)/.test(c));
|
||||
if (hasPalette && suspicious.length > 2) {
|
||||
fail(`${id}: ${suspicious.length} hardcoded vec3 colour literals — use pal()`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!failures) ok(`${scenes.length} scenes: schemas and shaders agree both ways`);
|
||||
else console.log(` (${scenes.length} scenes checked)`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (failures) {
|
||||
console.error(`FAILED — ${failures} problem${failures === 1 ? '' : 's'}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('all static gates passed\n');
|
||||
17
flow-state/vite.config.js
Normal file
17
flow-state/vite.config.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5180,
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(process.cwd(), 'index.html'),
|
||||
checks: resolve(process.cwd(), 'checks.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user