Phase 7: grow the scene library to 16

Ten new scenes, with 'minimal' first: the family was empty, so intros and
breakdowns fell through to flow/organic and every track opened at full
density. Quiet sections now land on a restful family 48/48 times across 24
seeds, 28 of them minimal.

New: Horizon Lines, Spectrum Sculpture, Slow Orb (minimal); Curl Flow
(flow); Plasma Bloom, Metaballs (organic); Kaleido Tunnel, Moiré Grid
(geometric); Ridge Terrain (structural); Scan Tear (glitch).

Four real bugs, three of which the existing gates could not have caught:

1. SHADER PROGRAMS LINK ASYNCHRONOUSLY. three.js uses
   KHR_parallel_shader_compile, so draws against an unlinked program render
   wrong. The heaviest scene had its first TEN frames differ from every
   later render of the same frames. Preview hides this entirely; export
   renders each frame once, so those frames would ship broken. Added
   Engine.prime() — WebGLRenderer.compile() plus a discarded warm frame —
   and the exporter now primes before encoding. Rendering a throwaway frame
   and reading it back is NOT sufficient; measured, it left 3-5 frames wrong.

2. Moiré Grid declared a param on u_width, which the shader contract already
   uses for stereo width. GLSL redefinition, and the only symptom was a
   black frame. Lint now rejects any param uniform colliding with the
   contract.

3. Spectrum Sculpture strobed at 4 flashes/s. Two causes: rotation measured
   in turns meant bar-crossing frequency was bars x rate (82 bars put a
   slow-looking 0.12 turns/s at 10 Hz), and hard band-tier boundaries made
   every bar switch band simultaneously. Rotation is now in segment units so
   the rate IS the crossing frequency, bands interpolate, and the range is
   capped where the flash meter measures zero.

4. Particle Field was being chosen as a primary background despite being
   mostly empty by design. Scenes now declare role: 'accent'; those are
   never primary and are judged on variance rather than luminance.

Three checks were themselves wrong and were rebuilt: mean-distance metrics
unfairly fail sparse scenes for being tasteful rather than static, so
"animates" and "no duplicates" now use max channel delta.

PLAN.md §1 gains two refinements: programs must be primed before the first
frame, and even same-machine the heaviest shaders vary by one LSB under
differing GPU load — so the per-scene criterion is max delta <= 1 rather
than an identical hash. A real bug scores in the tens there.

Full suite 67/67 across all seven phases. Worst 4K frame 3.8ms,
worst flash rate 0/s, worst determinism delta 1/255. Adds README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-05 18:40:56 +02:00
parent 8a94a3f3a5
commit 9d15c3cf49
24 changed files with 1234 additions and 10 deletions

View File

@ -85,6 +85,24 @@ unrealistic, and chasing it would be wasted effort. The cross-machine guarantee
*perceptual*: frames must match within a small diff threshold. The validation checks in §11
are written to that distinction, so the acceptance criteria are actually achievable.
Two refinements, both found by building it:
**Programs must be primed before the first frame.** Shader programs link asynchronously
(`KHR_parallel_shader_compile`), and a draw issued against an unlinked program produces
wrong output. Measured on the heaviest scene, the first *ten* frames rendered differently
from every later render of the same frames. Preview hides this completely — the frames go
past and the next pass is right — but an export renders each frame exactly once, so those
frames would ship broken. `Engine.prime()` compiles every program and discards a warm frame,
and the exporter calls it before encoding anything. Rendering a throwaway frame and reading
it back is *not* sufficient; `WebGLRenderer.compile()` is.
**Even same-machine, the heaviest shaders vary by one LSB.** With priming in place most
scenes reproduce byte-for-byte, but a few come back with scattered pixels differing by 1/255
— floating-point variance under differing GPU load. That is below any perceptual threshold
and is not something the hardware offers to fix, so the per-scene criterion is a max
channel delta of ≤ 1 rather than an identical hash. A real determinism bug scores in the
tens or hundreds on that metric, so the check keeps its teeth.
---
## 2. Audio analysis
@ -317,6 +335,8 @@ difference between "seems fine" and "verified".
| **Schema lint** | Parses each scene's shader for `uniform` declarations and cross-checks against its `params` block, both directions. Catches typos and orphans; the thing that keeps a 30-scene library maintainable. |
| **Param range sweep** | Renders each param at several points across its declared range, asserts no NaN, no all-black, no all-white frames. |
| **Perf HUD** | Frame time, GPU time per layer, frame index, active section, live param values. |
| **Flash-rate meter** | Counts light-dark cycles per second against the WCAG 2.3.1 / Harding ceiling of three. Added during Phase 5 and not in the original plan — this generates beat-reactive video for publication, an unsupervised generator finds unsafe states on its own, and nobody watches every frame of every export. It caught a scene running at 7-8 flashes/s at *every* output resolution. |
| **First-render check** | Renders a frame in a fresh engine and compares against the same frame rendered later. Catches anything that is correct on repeat but wrong the first time — invisible to fresh-vs-fresh comparison, and wrong in every export, which renders each frame exactly once. Caught two separate bugs (a reused feature row, and unlinked shader programs). |
### The track battery

121
flow-state/README.md Normal file
View File

