diff --git a/flow-state/PLAN.md b/flow-state/PLAN.md index b78a4b6..3a5ef73 100644 --- a/flow-state/PLAN.md +++ b/flow-state/PLAN.md @@ -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 diff --git a/flow-state/README.md b/flow-state/README.md new file mode 100644 index 0000000..5f300a4 --- /dev/null +++ b/flow-state/README.md @@ -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. diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js index 65ae6a2..7e7b651 100644 --- a/flow-state/src/Show.js +++ b/flow-state/src/Show.js @@ -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); diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index e014f71..131741b 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -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'); diff --git a/flow-state/src/checks/phase2.js b/flow-state/src/checks/phase2.js index 7f23d61..54ef6d7 100644 --- a/flow-state/src/checks/phase2.js +++ b/flow-state/src/checks/phase2.js @@ -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 { diff --git a/flow-state/src/checks/phase7.js b/flow-state/src/checks/phase7.js new file mode 100644 index 0000000..b55ab65 --- /dev/null +++ b/flow-state/src/checks/phase7.js @@ -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 }); diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js index 507acbc..5812e50 100644 --- a/flow-state/src/engine/Compositor.js +++ b/flow-state/src/engine/Compositor.js @@ -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; diff --git a/flow-state/src/engine/Engine.js b/flow-state/src/engine/Engine.js index c84c365..69e3be5 100644 --- a/flow-state/src/engine/Engine.js +++ b/flow-state/src/engine/Engine.js @@ -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++) { diff --git a/flow-state/src/engine/Renderer.js b/flow-state/src/engine/Renderer.js index 5d66050..6c41059 100644 --- a/flow-state/src/engine/Renderer.js +++ b/flow-state/src/engine/Renderer.js @@ -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; diff --git a/flow-state/src/export/Exporter.js b/flow-state/src/export/Exporter.js index b67161b..6414d03 100644 --- a/flow-state/src/export/Exporter.js +++ b/flow-state/src/export/Exporter.js @@ -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) { diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index 3f3b10d..715bfa7 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -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 = [{ diff --git a/flow-state/src/scenes/layers3d/particles.js b/flow-state/src/scenes/layers3d/particles.js index 2bcebb7..1909885 100644 --- a/flow-state/src/scenes/layers3d/particles.js +++ b/flow-state/src/scenes/layers3d/particles.js @@ -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 }, diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index be23bb4..f6eda68 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -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 = []; diff --git a/flow-state/src/scenes/shader/curl-flow.js b/flow-state/src/scenes/shader/curl-flow.js new file mode 100644 index 0000000..9420bdb --- /dev/null +++ b/flow-state/src/scenes/shader/curl-flow.js @@ -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; diff --git a/flow-state/src/scenes/shader/horizon-lines.js b/flow-state/src/scenes/shader/horizon-lines.js new file mode 100644 index 0000000..41cc064 --- /dev/null +++ b/flow-state/src/scenes/shader/horizon-lines.js @@ -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; diff --git a/flow-state/src/scenes/shader/kaleido-tunnel.js b/flow-state/src/scenes/shader/kaleido-tunnel.js new file mode 100644 index 0000000..df0b75f --- /dev/null +++ b/flow-state/src/scenes/shader/kaleido-tunnel.js @@ -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; diff --git a/flow-state/src/scenes/shader/metaballs.js b/flow-state/src/scenes/shader/metaballs.js new file mode 100644 index 0000000..365a94f --- /dev/null +++ b/flow-state/src/scenes/shader/metaballs.js @@ -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; diff --git a/flow-state/src/scenes/shader/moire-grid.js b/flow-state/src/scenes/shader/moire-grid.js new file mode 100644 index 0000000..6dbc2ee --- /dev/null +++ b/flow-state/src/scenes/shader/moire-grid.js @@ -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; diff --git a/flow-state/src/scenes/shader/plasma-bloom.js b/flow-state/src/scenes/shader/plasma-bloom.js new file mode 100644 index 0000000..927b17b --- /dev/null +++ b/flow-state/src/scenes/shader/plasma-bloom.js @@ -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; diff --git a/flow-state/src/scenes/shader/ridge-terrain.js b/flow-state/src/scenes/shader/ridge-terrain.js new file mode 100644 index 0000000..738e7ee --- /dev/null +++ b/flow-state/src/scenes/shader/ridge-terrain.js @@ -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; diff --git a/flow-state/src/scenes/shader/scan-tear.js b/flow-state/src/scenes/shader/scan-tear.js new file mode 100644 index 0000000..85b1e87 --- /dev/null +++ b/flow-state/src/scenes/shader/scan-tear.js @@ -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; diff --git a/flow-state/src/scenes/shader/slow-orb.js b/flow-state/src/scenes/shader/slow-orb.js new file mode 100644 index 0000000..11b8002 --- /dev/null +++ b/flow-state/src/scenes/shader/slow-orb.js @@ -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; diff --git a/flow-state/src/scenes/shader/spectrum-sculpture.js b/flow-state/src/scenes/shader/spectrum-sculpture.js new file mode 100644 index 0000000..7d1eb5f --- /dev/null +++ b/flow-state/src/scenes/shader/spectrum-sculpture.js @@ -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; diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index 362bdd1..c06c1a3 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -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);