@ -0,0 +1,121 @@
# flow-state
Ambient/EDM music video generator. Drop in a track, get a full-length, non-story,
music-reactive video. No sourced footage — every frame is generated, and the whole
look is derived from the audio.
```bash
npm install
npm run dev # http://localhost:5180
```
Drop an audio file onto the page (mp3, flac, wav, ogg). Analysis takes a second or
two, then the video is ready to preview and export.
## How it works
The track is decoded and analysed **before the first frame renders**, into a table
with one row per video frame: band energies, onset flux, spectral centroid and
flatness, a phase-locked beat grid, section boundaries, and lookahead fields.
Nothing reads a live `AnalyserNode`. Realtime preview maps `audio.currentTime` to a
frame index; export counts frames. Both read the same rows, so **what you preview is
what you export** — the exporter has no render path of its own.
Analysing the whole track up front also buys the thing a causal analyser cannot do:
a build can *anticipate* its drop and arrive at the transition already at full
tension, instead of reacting once the drop has landed.
## Working with it
| | |
|---|---|
| `space` | play / pause |
| `←` `→` | previous / next section boundary |
| `L` | loop the current section |
| `D` | debug HUD |
| `,` `.` | step one frame |
**test render** exports 20 seconds around the playhead at full export quality. Use
it before committing to a full render.
**reroll** re-seeds the whole track; **reroll section** changes only the section
under the playhead; **lock** protects a section from further rerolls. Every
parameter the generator chose is exposed under the *scene* tab and can be edited
live.
The **click track** button (look tab) mixes an audible click onto the detected beat
grid. If the clicks don't sit on the beat, tempo detection is wrong and everything
downstream inherits it — check this first when a track looks off.
## Checks
```bash
npm test # audio pipeline against synthetic ground truth
npm run lint:scenes # determinism grep + scene schema/shader agreement
```
`http://localhost:5180/checks.html` runs the GPU gates for every phase. Add
`?slow=1` for the full suite, `?phase=5` for one phase.
## Adding a scene
A scene is a shader plus a params block. Everything else — uniform binding, UI
controls, seeded per-track sampling, arc automation — is derived from the schema.
```js
export const myScene = {
name: 'My Scene',
family: 'organic', // flow organic minimal structural geometric glitch
kind: 'fragment',
params: {
density: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_density', bias: 'density' },
speed: { type: 'float', range: [0.1, 2], default: 0.5, uniform: 'u_speed', rate: true },
palette: { type: 'palette', count: 4 },
},
reactive: {
density: { feature: 'bandLow', amount: 0.3 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
return vec4(palRamp(fbm(p * 4.0 + t, 4)), 1.0);
}
`,
};
```
Register it in `src/scenes/registry.js`, then run `npm run lint:scenes` and the
Phase 7 checks. Three rules the linter enforces, each of which has already caused a
real bug here:
- **Anything multiplying `u_time` must be `rate: true`.** Phase is `elapsed × rate`,
so modulating a rate jumps the phase by `elapsed × delta` — a minute in, a small
wobble throws the image several whole units between frames. It measured as
strobing at twice the accessibility limit.
- **Don't reuse a contract uniform name** (`u_width`, `u_time`, `u_seed`, …). It's a
GLSL redefinition error, and the only symptom is a black frame.
- **Use `pal()` / `palRamp()`**, not hardcoded colours, or the look generator can't
recolour the scene.
Scenes that composite over a background rather than being one declare
`role: 'accent'`.
## Layout
```
src/
audio/ decode, STFT analysis, tempo, segmentation, FeatureTrack, click track
engine/ Timeline, Renderer, Layer, Compositor, passes, seeded rng, flash safety
look/ palette (OKLCH), LookGenerator, ArcDriver
params/ declarative schema, validation, serialisation
scenes/ the library — shader/ and layers3d/
export/ WebCodecs exporter
ui/ preview surface
checks/ phase gates, run from checks.html
```
`PLAN.md` has the full design and the reasoning behind each gate.
Forked from `party-stage` by copying what was useful, then fully detached — there
are no imports across the directory boundary in either direction.

View File

@ -140,6 +140,13 @@ export class Show {
return this.engine.compositor.render({ timeline, features });
}
/** See Engine.prime — required before frame-exact rendering. */
prime(frame = 0) {
this.renderFrame(frame); // ensures the arc has built its layers
this.engine.prime(frame);
return this;
}
/** Advance stateful layers so an arbitrary seek lands on converged state. */
warmUp(frame, warmupFrames = 120) {
const start = Math.max(0, frame - warmupFrames);

View File

@ -8,6 +8,7 @@ import './phase3.js';
import './phase4.js';
import './phase5.js';
import './phase6.js';
import './phase7.js';
const out = document.getElementById('results');
const summaryEl = document.getElementById('summary');

View File

@ -82,7 +82,9 @@ check(2, 'every scene compiles and renders', () => {
const pixels = engine.readPixels(engine.renderFrame(1200));
const lum = frameLuminance(pixels);
const variance = frameVariance(pixels);
if (!(lum > 0.001)) problems.push(`${module.name}: black frame`);
// Accent scenes composite over a background; most of their frame is
// legitimately black, so only variance is meaningful for them.
if (module.role !== 'accent' && !(lum > 0.001)) problems.push(`${module.name}: black frame`);
if (variance < 0.002) problems.push(`${module.name}: flat (var ${variance.toFixed(4)})`);
} catch (err) {
problems.push(`${module.name}: ${err.message}`);
@ -112,9 +114,12 @@ check(2, 'param range sweep produces no dead or blown frames', () => {
const lum = frameLuminance(pixels);
const variance = frameVariance(pixels);
const label = `${module.name}.${name}=${JSON.stringify(value)}`;
const accent = module.role === 'accent';
// An accent at brightness 0 really is black, and that is a
// legitimate value — judge those on variance alone.
if (lum > 0.985) problems.push(`${label} blown (lum ${lum.toFixed(3)})`);
if (lum < 0.0008) problems.push(`${label} black (lum ${lum.toFixed(5)})`);
if (variance < 0.0015) problems.push(`${label} flat (var ${variance.toFixed(4)})`);
if (!accent && lum < 0.0008) problems.push(`${label} black (lum ${lum.toFixed(5)})`);
if (!accent && variance < 0.0015) problems.push(`${label} flat (var ${variance.toFixed(4)})`);
} catch (err) {
problems.push(`${module.name}.${name}: ${err.message}`);
} finally {

View File

@ -0,0 +1,290 @@
// Phase 7 gate — the library.
//
// Everything here is per-scene rather than per-phase, and it is the gate every
// future scene has to clear too. The static half (schema/shader agreement, rate
// params) runs in tools/lint-scenes.js; the range sweep is Phase 2's and the
// flash sweep is Phase 5's — both automatically cover new scenes because they
// iterate the registry.
import { check, expect, expectBelow } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { Show } from '../Show.js';
import { scenes, FAMILIES, scenesInFamily } from '../scenes/registry.js';
import { defaultValues, sampleValues } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { generateLook } from '../look/LookGenerator.js';
import { frameDistance, frameMaxDelta, frameLuminance, frameVariance } from '../engine/hash.js';
const PALETTE = [
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
[0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
];
let cached = null;
function track7() {
if (!cached) {
cached = FeatureTrack.fromAudioBuffer(
synthesizeSectioned({ bpm: 128, duration: 120, changeAt: 60 }), { fps: 60 });
}
return cached;
}
function makeEngine(width = 192, height = 108) {
const engine = new Engine({ width, height });
const track = track7();
engine.timeline.setDuration(track.duration);
engine.setFeatureProvider(featureProviderFor(track));
return engine;
}
check(7, 'every family has enough scenes to choose between', () => {
const counts = Object.keys(FAMILIES).map((f) => [f, scenesInFamily(f).length]);
const thin = counts.filter(([, n]) => n < 2);
return expect(thin.length === 0,
counts.map(([f, n]) => `${f}:${n}`).join(' ') +
(thin.length ? ` — too thin: ${thin.map(([f]) => f).join(', ')}` : ` · ${scenes.length} total`));
});
check(7, 'no two scenes render the same image', () => {
// Catches a copy-paste scene whose shader was never actually changed, and
// accidental near-duplicates that would waste a library slot.
const engine = makeEngine();
try {
const frames = scenes.map((module) => {
engine.setLayerSpecs([{
module, params: defaultValues(module), seed: 99,
opacity: 1, blend: 'normal', palette: PALETTE,
}]);
engine.compositor.reset();
return { name: module.name, pixels: Uint8Array.from(engine.readPixels(engine.renderFrame(1200))) };
});
// Largest single-channel difference, not the mean: two sparse scenes are
// both mostly black, so their MEAN distance is tiny even when they look
// nothing alike. Identical scenes score 0 here; different ones score high.
let closest = 255;
let pair = '';
for (let i = 0; i < frames.length; i++) {
for (let j = i + 1; j < frames.length; j++) {
const d = frameMaxDelta(frames[i].pixels, frames[j].pixels);
if (d < closest) { closest = d; pair = `${frames[i].name} / ${frames[j].name}`; }
}
}
return expect(closest > 24, `closest pair ${pair} at max delta ${closest} (floor 24)`);
} finally {
engine.dispose();
}
}, { slow: true });
check(7, 'every scene stays live across seeds and section energies', () => {
// The per-scene acceptance run: several seeds, both a quiet and a loud
// context, checking nothing goes black, blows out or freezes flat.
const track = track7();
const quiet = track.sections.reduce((a, b) => (a.energy < b.energy ? a : b));
const loud = track.sections.reduce((a, b) => (a.energy > b.energy ? a : b));
const problems = [];
let rendered = 0;
for (const module of scenes) {
const engine = makeEngine();
try {
for (let s = 0; s < 3; s++) {
const rng = new Rng(4200 + s * 7919);
const bias = s === 0 ? { energy: 0.15, density: 0.2, motion: 0.2 }
: s === 1 ? { energy: 0.5, density: 0.5, motion: 0.5 }
: { energy: 0.95, density: 0.9, motion: 0.9 };
engine.setLayerSpecs([{
module, params: sampleValues(module, rng, bias), seed: s * 31 + 5,
opacity: 1, blend: 'normal', palette: PALETTE,
}]);
for (const section of [quiet, loud]) {
const frame = section.startFrame + 120;
engine.compositor.reset();
const pixels = engine.readPixels(engine.renderFrame(frame));
rendered++;
const lum = frameLuminance(pixels);
const variance = frameVariance(pixels);
const accent = module.role === 'accent';
const dead = accent ? variance < 0.0008
: (lum < 0.0008 || lum > 0.99 || variance < 0.0015);
if (dead) {
problems.push(`${module.name} s${s} ${section.kind}: ` +
`lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`);
}
}
}
} catch (err) {
problems.push(`${module.name}: ${err.message}`);
} finally {
engine.dispose();
}
}
return expect(problems.length === 0,
problems.length ? problems.slice(0, 5).join(' · ')
: `${rendered} frames across ${scenes.length} scenes, all live`);
}, { slow: true });
check(7, 'every scene animates rather than sitting still', () => {
// A scene that renders a beautiful static frame passes every other check and
// is useless. Compare frames two seconds apart.
const engine = makeEngine();
const problems = [];
try {
for (const module of scenes) {
engine.setLayerSpecs([{
module, params: defaultValues(module), seed: 1234,
opacity: 1, blend: 'normal', palette: PALETTE,
}]);
engine.compositor.reset();
const a = Uint8Array.from(engine.readPixels(engine.renderFrame(1200)));
engine.compositor.reset();
const b = Uint8Array.from(engine.readPixels(engine.renderFrame(1320)));
// Measured as the largest single-channel change, not the mean: a
// sparse scene (thin bars on black) moves few pixels, so a mean-based
// threshold fails it for being tasteful rather than for being static.
const d = frameMaxDelta(a, b);
if (d < 12) problems.push(`${module.name}: max channel delta only ${d} over 2s`);
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : `${scenes.length} scenes all move`);
} finally {
engine.dispose();
}
}, { slow: true });
check(7, 'every scene is deterministic', () => {
// Judged on a one-LSB tolerance rather than bit-exact hashes.
//
// With the engine primed, most scenes reproduce byte-for-byte. The heaviest
// shaders do not quite: they come back with a handful of pixels differing by
// 1/255, which is GPU floating-point variance under differing load, not a
// logic fault. Demanding bit-exactness of them would be demanding something
// the hardware does not offer, so the criterion is "no visible difference"
// — and 1/255 is comfortably below that. Anything with a real bug scores in
// the tens or hundreds here, not 1. See PLAN.md §1.
const problems = [];
let worst = 0;
let worstScene = '';
for (const module of scenes) {
const engine = makeEngine(128, 72);
try {
engine.setLayerSpecs([{
module, params: defaultValues(module), seed: 4242,
opacity: 1, blend: 'normal', palette: PALETTE,
}]);
engine.prime(600);
const capture = () => {
engine.compositor.reset();
const out = [];
for (let f = 600; f < 620; f++) {
out.push(Uint8Array.from(engine.readPixels(engine.renderFrame(f))));
}
return out;
};
const a = capture();
const b = capture();
let sceneWorst = 0;
for (let i = 0; i < a.length; i++) {
sceneWorst = Math.max(sceneWorst, frameMaxDelta(a[i], b[i]));
}
if (sceneWorst > worst) { worst = sceneWorst; worstScene = module.name; }
if (sceneWorst > 1) problems.push(`${module.name}: max delta ${sceneWorst}`);
} finally {
engine.dispose();
}
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ')
: `${scenes.length} scenes reproducible · worst ${worst}/255 (${worstScene || 'none'})`);
}, { slow: true });
check(7, 'every scene stays within the 4K frame budget', () => {
// 16.7ms is the realtime bar; at 4K a scene is allowed more, but a scene an
// order of magnitude over would make a six-minute export unreasonable.
const engine = makeEngine(3840, 2160);
const timings = [];
try {
for (const module of scenes) {
engine.setLayerSpecs([{
module, params: defaultValues(module), seed: 7,
opacity: 1, blend: 'normal', palette: PALETTE,
}]);
engine.renderFrame(1200); // compile and warm
const started = performance.now();
for (let f = 1200; f < 1210; f++) engine.renderFrame(f);
engine.readPixels(engine.compositor.outputTarget); // force the GPU to finish
timings.push({ name: module.name, ms: (performance.now() - started) / 10 });
}
timings.sort((a, b) => b.ms - a.ms);
const worst = timings[0];
return expectBelow(worst.ms, 60,
`worst ${worst.name} ${worst.ms.toFixed(1)}ms/frame at 3840x2160 · ` +
timings.slice(0, 3).map((t) => `${t.name} ${t.ms.toFixed(1)}`).join(', '));
} finally {
engine.dispose();
}
}, { slow: true });
check(7, 'quiet sections now get minimal scenes', () => {
// The concrete payoff of filling the family. Before Phase 7 there were no
// 'minimal' scenes, so intros and breakdowns fell through to flow/organic
// and every track opened at full density.
const track = track7();
const kinds = { intro: 0, breakdown: 0, outro: 0 };
const restful = new Set(['minimal', 'flow', 'organic']);
let total = 0;
let restfulCount = 0;
let minimalCount = 0;
for (let s = 0; s < 24; s++) {
const look = generateLook(track, { seed: 11000 + s * 104729 });
for (const section of look.sections) {
if (!(section.kind in kinds)) continue;
total++;
const family = section.layers[0].module.family;
if (restful.has(family)) restfulCount++;
if (family === 'minimal') minimalCount++;
}
}
return expect(total > 0 && restfulCount === total && minimalCount > 0,
`${restfulCount}/${total} quiet sections got a restful family, ` +
`${minimalCount} of them minimal, across 24 seeds`);
});
check(7, 'the library still renders whole looks end to end', () => {
const track = track7();
const problems = [];
let sections = 0;
for (let s = 0; s < 6; s++) {
const show = new Show({ width: 160, height: 90 });
try {
show.useTrack(track, generateLook(track, { seed: 21000 + s * 15485863 }));
for (const section of show.look.sections) {
sections++;
const frame = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2);
show.engine.compositor.reset();
const pixels = show.readPixels(show.renderFrame(frame));
const lum = frameLuminance(pixels);
const variance = frameVariance(pixels);
if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) {
problems.push(`seed ${s} ${section.kind} ` +
`[${section.layers.map((l) => l.module.name).join(' + ')}]`);
}
}
} catch (err) {
problems.push(`seed ${s}: ${err.message}`);
} finally {
show.dispose();
}
}
return expect(problems.length === 0,
problems.length ? problems.slice(0, 4).join(' · ')
: `${sections} sections across 6 seeds, all live`);
}, { slow: true });

View File

@ -45,6 +45,7 @@ export class Compositor {
this.fade = 1;
this.soloIndex = -1; // debug: render one layer alone
this.postEnabled = true;
this._primed = new WeakSet();
this._buildTargets();
this._buildMaterials();
@ -124,10 +125,68 @@ export class Compositor {
* about to be reused (and recompile them on the way back).
*/
setLayers(layers) {
for (const layer of layers) {
if (!this._primed.has(layer)) {
this._primeLayer(layer);
this._primed.add(layer);
}
}
this.layers = layers;
return this;
}
/**
* Force a layer's shader program to finish linking before it is used for real.
*
* three.js links programs through KHR_parallel_shader_compile, so the first
* draws after a material is created can run against a program that is not
* ready and produce wrong output. Measured on the heaviest scene in the
* library, the first TEN frames rendered differently from every later render
* of the same frames. Preview hides this the frames go by and the next pass
* is correct but an export renders each frame exactly once, so those frames
* would ship broken.
*
* Rendering a throwaway frame and reading it back is NOT sufficient: measured,
* it left 3-5 frames still wrong. WebGLRenderer.compile() is the API that
* actually waits for the link, and it clears the problem completely.
*/
_primeLayer(layer) {
try {
if (layer.material) this.renderer.compileMaterial(layer.material);
else if (layer.scene && layer.camera) this.renderer.compileScene(layer.scene, layer.camera);
} catch (err) {
console.warn('[compositor] priming failed for', layer.module && layer.module.name, err);
}
}
/**
* Bring the whole chain to a state where the next rendered frame is correct.
*
* Shader programs link asynchronously (KHR_parallel_shader_compile), and until
* they are ready a draw produces wrong output. compile() covers the programs;
* the discarded frame covers everything else that is lazily created on first
* use. Cheap, and it converts "the first few frames may be wrong" into "the
* first few frames were thrown away".
*
* The exporter calls this before encoding anything, because an export renders
* each frame exactly once and has no second chance to get frame 0 right.
*/
prime(ctx = null) {
const materials = [
this.blendMaterial, this.feedbackMaterial, this.brightMaterial,
this.blurMaterial, this.compositeMaterial, this.copyMaterial,
];
for (const material of materials) {
try { this.renderer.compileMaterial(material); } catch { /* non-fatal */ }
}
for (const layer of this.layers) this._primeLayer(layer);
if (ctx) {
try { this.render(ctx); } catch { /* non-fatal */ }
}
this.reset();
}
setPost(post) {
this.post = { ...this.post, ...post };
return this;

View File

@ -68,6 +68,16 @@ export class Engine {
this.compositor.layers.forEach((l) => l.setPalette(colors));
}
/**
* Compile every shader and discard a warm frame, so the next frame rendered
* is correct. Required before any frame-exact use (export, hashing).
*/
prime(frame = 0) {
this.timeline.seek(frame);
this.compositor.prime({ timeline: this.timeline, features: this.featuresAt(frame) });
return this;
}
/** Render exactly one frame at the timeline's current position. */
renderCurrent() {
const features = this.featuresAt(this.timeline.frame);
@ -99,7 +109,8 @@ export class Engine {
* 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 } = {}) {
hashRun(start, count, { reset = true, prime = true } = {}) {
if (prime) this.prime(start);
if (reset) this.compositor.reset();
const hashes = [];
for (let i = 0; i < count; i++) {

View File

@ -89,6 +89,26 @@ export class Renderer {
this.gl.setRenderTarget(null);
}
/**
* Force a material's shader program to compile and link NOW.
*
* three.js links through KHR_parallel_shader_compile, so a freshly created
* material can be drawn with a program that is not ready yet, producing wrong
* frames until it is. Rendering a throwaway frame and reading it back does not
* reliably wait for the link; WebGLRenderer.compile() does.
*/
compileMaterial(material) {
const previous = this.quadMesh.material;
this.quadMesh.material = material;
this.gl.compile(this.quadScene, this.quadCamera);
this.quadMesh.material = previous;
}
/** Same, for a 3D layer's own scene. */
compileScene(scene, camera) {
this.gl.compile(scene, camera);
}
readPixels(target) {
const w = target ? target.width : this.width;
const h = target ? target.height : this.height;

View File

@ -134,6 +134,12 @@ export class Exporter {
show.setSize(width, height);
try {
// Compile every shader and discard a warm frame first. Programs link
// asynchronously, and an export renders each frame exactly once — there
// is no second pass to fix frame 0 with.
onProgress && onProgress({ frame: 0, total, fraction: 0, stage: 'compiling shaders' });
show.prime(startFrame);
// Warm-up so the first exported frame has the same feedback state it
// would have had in sequential playback from the range start.
if (startFrame > 0) {

View File

@ -71,7 +71,7 @@ function assignScenesByKind(sections, rng) {
const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES);
let candidates = [];
for (const family of families) {
const inFamily = scenesInFamily(family);
const inFamily = scenesInFamily(family).filter((m) => m.role !== 'accent');
// Weight by family preference order, and push down anything already
// used so a five-section track doesn't show one scene five times.
const weight = families.length - families.indexOf(family);
@ -79,7 +79,9 @@ function assignScenesByKind(sections, rng) {
candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) });
}
}
if (!candidates.length) candidates = scenes.map((scene) => ({ scene, weight: 1 }));
if (!candidates.length) {
candidates = scenes.filter((m) => m.role !== 'accent').map((scene) => ({ scene, weight: 1 }));
}
const chosen = rng.pickWeighted(
candidates.map((c) => c.scene),
@ -144,9 +146,10 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes'));
const { post, feedback } = derivePost(summary, rng.fork('post'));
// Scenes eligible as accents: 3D layers composite over a shader background
// without fighting it, so they are preferred where available.
const accentRoster = scenes.filter((m) => m.kind === 'layer3d');
// Scenes that declare role 'accent' composite over a background rather than
// being one — most of their frame is empty by design. They are never chosen
// as a section's primary scene.
const accentRoster = scenes.filter((m) => m.role === 'accent');
const sections = track.sections.map((section) => {
const module = sceneByKind.get(section.kind) || scenes[0];
@ -211,7 +214,7 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
const families = FAMILY_BY_KIND[section.kind] || Object.keys(FAMILIES);
const candidates = families.flatMap((f) => scenesInFamily(f));
const candidates = families.flatMap((f) => scenesInFamily(f)).filter((m) => m.role !== 'accent');
const module = candidates.length ? rng.pick(candidates) : scenes[0];
section.layers = [{

View File

@ -11,6 +11,10 @@ export const particleField = {
name: 'Particle Field',
family: 'flow',
kind: 'layer3d',
// Composited over a background, never used as one: most of the frame is
// legitimately black, so it is judged on variance rather than luminance and
// the look generator only picks it as an accent layer.
role: 'accent',
params: {
count: { type: 'int', range: [200, 4000], default: 1200, bias: 'density', noDrift: true },

View File

@ -6,6 +6,16 @@ import { floatingGeometry } from './shader/floating-geometry.js';
import { synthwaveRun } from './shader/synthwave-run.js';
import { psychedelicDrift } from './shader/psychedelic-drift.js';
import { particleField } from './layers3d/particles.js';
import { horizonLines } from './shader/horizon-lines.js';
import { spectrumSculpture } from './shader/spectrum-sculpture.js';
import { slowOrb } from './shader/slow-orb.js';
import { curlFlow } from './shader/curl-flow.js';
import { plasmaBloom } from './shader/plasma-bloom.js';
import { metaballs } from './shader/metaballs.js';
import { kaleidoTunnel } from './shader/kaleido-tunnel.js';
import { moireGrid } from './shader/moire-grid.js';
import { ridgeTerrain } from './shader/ridge-terrain.js';
import { scanTear } from './shader/scan-tear.js';
/**
* The scene library. Families exist so the arc driver can choose by section
@ -28,6 +38,19 @@ const MODULES = [
synthwaveRun,
psychedelicDrift,
particleField,
// Phase 7 additions. 'minimal' came first: with the family empty, intros and
// breakdowns fell through to flow/organic and every track opened at density.
horizonLines,
spectrumSculpture,
slowOrb,
curlFlow,
plasmaBloom,
metaballs,
kaleidoTunnel,
moireGrid,
ridgeTerrain,
scanTear,
];
const errors = [];

View File

@ -0,0 +1,54 @@
// Flow family: streaks advected along a curl-noise field.
//
// Uses the compositor's feedback texture rather than integrating positions, so
// the trails cost nothing in state and a seek still lands correctly once the
// feedback buffer has converged.
export const curlFlow = {
name: 'Curl Flow',
family: 'flow',
kind: 'fragment',
params: {
scale: { type: 'float', range: [0.5, 6], default: 2.0, uniform: 'u_scale', bias: 'density' },
speed: { type: 'float', range: [0.02, 0.5], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true },
streak: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_streak' },
contrast: { type: 'float', range: [0.5, 4], default: 1.6, uniform: 'u_contrast' },
veins: { type: 'float', range: [1, 12], default: 5.0, uniform: 'u_veins', bias: 'density' },
glow: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 5 },
},
reactive: {
glow: { feature: 'beat', amount: 0.3, response: 'spike' },
veins: { feature: 'bandMid', amount: 0.25 },
streak: { feature: 'flux', amount: 0.2, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
vec2 flow = curl(p * u_scale + vec2(t, -t * 0.7), t * 0.5);
vec2 q = p + flow * 0.35;
// Ridged noise gives filament-like veins rather than soft cloud.
float n = fbm(q * u_scale * 1.4 + t * 0.6, 5);
float veins = 1.0 - abs(sin(n * u_veins + t * 2.0));
veins = pow(sat(veins), u_contrast);
vec3 col = mix(pal(0) * 0.12, pal(1), veins);
col += pal(2) * pow(veins, 3.0) * u_glow;
col = mix(col, pal(3), sat(length(flow) * 0.4) * 0.35);
// Feedback trails: the previous frame, pulled slightly along the flow.
vec3 trail = prev(uv - flow * 0.004);
col = max(col, trail * u_streak);
col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.35);
return vec4(col, 1.0);
}
`,
};
export default curlFlow;

View File

@ -0,0 +1,59 @@
// Minimal family: a sparse field of horizontal lines that bend around the
// centre. Most of the frame is negative space, which is exactly what an intro or
// a breakdown wants — the arc driver has nowhere restful to go otherwise.
export const horizonLines = {
name: 'Horizon Lines',
family: 'minimal',
kind: 'fragment',
params: {
count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density' },
thickness: { type: 'float', range: [0.002, 0.03], default: 0.008, uniform: 'u_thickness' },
bend: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_bend' },
speed: { type: 'float', range: [0.02, 0.4], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true },
spread: { type: 'float', range: [0.2, 1.4], default: 0.9, uniform: 'u_spread' },
glow: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {
bend: { feature: 'bandLow', amount: 0.35, response: 'smooth' },
glow: { feature: 'beat', amount: 0.3, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Displace vertically by a slow wave, strongest at the centre of the frame.
float envelope = exp(-p.x * p.x * 1.2);
float offset = sin(p.x * 2.2 + t * 2.0) * u_bend * envelope;
vec3 col = pal(0) * 0.06;
float total = 0.0;
for (int i = 0; i < 40; i++) {
if (float(i) >= u_count) break;
float fi = float(i);
float slot = (fi / max(u_count - 1.0, 1.0) - 0.5) * 2.0 * u_spread;
float y = slot + offset * (0.4 + fract(fi * 0.37));
float d = abs(p.y - y);
float line = smoothstep(u_thickness, 0.0, d);
float halo = exp(-d * 26.0) * u_glow;
vec3 c = pal(i);
col += c * (line + halo * 0.55);
total += line;
}
// Keep the far edges dark so the lines read as a subject, not wallpaper.
col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.5);
return vec4(col, 1.0);
}
`,
};
export default horizonLines;

View File

@ -0,0 +1,55 @@
// Geometric family: a kaleidoscopic tunnel. The default drop scene — strong
// forward motion, hard symmetry, and it takes the beat well.
export const kaleidoTunnel = {
name: 'Kaleido Tunnel',
family: 'geometric',
kind: 'fragment',
params: {
sides: { type: 'int', range: [2, 12], default: 6, uniform: 'u_sides' },
depth: { type: 'float', range: [1, 8], default: 3.0, uniform: 'u_depth', bias: 'density' },
speed: { type: 'float', range: [0.1, 1.5], default: 0.45, uniform: 'u_speed', bias: 'motion', rate: true },
twist: { type: 'float', range: [0, 2], default: 0.5, uniform: 'u_twist' },
rings: { type: 'float', range: [2, 24], default: 8, uniform: 'u_rings', bias: 'density' },
glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 6 },
},
reactive: {
glow: { feature: 'beat', amount: 0.45, response: 'spike' },
twist: { feature: 'bandLow', amount: 0.3 },
rings: { feature: 'bandHigh', amount: 0.2 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float radius = max(length(p), 1e-4);
vec2 folded = kaleido(p, float(u_sides));
float angle = atan(folded.y, folded.x);
// Tunnel coordinates: 1/r is depth, angle is the wall.
float z = u_depth / radius + t * 2.0;
float wall = angle / 3.14159265 + sin(z * 0.5 + t) * u_twist * 0.25;
float ringLines = abs(fract(z * u_rings * 0.1) - 0.5) * 2.0;
float wallLines = abs(fract(wall * float(u_sides)) - 0.5) * 2.0;
float grid = smoothstep(0.42, 0.0, ringLines) + smoothstep(0.42, 0.0, wallLines);
vec3 col = palRamp(z * 0.05 + wall * 0.2) * 0.35;
col += pal(int(mod(floor(z * u_rings * 0.1), 6.0))) * grid * 0.7;
// Depth cue: far end of the tunnel darkens, mouth glows.
float fade = smoothstep(0.0, 1.1, radius);
col *= 0.25 + 0.9 * fade;
col += pal(3) * (1.0 - fade) * u_glow * 0.6;
return vec4(col, 1.0);
}
`,
};
export default kaleidoTunnel;

View File

@ -0,0 +1,65 @@
// Organic family: merging metaballs on an analytic orbit.
//
// Positions come from closed-form orbits rather than any simulation, which keeps
// the scene seek-exact — the same rule the 3D particle layer follows.
export const metaballs = {
name: 'Metaballs',
family: 'organic',
kind: 'fragment',
params: {
count: { type: 'int', range: [2, 10], default: 5, uniform: 'u_count', bias: 'density' },
radius: { type: 'float', range: [0.1, 0.6], default: 0.3, uniform: 'u_radius', bias: 'energy' },
threshold: { type: 'float', range: [0.4, 2.2], default: 1.0, uniform: 'u_threshold' },
speed: { type: 'float', range: [0.03, 0.5], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
spread: { type: 'float', range: [0.2, 1.1], default: 0.6, uniform: 'u_spread' },
rim: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_rim' },
palette: { type: 'palette', count: 5 },
},
reactive: {
radius: { feature: 'bandLow', amount: 0.25 },
rim: { feature: 'beat', amount: 0.3, response: 'spike' },
threshold: { feature: 'flux', amount: 0.2, response: 'inverse' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float field = 0.0;
vec3 tint = vec3(0.0);
for (int i = 0; i < 10; i++) {
if (i >= u_count) break;
float fi = float(i);
float s = u_seed + fi * 71.3;
vec2 centre = vec2(
sin(t * (0.7 + fract(s * 0.13)) + s) * u_spread,
cos(t * (0.5 + fract(s * 0.29)) + s * 1.7) * u_spread * 0.62
);
float d = length(p - centre);
float contribution = (u_radius * u_radius) / max(d * d, 1e-4);
field += contribution;
tint += pal(i) * contribution;
}
tint /= max(field, 1e-4);
float surface = smoothstep(u_threshold - 0.25, u_threshold + 0.25, field);
float rim = smoothstep(u_threshold + 0.35, u_threshold, field)
* smoothstep(u_threshold - 0.3, u_threshold, field);
vec3 col = pal(0) * 0.06;
col = mix(col, tint, surface);
col += tint * rim * u_rim;
return vec4(col, 1.0);
}
`,
};
export default metaballs;

View File

@ -0,0 +1,67 @@
// Geometric family: two rotating line grids interfering.
//
// Moiré is a spatial-aliasing effect by nature, so this scene is the most likely
// in the library to alias badly. Line width is held above a floor and scaled by
// u_pixelScale, which is what keeps a 720p preview and a 4K export looking the
// same rather than the preview shimmering.
export const moireGrid = {
name: 'Moiré Grid',
family: 'geometric',
kind: 'fragment',
params: {
density: { type: 'float', range: [6, 60], default: 22, uniform: 'u_density', bias: 'density' },
offset: { type: 'float', range: [0.0, 0.5], default: 0.08, uniform: 'u_offset' },
rotate: { type: 'float', range: [0, 0.25], default: 0.04, uniform: 'u_rotate', bias: 'motion', rate: true },
// Named u_lineWidth, not u_width: the shader contract already declares
// `uniform float u_width` for stereo width, and a colliding name is a
// redefinition error that renders the scene as a black frame.
width: { type: 'float', range: [0.06, 0.5], default: 0.2, uniform: 'u_lineWidth' },
warp: { type: 'float', range: [0, 1], default: 0.25, uniform: 'u_warp' },
glow: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {
offset: { feature: 'bandLow', amount: 0.35 },
glow: { feature: 'beat', amount: 0.35, response: 'spike' },
warp: { feature: 'flux', amount: 0.2, response: 'smooth' },
},
shader: `
// Anti-aliased line grid: the smoothstep edge is widened by the screen-space
// derivative, so lines stay a consistent visual weight at any resolution.
float grid(vec2 q, float density, float width) {
vec2 g = q * density;
vec2 f = abs(fract(g) - 0.5);
float d = min(f.x, f.y);
float aa = max(fwidth(d), 0.001);
return smoothstep(width * 0.5 + aa, width * 0.5 - aa, d);
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_rotate + u_seed;
vec2 warp = vec2(fbm(p * 1.5 + t, 3), fbm(p * 1.5 - t, 3)) - 0.5;
vec2 q = p + warp * u_warp;
float a = grid(rot(t * 6.28318530718) * q, u_density, u_lineWidth);
float b = grid(rot(-t * 6.28318530718 + u_offset * 3.14159) * (q + u_offset), u_density, u_lineWidth);
// The interference term is the point: where both grids land, it peaks.
float interference = a * b;
float either = max(a, b);
vec3 col = pal(0) * 0.05;
col += pal(1) * either * 0.35;
col += pal(2) * interference * (0.8 + u_glow);
col += pal(3) * pow(interference, 3.0) * u_glow;
col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.3);
return vec4(col, 1.0);
}
`,
};
export default moireGrid;

View File

@ -0,0 +1,50 @@
// Organic family: domain-warped plasma. The workhorse sustain scene — it holds
// up for minutes because the warp keeps folding new structure into itself rather
// than cycling.
export const plasmaBloom = {
name: 'Plasma Bloom',
family: 'organic',
kind: 'fragment',
params: {
scale: { type: 'float', range: [0.8, 6], default: 2.4, uniform: 'u_scale', bias: 'density' },
warp: { type: 'float', range: [0, 3], default: 1.2, uniform: 'u_warp' },
speed: { type: 'float', range: [0.02, 0.5], default: 0.1, uniform: 'u_speed', bias: 'motion', rate: true },
bands: { type: 'float', range: [1, 10], default: 3.5, uniform: 'u_bands' },
softness:{ type: 'float', range: [0, 1], default: 0.5, uniform: 'u_softness' },
glow: { type: 'float', range: [0, 1.5], default: 0.4, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 6 },
},
reactive: {
warp: { feature: 'bandLow', amount: 0.35 },
glow: { feature: 'beat', amount: 0.35, response: 'spike' },
bands: { feature: 'centroid', amount: 0.2, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Two rounds of domain warping. One looks like noise; two looks organic.
vec2 q = vec2(fbm(p * u_scale + t, 4), fbm(p * u_scale + vec2(5.2, 1.3) - t, 4));
vec2 r = vec2(fbm(p * u_scale + q * u_warp * 2.0 + vec2(1.7, 9.2) + t * 0.6, 5),
fbm(p * u_scale + q * u_warp * 2.0 + vec2(8.3, 2.8) - t * 0.4, 5));
float v = fbm(p * u_scale + r * u_warp * 2.0, 5);
float shaped = sin(v * u_bands * 3.14159 + t * 1.5) * 0.5 + 0.5;
shaped = mix(shaped, smoothstep(0.25, 0.75, shaped), u_softness);
vec3 col = palRamp(shaped * 0.6 + length(r) * 0.25);
col *= 0.35 + 0.75 * shaped;
col += pal(4) * pow(shaped, 5.0) * u_glow;
// Dark corners so the bloom has somewhere to sit.
col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.4);
return vec4(col, 1.0);
}
`,
};
export default plasmaBloom;

View File

@ -0,0 +1,72 @@
// Structural family: layered ridge silhouettes receding to a horizon.
//
// Cheap fake depth — parallax layers rather than a raymarch — which keeps it
// affordable at 4K while still reading as a place rather than a pattern.
export const ridgeTerrain = {
name: 'Ridge Terrain',
family: 'structural',
kind: 'fragment',
params: {
layers: { type: 'int', range: [2, 10], default: 6, uniform: 'u_layers', bias: 'density' },
height: { type: 'float', range: [0.1, 0.8], default: 0.35, uniform: 'u_height', bias: 'energy' },
rough: { type: 'float', range: [1, 6], default: 2.5, uniform: 'u_rough' },
speed: { type: 'float', range: [0.01, 0.3], default: 0.06, uniform: 'u_speed', bias: 'motion', rate: true },
horizon: { type: 'float', range: [-0.4, 0.4], default: 0.0, uniform: 'u_horizon' },
haze: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_haze' },
stars: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_stars' },
palette: { type: 'palette', count: 6 },
},
reactive: {
height: { feature: 'bandLow', amount: 0.25, response: 'smooth' },
haze: { feature: 'beat', amount: 0.2, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Sky gradient above the horizon.
float sky = sat((p.y - u_horizon) * 0.8 + 0.5);
vec3 col = mix(pal(0) * 0.35, pal(1) * 0.18, sky);
// Sparse stars, only in the upper sky, fading as haze rises.
if (u_stars > 0.01 && p.y > u_horizon) {
vec2 cell = floor(uv * 220.0);
float rnd = hash12(cell);
float star = step(0.9975, rnd) * sat((p.y - u_horizon) * 2.0);
col += vec3(star) * u_stars * (0.6 + 0.4 * sin(t * 8.0 + rnd * 30.0));
}
// Ridges, far to near. Each is a 1D fbm silhouette.
for (int i = 0; i < 10; i++) {
if (i >= u_layers) break;
float fi = float(i);
float depth = fi / float(max(u_layers - 1, 1)); // 0 far .. 1 near
float parallax = mix(0.15, 1.0, depth);
float x = p.x * mix(0.6, 1.8, depth) + t * parallax + fi * 13.7;
float ridge = fbm(vec2(x, fi * 5.1) * u_rough, 4) - 0.5;
float base = u_horizon - depth * 0.28;
float top = base + ridge * u_height * mix(0.5, 1.3, depth);
float mask = smoothstep(0.004, 0.0, p.y - top);
vec3 tint = mix(pal(2), pal(4), depth);
// Distant layers wash out toward the sky colour.
tint = mix(mix(pal(1) * 0.4, tint, 0.35 + depth * 0.65), tint, 1.0 - u_haze * (1.0 - depth));
col = mix(col, tint * (0.25 + depth * 0.75), mask);
// Rim light along each crest.
col += pal(5) * smoothstep(0.02, 0.0, abs(p.y - top)) * (0.12 + depth * 0.25) * u_haze;
}
return vec4(col, 1.0);
}
`,
};
export default ridgeTerrain;

View File

@ -0,0 +1,73 @@
// Glitch family: horizontal block displacement, chroma tearing and scanlines,
// built on the feedback buffer so the corruption smears across frames.
//
// Everything here is quantised to a beat- or bar-locked step rather than driven
// continuously. Free-running glitch reads as a broken renderer; glitch that
// lands on the grid reads as an effect.
export const scanTear = {
name: 'Scan Tear',
family: 'glitch',
kind: 'fragment',
params: {
slices: { type: 'float', range: [4, 48], default: 16, uniform: 'u_slices', bias: 'density' },
shift: { type: 'float', range: [0, 0.4], default: 0.12, uniform: 'u_shift', bias: 'energy' },
tear: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_tear' },
chroma: { type: 'float', range: [0, 0.08], default: 0.02, uniform: 'u_chromaSplit' },
scan: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_scan' },
persist: { type: 'float', range: [0, 0.9], default: 0.45, uniform: 'u_persist' },
speed: { type: 'float', range: [0.05, 0.8], default: 0.25, uniform: 'u_speed', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
shift: { feature: 'beat', amount: 0.5, response: 'spike' },
tear: { feature: 'flux', amount: 0.35, response: 'spike' },
slices:{ feature: 'bandHigh', amount: 0.25 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
// Quantise to the bar so displacement steps in time with the music rather
// than crawling. floor() of the bar phase gives a stable step per bar.
float step_ = floor(u_barPhase * 8.0) + floor(t * 4.0) * 8.0;
float row = floor(uv.y * u_slices);
float rowRandom = hash12(vec2(row, step_));
// Only some rows tear, and only above the tear threshold.
float torn = step(1.0 - u_tear, rowRandom);
float offset = (rowRandom - 0.5) * 2.0 * u_shift * torn;
vec2 q = vec2(fract(uv.x + offset), uv.y);
// Base image: a banded field, so the scene stands alone rather than needing
// something underneath it.
float band = fbm(vec2(q.x * 3.0, q.y * 6.0 + t), 4);
float ramp = fract(band * 2.0 + q.y * 2.0 - t * 0.5);
vec3 col = palRamp(ramp * 0.7 + row * 0.02);
col *= 0.4 + 0.6 * smoothstep(0.1, 0.9, band);
// Chroma split, strongest on torn rows.
float split = u_chromaSplit * (0.35 + torn);
col.r = mix(col.r, palRamp(ramp + split).r, 0.6);
col.b = mix(col.b, palRamp(ramp - split).b, 0.6);
// Scanlines, in normalised space so they survive a resolution change.
float lines = 0.5 + 0.5 * sin(uv.y * 900.0 * u_pixelScale);
col *= 1.0 - u_scan * 0.45 * lines;
// Smear the previous frame along the displacement.
vec3 ghost = prev(vec2(fract(uv.x + offset * 0.6), uv.y));
col = max(col, ghost * u_persist);
col += pal(4) * torn * u_shift * 0.6;
return vec4(col, 1.0);
}
`,
};
export default scanTear;

View File

@ -0,0 +1,55 @@
// Minimal family: one soft body drifting through a mostly empty frame.
//
// The quietest scene in the library, and the one an ambient intro or a long
// breakdown should usually land on. Nothing here reacts sharply — the beat
// mapping is deliberately weak, because a scene whose job is stillness should
// not twitch.
export const slowOrb = {
name: 'Slow Orb',
family: 'minimal',
kind: 'fragment',
params: {
size: { type: 'float', range: [0.15, 0.8], default: 0.38, uniform: 'u_size', bias: 'energy' },
softness: { type: 'float', range: [0.2, 1.0], default: 0.7, uniform: 'u_softness' },
drift: { type: 'float', range: [0.01, 0.2], default: 0.05, uniform: 'u_drift', bias: 'motion', rate: true },
wobble: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_wobble' },
halo: { type: 'float', range: [0, 1.2], default: 0.4, uniform: 'u_halo' },
grain: { type: 'float', range: [0, 0.5], default: 0.12, uniform: 'u_grain' },
palette: { type: 'palette', count: 4 },
},
reactive: {
size: { feature: 'loudness', amount: 0.12, response: 'smooth' },
halo: { feature: 'beat', amount: 0.15, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_drift + u_seed;
vec2 centre = vec2(sin(t * 1.7) * 0.28, cos(t * 1.3) * 0.18);
vec2 q = p - centre;
// Break the silhouette so it never reads as a hard circle.
float wobble = fbm(q * 2.4 + t, 4) * u_wobble;
float d = length(q) * (1.0 + wobble) - u_size;
float body = smoothstep(u_softness * 0.5, -u_softness * 0.5, d);
float glow = exp(-max(d, 0.0) * (5.0 / max(u_halo, 0.05))) * u_halo;
vec3 col = pal(0) * 0.05;
col = mix(col, pal(1), body * 0.85);
col += pal(2) * body * body * 0.5;
col += pal(3) * glow * 0.35;
// Fine grain keeps large flat areas from banding.
col += (hash12(uv * 640.0 + floor(u_frame)) - 0.5) * u_grain * 0.08;
return vec4(col, 1.0);
}
`,
};
export default slowOrb;

View File

@ -0,0 +1,94 @@
// Minimal family: a radial bar sculpture driven by the band split.
//
// This is the one scene that shows the spectrum more or less literally. It reads
// as a music visualiser rather than as an abstraction, which is why it is
// deliberately restrained — thin bars, lots of black — and why it lives in
// `minimal` rather than `geometric`.
export const spectrumSculpture = {
name: 'Spectrum Sculpture',
family: 'minimal',
kind: 'fragment',
params: {
bars: { type: 'float', range: [8, 96], default: 40, uniform: 'u_bars', bias: 'density' },
radius: { type: 'float', range: [0.15, 0.7], default: 0.35, uniform: 'u_radius' },
length: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_length', bias: 'energy' },
thickness: { type: 'float', range: [0.1, 0.9], default: 0.45, uniform: 'u_thickness' },
// Bar segments per second, NOT turns per second. Rotating by a full turn
// meant the bar-crossing frequency was bars x rate — at 82 bars that put a
// slow-looking 0.12 turns/s at 10 Hz of luminance flicker. In segment units
// the crossing frequency IS the rate, so it stays under the flash ceiling
// whatever the bar count.
// Capped at 0.4 by measurement, not by taste: at 0.83 the mirror fold puts
// this at 4 flashes/s, and at 0.4 it measures 0. Bar count no longer affects
// it now that rotation is in segment units.
rotate: { type: 'float', range: [0, 0.4], default: 0.2, uniform: 'u_rotate', bias: 'motion', rate: true },
mirror: { type: 'bool', default: true, uniform: 'u_mirror' },
palette: { type: 'palette', count: 5 },
},
reactive: {
// Kept low deliberately: bar length scales the whole ring at once, so a
// large amount pumps global luminance and measured 4 flashes/s against a
// ceiling of 3. See engine/flash.js.
length: { feature: 'loudness', amount: 0.15, response: 'smooth' },
thickness: { feature: 'beat', amount: 0.12, response: 'spike' },
},
shader: `
float bandByIndex(float i) {
if (i < 0.5) return u_bandSub;
if (i < 1.5) return u_bandLow;
if (i < 2.5) return u_bandMid;
if (i < 3.5) return u_bandHigh;
return u_bandAir;
}
float bandAt(float x) {
float s = clamp(x, 0.0, 1.0) * 4.0;
float i = floor(s);
float f = fract(s);
f = f * f * (3.0 - 2.0 * f);
return mix(bandByIndex(i), bandByIndex(i + 1.0), f);
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_rotate + u_seed;
float bars = u_bars;
float seg = 6.28318530718 / bars;
float angle = atan(p.y, p.x) + t * seg;
float radius = length(p);
float index = floor((angle + 3.14159265) / seg);
float cellAngle = mod(angle + 3.14159265, seg) / seg;
// Fold the ring so the two halves mirror; reads as a designed object rather
// than a spinning readout.
float slot = u_mirror > 0.5 ? abs(index / bars - 0.5) * 2.0 : index / bars;
// Band split across the ring, sub at one end and air at the other, INTERPOLATED
// rather than switched. Hard tier boundaries made every bar jump between bands
// at the same moment as the ring rotated, which stepped whole-frame luminance
// and measured 4 flashes/s against a ceiling of 3. Blending removes the step
// and looks better besides.
float band = bandAt(slot);
float height = u_radius + u_length * (0.25 + band);
float inBar = step(u_radius, radius) * step(radius, height);
float shape = smoothstep(0.5 - u_thickness * 0.5, 0.5, cellAngle)
* smoothstep(0.5 + u_thickness * 0.5, 0.5, cellAngle);
vec3 col = pal(0) * 0.05;
col += palRamp(slot * 0.7 + 0.15) * inBar * shape * (0.6 + band);
// Inner ring outline holds the composition together.
col += pal(2) * smoothstep(0.006, 0.0, abs(radius - u_radius)) * 0.35;
return vec4(col, 1.0);
}
`,
};
export default spectrumSculpture;

View File

@ -106,6 +106,16 @@ console.log('\nscene schema lint');
if (def.uniform) declared.set(def.uniform, name);
}
// Collision with the shader contract. A param that reuses a contract
// uniform name (u_width, u_time, u_seed...) is a GLSL redefinition error,
// and the whole scene renders as a black frame with no other symptom.
for (const [uniform, param] of declared) {
if (CONTRACT_UNIFORMS.has(uniform)) {
fail(`${id}: param '${param}' uses '${uniform}', which the shader ` +
`contract already declares — pick another 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);