diff --git a/flow-state/EPIC-3.md b/flow-state/EPIC-3.md new file mode 100644 index 0000000..c736701 --- /dev/null +++ b/flow-state/EPIC-3.md @@ -0,0 +1,330 @@ +# Epic 3 — the song brings its own cast + +Epic 2 asked whether the output was worth watching. This one asks a narrower question that +the variety harness has now answered numerically, twice, with the same result: + +> Two different songs are about as different from each other as one video is from itself +> five minutes later. + +Everything below follows from taking that seriously. + +--- + +## 1. What the measurements actually say + +From `checks.html?variety=1` and `?songs=1`, both run against the song bank: + +| | seed variety | song variety | +|---|---|---| +| floor — one video against itself | 0.143 | 0.145 | +| observed — two seeds / two songs | 0.145 | 0.151 | +| colour block | 111% of reference | 92% | +| coupling — musical distance → visual distance | — | no signal at n=21 | + +Two numbers matter more than the rest. + +**The floor is enormous.** A video differs from itself, across its own sections, by nearly as +much as it differs from a video of another song. That is not a subtle failure — it says the +thing we are shipping has no identity. A viewer cannot recognise a video as *this song's +video*, because its own opening and its own drop have less in common than its drop has with +some other track's drop. + +**Colour is the only register doing work.** It scores at or above the reference while every +structural register sits below. The generator varies the palette and shuffles which shader +runs; it does not vary *what is on screen*. + +The last round of fixes (per-track casting pools, soft signature weighting, motion character) +raised every raw structural distance — motion by 57% — and made all 61 scenes reachable. It +did not close the gap, because it raised the floor by as much as it raised the ceiling. More +scenes reachable means more rotation *within* a video too. Pulling harder on the same lever +will keep doing that. + +**The lever is wrong, not weak.** Scene choice is a choice of *container*. Two containers +showing the same nothing look alike, and the same container showing two different things +looks different. We have been varying containers. + +--- + +## 2. The inversion + +Today a scene is self-contained. `metaballs.js` knows how to make metaballs and needs nothing +from the track except a palette and eighteen `u_sig*` uniforms it is free to ignore — and +most do, because they are modifiers on an image the shader already had. + +The proposal turns that around. + +> A song generates an **identity**: a small set of design decisions that produce concrete, +> reusable **artifacts**. Visualizers are **stages** that know how to arrange, draw and +> animate artifacts they are given, and a song picks two to five of them. A stage with no +> artifacts has no image. + +The comic-book framing is the useful one. A comic is not held together by its panel layouts. +It is held together by the fact that the same characters, drawn in the same hand, keep walking +through it. Change the panel layouts and it is still recognisably the same comic; change the +characters and it is a different book even if every panel is laid out identically. + +Panel layouts are what we have been varying. + +The hard rule that makes this work, and the one thing that cannot be compromised: + +> **An artifact is content a stage could not have invented for itself.** If a stage renders +> acceptably when the artifact is replaced by a default, the artifact is a modifier and it +> will be ignored exactly the way `u_sigSides` is ignored today. + +--- + +## 3. The five registers + +An identity is decided before any stage is cast, in five registers plus a timeline. Each +register is a set of *decisions*; each decision produces *artifacts* that stages consume. + +### CAST — who is on screen + +Two to four members, each with a role. Roles matter more than counts: a cast where everyone +is equal reads as wallpaper. + +- **protagonist** — the form that carries most screen time, usually large and few. +- **chorus** — many small copies of a second form; the texture of the piece. +- **antagonist** — optional, and only present in some identities. The thing that interrupts: + a form that does not belong to the same family and shows up on transients. + +Each member is one of a few *kinds*, and the kind determines what artifact it bakes: + +| kind | what it is | artifact produced | +|---|---|---| +| `glyph` | a closed silhouette | SDF baked to a texture: sides, corner radius, elongation, notch depth/count, hollow ratio, aspect | +| `filament` | a line or ribbon | thickness profile along its length, taper, waviness, dash pattern, end caps | +| `field` | an unbounded surface | cell structure (voronoi / reaction / flow / weave), scale, anisotropy, contrast | +| `aperture` | a hole or window — a negative character | the same SDF machinery, used as a mask | + +`shape.sides` / `roundness` / `elongation` already exist in `Personality.js` and are the seed +of this. The difference is that today they are numbers a shader may consult, and here they are +baked into an actual distance field that a stage *draws*. A stage that draws elements draws +**this** form, or it does not get to be in this video. + +### INK — how they are drawn + +The hand. Same cast, different ink, is a different book. + +- **weight** — hairline, medium, heavy, variable-width. +- **edge** — hard vector, soft glow, dry brush, chalk, aliased/pixelated. +- **fill** — flat, gradient ramp, hatch, stipple, halftone dots, empty (outline only). +- **outline** — none, thin, heavy, double-struck, offset (misregistered). +- **value structure** — high-key, low-key, two-tone posterised, full range. + +Artifacts: a **stroke profile** (a small 1D LUT of width and alpha across an edge), a **fill +texture** (hatch/stipple/halftone tile, generated), and a **value curve** (1D LUT). All three +are cheap textures every stage samples the same way. This is the register that most cheaply +makes two videos unmistakably different, because it changes every pixel of every stage at +once — and unlike a palette swap it changes *structure*, so the harness will see it. + +### STAGING — where they are placed + +- **lattice** — grid, radial, spiral, scattered (poisson), stacked/strata, horizon-anchored, + packed (apollonian-ish). +- **scale distribution** — uniform, few-large-many-small (power law), bimodal. +- **depth** — flat, layered parallax, true perspective. +- **occupancy** — how much of the frame is used, and whether the centre or the edges carry it. + +Artifact: a **point set** with per-point scale, rotation and depth — generated once per song, +possibly a few hundred entries in a texture. Stages that place things place them *here*. + +This is the register that fixes the `layout` block, which has been the weakest structural +number in every run. It is weak because every scene composes itself and they all converge on +"interesting thing, middle of frame". + +### CHOREOGRAPHY — how they move + +- **locomotion** — drift, orbit, pulse-in-place, march, tumble, swarm, fall, breathe. +- **timing** — continuous, beat-quantised, swung, stuttered, triggered-and-decay. +- **coherence** — do cast members move together or independently? +- **response map** — which audio feature drives which property. This is currently the + `reactive` block on each scene, decided by the scene author. It should be decided by the + identity, so a song has *one* idea about what a transient does to the picture. + +Artifacts: a **motion path** (parametric curve or short keyframe list) and a **response +table**. The existing `motion.stillness` / `churn` characters are the first two knobs of this. + +### EFFECTS — what happens to the whole frame + +Mostly exists (`post`, `feedback`, `grain`). Worth adding the comic vocabulary, because it is +event-driven rather than constant and events are what the current output lacks: + +- speed lines and impact bursts on transients +- registration offset / misprint on a drop +- screen tone and halftone as a *treatment* rather than as two separate scenes +- panel splits — the frame divided, two stages visible at once +- ghosting and echo tied to the beat grid rather than to a decay constant + +### BEATS — what happens when + +The narrative layer, and the one that turns a set of decisions into an authored piece: + +- **entrance** — the cast does not all arrive at once. The chorus enters on the first build. +- **escalation** — cast count, density and ink weight climb across the track. +- **reduction** — a breakdown drops to the protagonist alone, held, on an empty stage. +- **payoff** — the antagonist appears exactly once, at the biggest moment. + +This is the register the current system has nothing at all for, and it is why five minutes +feels long. + +--- + +## 4. What an artifact is, technically + +Everything above has to survive contact with a fragment-shader pipeline. Four transport +mechanisms cover all of it: + +1. **SDF atlas texture** — glyphs and apertures baked once at load into an R8 or RG16F + texture. New engine capability: generating and uploading a texture. Modest work; the + renderer already manages framebuffers. +2. **1D LUT textures** — stroke profiles, value curves, palette ramps. Trivial. +3. **Tile textures** — hatch, stipple, halftone, field structure. Generated procedurally into + a texture once, then sampled — which also makes them cheaper than computing them per pixel + per frame, as scenes do today. +4. **Uniform blocks** — point sets, response tables, motion coefficients. The point set may + want a data texture if it exceeds a few dozen entries. + +All four are *data*. None of them is code. That is what makes an artifact reusable across +stages that have never heard of each other, and it is what makes the identity serialisable — +which the editor, the preset system and the check harness all need. + +--- + +## 5. Stages replace scenes + +A stage declares what it consumes and what it can express: + +```js +export const swarmStage = { + name: 'Swarm', + consumes: ['glyph', 'pointSet', 'strokeProfile'], // hard requirements + optional: ['fillTile', 'aperture'], + expresses: { + locomotion: ['swarm', 'drift', 'orbit'], + depth: ['flat', 'parallax'], + scaleDistribution: ['power', 'uniform'], + }, + params: { /* as today */ }, +}; +``` + +Casting becomes a **matching** problem instead of a trait-filter problem: which stages can +express *this* identity's choreography and staging, given the artifacts it produced? That is a +much better question than the one `sceneHonours` asks, because it is about capability rather +than about which traits a scene author happened to list — the accident that put eleven +over-declared scenes in half of all videos. + +Two to five stages per song, as proposed, is the right number and worth defending: it is +enough to cut between so the video is not static, and few enough that the cast is recognisable +in all of them. It should also be *derived*, not fixed — a long dynamic track earns five, a +four-minute ambient piece wants two. + +--- + +## 6. Mapping functions + +"Song + seed picks one function" is the generalisation of the current `director`, and it is +the right place for it. A mapping function is a *style of interpretation*: given an identity, +how do artifacts bind to stages? + +- **literal** — protagonist is drawn plainly and large; one stage per section kind. +- **abstract** — the cast is never shown directly, only as apertures, shadows and negative + space. Same artifacts, unrecognisable result. +- **escalating** — cast count and ink weight climb monotonically; the video is one long build. +- **antagonistic** — two members are in visual conflict all the way through; stages are + chosen in pairs that disagree. +- **serial** — each section is a variation on the previous one rather than a cut to something + new; stages are chosen for adjacency on the library's structural map. + +Directors already prove the mechanism works and that it is worth having several. Five +interpretations over a rich identity is a far larger space than five family orderings over a +fixed library. + +--- + +## 7. Why this should move the numbers + +Falsifiable predictions, so this can be checked rather than believed: + +- **Floor drops sharply.** Two to five stages sharing one cast, one ink and one point set will + produce probes that agree on `scale`, `texture` and `orient` across a video's own length. + This is the single largest available win — the floor is currently 0.143 out of an observed + 0.145. +- **Observed rises.** Artifacts are content. Two songs differ in what is on screen, not in + which of 61 shaders is running. +- **Coupling gets somewhere to attach.** Artifact generation is a natural place to read the + audio, and unlike trait-weight tilting it produces a *continuous* mapping: a slightly + brighter track gets a slightly sharper ink, not a different scene. +- **Colour stops dominating** — not by dialling the palette back, but because the structural + registers finally vary as much as it does. +- **The structural twins stop mattering.** `Tide Rings ≈ Quasicrystal ≈ Classic Wave` are + twins because they all draw their own generic content. Give them different casts and they + are different images. +- **The ceiling problem may dissolve.** Three constructions have failed because any reference + that restricts casting also flattens the reference's own richness. With an identity layer + there is a much better reference available: *the same song with a different identity*. + +--- + +## 8. What happens to the 61 scenes + +The library is real work and most of it survives — but not untouched, and pretending otherwise +would be the way this epic quietly fails. + +- **Natural stages** (~20): anything that already places discrete elements — `metaballs`, + `voronoi-shatter`, `isometric-blocks`, `scale-mosaic`, `firefly-drift`, `floating-geometry`. + These want a glyph and a point set and mostly have parameters for both already. +- **Natural fields** (~15): `curl-flow`, `turing-bloom`, `mycelium-web`, `plasma-bloom`. These + become `field` artifact *producers* as much as consumers — a field character can be baked + from them and then sampled by other stages. +- **Natural treatments** (~10): `halftone-misprint`, `analog-wow`, `scan-tear`, `signal-decay`, + `time-smear`. These are ink and effects, not stages. Promoting them out of the scene library + and into the identity is a strict improvement — they are currently competing for screen time + with actual images. +- **Hard cases** (~15): scenes whose whole identity is one fixed image — `apollonian-gasket`, + `truchet-fold`, `quasicrystal`. Either they take a glyph as their repeating unit, which is + usually a small change and a large payoff, or they stay as legacy self-contained scenes with + a lower casting weight. + +Incremental path: artifacts are *optional* at first, with neutral defaults, so every existing +scene keeps working. A scene opts in by declaring `consumes`. The variety harness then answers +the only question that matters — does a video built from opted-in stages score better than one +built from legacy scenes? Measure that on five stages before touching the other fifty-six. + +--- + +## 9. Risks + +- **Homogenisation within a song.** Sharing a cast across stages is the point, but overdone it + makes every shot the same shot. The floor is currently far too high; it is possible to + overshoot into far too low, and a video with no internal variation is boring in a new way. + The harness measures both directions, so this is checkable — but nobody will check it unless + the target is stated as a *range* rather than "lower". +- **The artifact contract is a real API.** `shader-contract.js` and `lint-scenes` need to + understand `consumes`/`expresses`, and getting that wrong makes every scene harder to write. + `HOWTO-visualizers.md` is currently a good document because the contract is simple. +- **SDF baking is new machinery** in a renderer that has so far only ever managed + framebuffers. +- **Migration is the real cost.** Sixty-one scenes is a lot of surface, and a half-migrated + library where some stages honour the cast and some do not is *worse* than either end state, + because the ones that ignore it read as the shot filmed somewhere else. + +--- + +## 10. The smallest experiment worth running first + +One artifact, three stages, one measurement. Do not build the whole identity layer on a +prediction. + +1. Implement `glyph` only: an SDF baked from `shape.sides/roundness/elongation/notches` into a + texture, plus the plumbing to bind it. +2. Convert three existing element-placing scenes to consume it — `metaballs`, + `floating-geometry`, `scale-mosaic` are the least invasive. +3. Generate a look forced to those three stages, and run the seed and song variety tests + against it. + +The prediction is specific: **the floor should drop and the `texture` and `orient` blocks +should agree across probes within a video**, while the between-song distance holds or rises. +If the floor does not move, sharing a cast is not sufficient and the ink register is where the +work actually is — which is worth knowing after two days rather than after two months. diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index 9a0824b..47c9d8d 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -1,6 +1,8 @@ import { runAll, summarize, allChecks } from './framework.js'; import { runSceneGate } from './scene-gate.js'; -import { varietyReportLines, songVarietyReportLines } from './variety/print.js'; +import { + varietyReportLines, songVarietyReportLines, experimentReportLines, +} from './variety/print.js'; // Registering a phase's checks is a side effect of importing it. import './phase0.js'; @@ -98,6 +100,24 @@ async function main() { return; } + // The Epic 3 A/B: stages against comparable legacy scenes. + // checks.html?experiment=1 + if (params.get('experiment')) { + summaryEl.textContent = 'epic 3 experiment: rendering three arms across the song bank…'; + const started = Date.now(); + const { lines, ok, headline } = await experimentReportLines({ + songs: Number(params.get('count')) || 6, + probes: Number(params.get('probes')) || 4, + }); + out.innerHTML = `
${lines.join('\n')}`;
+ summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`;
+ summaryEl.className = ok ? 'ok' : 'bad';
+ window.__CHECKS__ = { experiment: true, ok, lines };
+ window.__CHECKS_DONE__ = true;
+ console.log('[experiment]\n' + lines.join('\n'));
+ return;
+ }
+
const phaseArg = params.get('phase');
const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
const skipSlow = params.get('slow') !== '1';
diff --git a/flow-state/src/checks/variety/print.js b/flow-state/src/checks/variety/print.js
index 4c66a0d..782113a 100644
--- a/flow-state/src/checks/variety/print.js
+++ b/flow-state/src/checks/variety/print.js
@@ -199,3 +199,77 @@ export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) {
return { lines, ok, headline };
}
+
+/**
+ * The Epic 3 experiment: does a video built from STAGES beat one built from
+ * comparable legacy scenes?
+ *
+ * Same songs, same instrument, same number of scenes available — the only
+ * difference is whether those scenes draw the song's cast in the song's ink, or
+ * their own content. Three arms, because two would not distinguish "stages are
+ * better" from "a small pool is better".
+ */
+export async function experimentReportLines({ songs = 6, probes = 4 } = {}) {
+ const { scenes } = await import('../../scenes/registry.js');
+ const byName = (n) => scenes.find((m) => m.name === n);
+
+ const arms = [
+ {
+ label: 'STAGES — draw the song\'s cast and ink',
+ pool: ['Procession', 'Constellation', 'Soloist', 'Swarm'].map(byName),
+ },
+ {
+ label: 'LEGACY — four comparable element-placing scenes',
+ pool: ['Floating Geometry', 'Firefly Drift', 'Scale Mosaic', 'Metaballs'].map(byName),
+ },
+ {
+ label: 'FULL — the unrestricted generator, for reference',
+ pool: null,
+ },
+ ];
+
+ const lines = [];
+ lines.push('EPIC 3 EXPERIMENT — container vs content');
+ lines.push('');
+ lines.push(' Same songs, same instrument, same pool size. The stages own arrangement');
+ lines.push(' and nothing else: what is on screen comes from the song. The legacy arm');
+ lines.push(' is four scenes that each invent their own content.');
+ lines.push('');
+ lines.push(' Lower floor = a video that looks like itself over its own length.');
+ lines.push(' Higher observed = two songs that look like different work.');
+ lines.push('');
+
+ const results = [];
+ for (const arm of arms) {
+ await new Promise((r) => setTimeout(r, 0));
+ const r = measureSongVariety({ songs, probes, pool: arm.pool });
+ results.push({ arm, r });
+ }
+
+ lines.push(' arm floor observed spread ratio');
+ lines.push(' ' + '-'.repeat(78));
+ for (const { arm, r } of results) {
+ const spread = r.observed - r.floor;
+ lines.push(` ${arm.label.padEnd(44)}${r.floor.toFixed(4)} ${r.observed.toFixed(4)}` +
+ ` ${spread >= 0 ? '+' : ''}${spread.toFixed(4)} ${(r.observed / r.floor).toFixed(3)}`);
+ }
+ lines.push('');
+
+ for (const { arm, r } of results) {
+ lines.push(` ${arm.label}`);
+ for (const [name, b] of Object.entries(r.byBlock)) {
+ lines.push(` ${name.padEnd(8)} between ${b.between.toFixed(3)}`);
+ }
+ lines.push(` coupling ${r.coupling.toFixed(2)}`);
+ const worst = r.pairs[0];
+ lines.push(` closest pair ${worst.a} ≈ ${worst.b} at ${worst.total.toFixed(3)}`);
+ lines.push('');
+ }
+
+ const stages = results[0].r, legacy = results[1].r;
+ const ok = (stages.observed - stages.floor) > (legacy.observed - legacy.floor);
+ const headline = `stages spread ${(stages.observed - stages.floor).toFixed(4)} ` +
+ `vs legacy ${(legacy.observed - legacy.floor).toFixed(4)} — ` +
+ (ok ? 'the inversion helps' : 'no improvement');
+ return { lines, ok, headline };
+}
diff --git a/flow-state/src/checks/variety/report.js b/flow-state/src/checks/variety/report.js
index a1396af..570ef49 100644
--- a/flow-state/src/checks/variety/report.js
+++ b/flow-state/src/checks/variety/report.js
@@ -40,10 +40,11 @@ const RENDER = { width: 160, height: 90 };
/** Signature for one seed, rendered through the whole normal pipeline. */
export function signatureForSeed(track, seed, options = {}) {
+ const { pool = null, ...rest } = options;
const show = new Show({ ...RENDER });
try {
- show.useTrack(track, generateLook(track, { seed: seed >>> 0 }));
- return videoSignature(show, options);
+ show.useTrack(track, generateLook(track, { seed: seed >>> 0, pool }));
+ return videoSignature(show, rest);
} finally {
show.dispose();
}
@@ -474,14 +475,14 @@ function spearman(xs, ys) {
* @param {object} options
* @returns {object} report
*/
-export function measureSongVariety({ songs = 6, probes = 5, refScenes = 4 } = {}) {
+export function measureSongVariety({ songs = 6, probes = 5, refScenes = 4, pool = null } = {}) {
const bank = songBank({ count: songs });
// The seed is derived from the audio in the real pipeline, so each song must
// get its own — deriving it from the name is the same relationship without
// needing the samples.
const sigs = bank.map((entry) =>
- signatureForSeed(entry.track, hashString(entry.name), { probes }));
+ signatureForSeed(entry.track, hashString(entry.name), { probes, pool }));
const floor = mean(sigs.map((s) => s.drift));
diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js
index f088bdc..42f609a 100644
--- a/flow-state/src/engine/Layer.js
+++ b/flow-state/src/engine/Layer.js
@@ -1,5 +1,8 @@
import * as THREE from 'three';
-import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS, SIGNATURE_UNIFORMS } from './shader-contract.js';
+import {
+ VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS,
+ SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS,
+} from './shader-contract.js';
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
import { clampValue } from '../params/schema.js';
@@ -40,7 +43,7 @@ export function buildShaderUniforms(module, baseParams, seed) {
u_hasPrev: { value: 0 },
};
for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
- for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
+ for (const [name, type] of Object.entries({ ...SIGNATURE_UNIFORMS, ...IDENTITY_UNIFORMS })) {
const v = NEUTRAL_UNIFORMS[name];
uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v };
}
@@ -93,7 +96,7 @@ export function setFrameUniforms(layer, renderer, target, ctx) {
}
const signature = signatureUniforms(layer.personality, layer.module);
- for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
+ for (const [name, type] of Object.entries({ ...SIGNATURE_UNIFORMS, ...IDENTITY_UNIFORMS })) {
const v = signature[name];
if (type === 'vec2') u[name].value.set(v[0], v[1]);
else u[name].value = v;
diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js
index 7d1d857..1267d10 100644
--- a/flow-state/src/engine/shader-contract.js
+++ b/flow-state/src/engine/shader-contract.js
@@ -89,6 +89,40 @@ export const SIGNATURE_UNIFORMS = {
u_sigFrameShift: 'vec2', // recentre, in scene units
};
+/**
+ * The song's CAST and INK — Epic 3's content and style artifacts.
+ *
+ * These differ from the signature uniforms above in kind, not degree. A
+ * signature uniform is a modifier on an image the shader already had, which is
+ * why a scene is free to ignore one. A cast uniform IS the image: a stage that
+ * ignores it has nothing to draw. See look/Identity.js.
+ */
+export const IDENTITY_UNIFORMS = {
+ u_castSides: 'float', // protagonist: 0 = round, else polygon sides
+ u_castRound: 'float',
+ u_castElong: 'float',
+ u_castTilt: 'float',
+ u_castNotchN: 'float', // notches cut into the boundary, 0 = none
+ u_castNotchD: 'float',
+ u_castHollow: 'float', // >0 makes it an annulus — a form with a hole
+
+ u_chorusSides: 'float', // the second member: a relative, not a stranger
+ u_chorusRound: 'float',
+ u_chorusElong: 'float',
+ u_chorusTilt: 'float',
+ u_chorusNotchN: 'float',
+ u_chorusNotchD: 'float',
+ u_chorusHollow: 'float',
+
+ u_inkWeight: 'float', // stroke width
+ u_inkEdge: 'float', // 0 = soft/airbrushed, 1 = hard vector
+ u_inkFill: 'float', // index into Identity.FILLS
+ u_inkHatchAngle: 'float',
+ u_inkHatchScale: 'float',
+ u_inkOutline: 'float', // 0..1 outline strength on top of the fill
+ u_inkPosterize: 'float', // 0 = off, else levels
+};
+
export const FRAME_UNIFORMS = [
'u_time', 'u_frame', 'u_progress', 'u_seed',
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
@@ -113,6 +147,8 @@ ${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
+${Object.entries(IDENTITY_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
+
uniform sampler2D u_prev;
uniform int u_hasPrev;
@@ -268,6 +304,99 @@ vec3 sigAir(vec3 col, vec2 p, float distance01) {
return col;
}
+// --- the cast --------------------------------------------------------------
+// The song's own forms. A stage that places discrete elements places THESE, and
+// that is what makes two stages in one video look like one video — and two
+// videos of different songs look like different work.
+
+/** Signed distance to a cast member, radius ~1 at size 1. */
+float castSDF(vec2 q, float sides, float rnd, float elong, float tilt,
+ float notchN, float notchD, float hollow) {
+ q = rot(tilt) * q;
+ q.x /= max(elong, 0.05);
+
+ float r = length(q);
+ float a = atan(q.y, q.x);
+
+ float d;
+ if (sides < 2.5) {
+ d = r - 1.0;
+ } else {
+ float seg = 6.28318530718 / sides;
+ float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
+ float poly = r * folded - cos(seg * 0.5);
+ d = mix(poly, r - 1.0, clamp(rnd, 0.0, 1.0));
+ }
+
+ // Notches scallop the boundary. Approximate as a radial perturbation — it
+ // is not a true distance any more, but every use here is a thresholded mask
+ // and the error is far below a pixel at the sizes these are drawn.
+ if (notchN > 0.5) d += notchD * cos(notchN * a);
+ // A hole through the middle. Cheap, and the single most recognisable thing
+ // a generated form can have.
+ if (hollow > 0.001) d = abs(d) - hollow * 0.35;
+ return d;
+}
+
+/** The protagonist, centred, radius ~1. */
+float castMain(vec2 q) {
+ return castSDF(q, u_castSides, u_castRound, u_castElong, u_castTilt,
+ u_castNotchN, u_castNotchD, u_castHollow);
+}
+
+/** The chorus member — many of these, small. */
+float castChorus(vec2 q) {
+ return castSDF(q, u_chorusSides, u_chorusRound, u_chorusElong, u_chorusTilt,
+ u_chorusNotchN, u_chorusNotchD, u_chorusHollow);
+}
+
+// --- the ink ---------------------------------------------------------------
+// How the cast is drawn. Changes every pixel of every stage at once, and does
+// it structurally rather than chromatically — which is the point, since colour
+// was already the only register doing any work.
+
+/** The fill treatment as a 0..1 coverage pattern. 1 everywhere when flat. */
+float inkPattern(vec2 uv) {
+ int mode = int(u_inkFill + 0.5);
+ if (mode == 2) { // hatch
+ vec2 h = rot(u_inkHatchAngle) * uv * u_inkHatchScale;
+ return smoothstep(0.3, 0.7, 0.5 + 0.5 * sin(h.y));
+ }
+ if (mode == 3) { // stipple
+ return step(0.42, hash12(floor(uv * u_inkHatchScale * 2.0)));
+ }
+ if (mode == 4) { // halftone
+ vec2 g = fract(uv * u_inkHatchScale * 0.25) - 0.5;
+ return smoothstep(0.38, 0.28, length(g));
+ }
+ return 1.0;
+}
+
+/**
+ * Ink coverage for a signed distance: the fill in the track's treatment, plus
+ * its outline. The 'hollow' fill treatment draws the outline only.
+ */
+float inkMask(float d, vec2 uv) {
+ float soft = mix(0.03, 0.0015, clamp(u_inkEdge, 0.0, 1.0));
+ int mode = int(u_inkFill + 0.5);
+
+ float fillA = smoothstep(soft, -soft, d) * inkPattern(uv);
+ if (mode == 5) fillA = 0.0;
+
+ float w = 0.004 + u_inkWeight * 0.055;
+ float strength = (mode == 5) ? 1.0 : u_inkOutline;
+ float line = smoothstep(w + soft, w - soft, abs(d)) * strength;
+
+ return clamp(max(fillA, line), 0.0, 1.0);
+}
+
+/** The track's value structure. Off unless the identity asked for it. */
+vec3 inkValue(vec3 col) {
+ if (u_inkPosterize < 1.5) return col;
+ float n = u_inkPosterize;
+ return floor(col * n + 0.5) / n;
+}
+
vec3 prev(vec2 uv) {
if (u_hasPrev == 0) return vec3(0.0);
return texture2D(u_prev, uv).rgb;
diff --git a/flow-state/src/look/Identity.js b/flow-state/src/look/Identity.js
new file mode 100644
index 0000000..d87d899
--- /dev/null
+++ b/flow-state/src/look/Identity.js
@@ -0,0 +1,179 @@
+// The song's IDENTITY: the content it is made of, and the hand it is drawn in.
+//
+// This is the Epic 3 inversion. A scene used to be self-contained — it knew how
+// to make metaballs and needed nothing from the track but a palette — and the
+// personality could only ever be a set of modifiers layered on an image the
+// shader already had. Measured, that is why two songs came out about as
+// different from each other as one video is from itself: the generator was
+// varying the CONTAINER and never the content.
+//
+// So the song generates content first, and a stage is a way of arranging
+// content it is given.
+//
+// CAST what is on screen. A protagonist and a chorus, as actual forms with
+// sides, notches and hollows, not as hints a shader may consult.
+// INK how they are drawn. Weight, edge, fill treatment, outline,
+// posterisation — the hand, which changes every pixel of every stage
+// at once and does it structurally rather than chromatically.
+//
+// Both travel as uniforms, which makes them data rather than code and means any
+// stage can consume them without knowing anything about any other stage. The
+// rule that keeps this honest, from EPIC-3.md §2:
+//
+// An artifact is content a stage could not have invented for itself.
+//
+// A stage that renders acceptably with the cast replaced by a default is using
+// it as a modifier and will drift back into ignoring it, exactly the way most of
+// the library ignores u_sigSides today.
+
+/** Fill treatments, as the shader's `u_inkFill` index. */
+export const FILLS = ['flat', 'ramp', 'hatch', 'stipple', 'halftone', 'hollow'];
+
+const clamp01 = (x) => Math.max(0, Math.min(1, x));
+
+/**
+ * One cast member.
+ *
+ * `notches` and `hollow` are what take this past the existing signature form.
+ * A rounded pentagon is a shape; a pentagon with six notches cut into it and a
+ * hole through the middle is a CHARACTER — recognisable across stages, which is
+ * the entire point of sharing it.
+ */
+function castMember(rng, { angular, intricate, solid }) {
+ const sides = rng.pickWeighted(
+ [0, 3, 4, 5, 6, 8],
+ [3 + (1 - angular) * 5, 1 + angular * 2, 2 + angular * 2,
+ 1 + angular * 2, 1.5 + angular * 2, 0.5 + angular * 1.5]);
+ return {
+ sides,
+ round: clamp01(rng.range(0.05, 0.55) * (1.3 - angular * 0.6)),
+ elong: rng.range(0.8, 1.5),
+ tilt: rng.range(0, Math.PI),
+ // A notched form reads as made rather than as found.
+ notchCount: rng.bool(0.25 + intricate * 0.5)
+ ? rng.pickWeighted([3, 4, 5, 6, 8, 12], [2, 3, 2, 3, 2, 1]) : 0,
+ notchDepth: rng.range(0.06, 0.1 + intricate * 0.22),
+ // Hollow forms are the difference between a blob library and a
+ // recognisable one, and they cost nothing to draw.
+ hollow: rng.bool(0.45 - solid * 0.3) ? rng.range(0.15, 0.6) : 0,
+ };
+}
+
+/**
+ * @param {object} summary FeatureTrack summary
+ * @param {Rng} rng
+ * @param {number} sections
+ */
+export function generateIdentity(summary, rng, sections = 4) {
+ const bright = summary.meanCentroid ?? 0.5;
+ const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);
+ const fast = clamp01(((summary.bpm ?? 120) - 80) / 80);
+ const dynamic = clamp01(summary.dynamicRange ?? 0.5);
+ const busy = clamp01((sections - 2) / 5);
+
+ // The audio sets the centre of each decision and the seed picks within it —
+ // the same arrangement the personality values use, and for the same reason:
+ // deriving outright would buy coupling by destroying seed variety.
+ const angular = clamp01(noisy * 0.6 + fast * 0.3 + rng.range(-0.25, 0.25));
+ const intricate = clamp01(busy * 0.5 + bright * 0.3 + rng.range(-0.3, 0.3));
+ const solid = clamp01(0.5 - dynamic * 0.4 + rng.range(-0.25, 0.25));
+
+ const protagonist = castMember(rng.fork('protagonist'), { angular, intricate, solid });
+ // The chorus is a relative of the protagonist, not a stranger: it shares the
+ // family and differs in proportion, which is what makes a frame full of them
+ // read as one production rather than as two libraries stacked.
+ const chorusRng = rng.fork('chorus');
+ const chorus = {
+ ...castMember(chorusRng, { angular, intricate, solid }),
+ sides: chorusRng.bool(0.6) ? protagonist.sides : chorusRng.pick([0, 3, 4, 6]),
+ tiltOffset: chorusRng.range(-0.6, 0.6),
+ };
+
+ const ink = {
+ // Line weight and edge hardness: the two decisions a viewer reads as
+ // "what this was drawn with".
+ weight: clamp01(0.15 + noisy * 0.35 + rng.range(-0.15, 0.35)),
+ edge: clamp01(0.3 + bright * 0.3 + rng.range(-0.3, 0.4)),
+ fill: rng.pickWeighted(FILLS, [
+ 3, // flat
+ 3, // ramp
+ 1 + intricate * 3, // hatch
+ 1 + noisy * 2.5, // stipple
+ 1 + bright * 2.5, // halftone
+ 1 + (1 - solid) * 2, // hollow — outline only
+ ]),
+ hatchAngle: rng.range(0, Math.PI),
+ hatchScale: rng.range(40, 160) * (0.6 + intricate * 0.9),
+ outline: rng.bool(0.45 + angular * 0.3) ? rng.range(0.3, 1) : 0,
+ // Posterisation is a value-structure decision, and it is the cheapest
+ // way to make one track look printed and another look lit.
+ posterize: rng.bool(0.3) ? rng.int(3, 6) : 0,
+ };
+
+ return { cast: { protagonist, chorus }, ink, character: { angular, intricate, solid } };
+}
+
+/** Neutral values, so a layer built without an identity renders as it always did. */
+export const NEUTRAL_IDENTITY_UNIFORMS = {
+ u_castSides: 0, u_castRound: 0.25, u_castElong: 1, u_castTilt: 0,
+ u_castNotchN: 0, u_castNotchD: 0, u_castHollow: 0,
+ u_chorusSides: 0, u_chorusRound: 0.25, u_chorusElong: 1, u_chorusTilt: 0,
+ u_chorusNotchN: 0, u_chorusNotchD: 0, u_chorusHollow: 0,
+ u_inkWeight: 0.3, u_inkEdge: 0.5, u_inkFill: 0, u_inkHatchAngle: 0,
+ u_inkHatchScale: 80, u_inkOutline: 0, u_inkPosterize: 0,
+};
+
+/**
+ * @param {object} identity
+ * @param {object} shape the personality's signature form
+ *
+ * The protagonist's GEOMETRY is the signature form, read live rather than
+ * copied at generation time. The two were separate decisions in the first
+ * draft, which meant a track built on hexagons could have a round protagonist —
+ * the signature form said one thing and the thing actually on screen said
+ * another, and the shape trait stopped meaning anything for stages. The cast is
+ * the signature form made concrete: same sides, same rounding, same tilt, plus
+ * the notches and hollows that turn a shape into a character.
+ */
+export function identityUniforms(identity, shape = null) {
+ if (!identity) return { ...NEUTRAL_IDENTITY_UNIFORMS };
+ const { protagonist: p, chorus: b } = identity.cast;
+ const a = shape ? {
+ ...p,
+ sides: shape.sides,
+ round: shape.roundness,
+ elong: shape.elongation,
+ tilt: shape.tilt,
+ } : p;
+ const ink = identity.ink;
+ return {
+ u_castSides: a.sides, u_castRound: a.round, u_castElong: a.elong, u_castTilt: a.tilt,
+ u_castNotchN: a.notchCount, u_castNotchD: a.notchCount ? a.notchDepth : 0,
+ u_castHollow: a.hollow,
+
+ // The chorus stays a relative of the protagonist: it inherits the
+ // signature form's tilt and usually its sides.
+ u_chorusSides: b.sides, u_chorusRound: b.round, u_chorusElong: b.elong,
+ u_chorusTilt: a.tilt + b.tiltOffset,
+ u_chorusNotchN: b.notchCount, u_chorusNotchD: b.notchCount ? b.notchDepth : 0,
+ u_chorusHollow: b.hollow,
+
+ u_inkWeight: ink.weight, u_inkEdge: ink.edge,
+ u_inkFill: FILLS.indexOf(ink.fill),
+ u_inkHatchAngle: ink.hatchAngle, u_inkHatchScale: ink.hatchScale,
+ u_inkOutline: ink.outline, u_inkPosterize: ink.posterize,
+ };
+}
+
+const SHAPE_NAMES = { 0: 'round', 3: 'triangular', 4: 'square', 5: 'pentagonal', 6: 'hexagonal', 8: 'octagonal' };
+
+export function describeIdentity(identity) {
+ if (!identity) return 'no identity';
+ const { protagonist: a, chorus: b } = identity.cast;
+ const form = (m) => `${SHAPE_NAMES[m.sides] || `${m.sides}-sided`}` +
+ `${m.notchCount ? `/${m.notchCount}-notch` : ''}${m.hollow ? '/hollow' : ''}`;
+ const ink = identity.ink;
+ return `cast ${form(a)} + ${form(b)} · ink ${ink.fill}` +
+ `${ink.outline ? '+outline' : ''}${ink.posterize ? `/${ink.posterize}-tone` : ''}` +
+ ` w${ink.weight.toFixed(2)}`;
+}
diff --git a/flow-state/src/look/Personality.js b/flow-state/src/look/Personality.js
index d2641bc..c3d6208 100644
--- a/flow-state/src/look/Personality.js
+++ b/flow-state/src/look/Personality.js
@@ -38,6 +38,10 @@
// Everything here is seeded off the look seed, so a track's personality is as
// reproducible as everything else.
+import {
+ generateIdentity, identityUniforms, describeIdentity, NEUTRAL_IDENTITY_UNIFORMS,
+} from './Identity.js';
+
export const TRAITS = ['shape', 'camera', 'space', 'style'];
const clamp01 = (x) => Math.max(0, Math.min(1, x));
@@ -208,7 +212,12 @@ export function generatePersonality(summary, rng, countEligible = null, sections
const signature = pickSignature(rng, countEligible, signatureTilt(summary, sections));
- return { signature, shape, camera, space, style, motion, temperament };
+ // The song's cast and ink. Generated here so everything downstream — layer
+ // uniforms, the HUD, presets — reaches it the same way it reaches the rest
+ // of the production design. See look/Identity.js.
+ const identity = generateIdentity(summary, rng.fork('identity'), sections);
+
+ return { signature, shape, camera, space, style, motion, temperament, identity };
}
/**
@@ -309,10 +318,13 @@ export function signatureUniforms(personality, module = null) {
// every frame. Neutral here so a layer built without one is unframed.
u_sigFrameScale: 1,
u_sigFrameShift: [0, 0],
+
+ ...identityUniforms(personality.identity, personality.shape),
};
}
export const NEUTRAL_UNIFORMS = {
+ ...NEUTRAL_IDENTITY_UNIFORMS,
u_sigSides: 0,
u_sigRound: 0.25,
u_sigElong: 1,
@@ -344,6 +356,7 @@ export function describePersonality(personality) {
SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`,
];
if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`);
+ if (personality.identity) parts.push(describeIdentity(personality.identity));
if (personality.motion) {
const m = personality.motion;
parts.push(`${m.stillness > 0.6 ? 'still' : m.stillness < 0.3 ? 'restless' : 'moving'}` +
diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js
index a38c43e..36c1fd0 100644
--- a/flow-state/src/scenes/registry.js
+++ b/flow-state/src/scenes/registry.js
@@ -1,5 +1,12 @@
import { validateModule } from '../params/schema.js';
+// STAGES — scenes that draw the song's cast rather than their own content.
+// See scenes/stage/README.md and EPIC-3.md.
+import { procession } from './stage/procession.js';
+import { constellation } from './stage/constellation.js';
+import { soloist } from './stage/soloist.js';
+import { swarm } from './stage/swarm.js';
+
import { nebula } from './shader/nebula.js';
import { classicWave } from './shader/classic-wave.js';
import { floatingGeometry } from './shader/floating-geometry.js';
@@ -149,6 +156,13 @@ const MODULES = [
halftoneMisprint,
drosteFeedback,
analogWow,
+
+ // Stages. Registered alongside the scenes so every existing gate covers
+ // them; what makes them different is `consumes`, not where they live.
+ procession,
+ constellation,
+ soloist,
+ swarm,
];
const errors = [];
diff --git a/flow-state/src/scenes/stage/README.md b/flow-state/src/scenes/stage/README.md
new file mode 100644
index 0000000..ce90a56
--- /dev/null
+++ b/flow-state/src/scenes/stage/README.md
@@ -0,0 +1,23 @@
+# Stages
+
+A stage is a scene that draws the SONG's content rather than its own.
+
+An ordinary scene under `shader/` is self-contained: it knows how to make
+metaballs, and the personality can only modify an image it already had. That is
+why most of the library quietly ignores `u_sigSides` — the uniform is a hint,
+and a hint costs nothing to skip.
+
+A stage has no image of its own. It knows how to arrange, place and animate
+things, and what those things ARE comes from the track's identity: `castMain`
+and `castChorus` for the forms, `inkMask` and `inkValue` for the hand they are
+drawn in. See `look/Identity.js` and EPIC-3.md.
+
+The test of whether a file belongs here, from EPIC-3.md §2:
+
+> An artifact is content a stage could not have invented for itself.
+
+If it renders acceptably with a default cast, it is using the cast as a
+modifier, and it belongs under `shader/` instead.
+
+Stages declare `consumes` so the harness — and eventually the casting code — can
+tell the two kinds apart.
diff --git a/flow-state/src/scenes/stage/constellation.js b/flow-state/src/scenes/stage/constellation.js
new file mode 100644
index 0000000..dda9673
--- /dev/null
+++ b/flow-state/src/scenes/stage/constellation.js
@@ -0,0 +1,71 @@
+// STAGE: a constellation. The chorus, scattered, drifting, joined by the light
+// between them.
+//
+// Where Procession is regular, this is the scattered lattice: a point set that
+// wanders, with the song's second cast member at every node.
+
+export const constellation = {
+ name: 'Constellation',
+ family: 'minimal',
+ kind: 'fragment',
+ consumes: ['cast', 'ink'],
+ texture: 0.6,
+ traits: ['shape', 'camera', 'space', 'style'],
+
+ params: {
+ count: { type: 'int', range: [6, 40], default: 16, uniform: 'u_count', bias: 'density' },
+ size: { type: 'float', range: [0.02, 0.16],default: 0.06,uniform: 'u_size' },
+ drift: { type: 'float', range: [0.02, 0.6], default: 0.12,uniform: 'u_drift', bias: 'motion', rate: true },
+ spread: { type: 'float', range: [0.4, 1.4], default: 0.95,uniform: 'u_spread' },
+ link: { type: 'float', range: [0, 1], default: 0.35,uniform: 'u_link', bias: 'energy' },
+ twinkle: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_twinkle' },
+ palette: { type: 'palette', count: 4 },
+ },
+
+ reactive: {
+ twinkle: { feature: 'bandHigh', amount: 0.5 },
+ size: { feature: 'beat', amount: 0.12, response: 'spike' },
+ },
+
+ shader: `
+vec4 scene(vec2 uv, vec2 p) {
+ p = sigCamera(p);
+ float t = u_time * u_drift + u_seed;
+
+ vec3 col = pal(0) * 0.12;
+ col = sigAir(col, p, 0.5);
+
+ float glow = 0.0;
+
+ for (int i = 0; i < 40; i++) {
+ if (i >= u_count) break;
+ float fi = float(i);
+ vec2 h = hash22(vec2(fi + u_seed, fi * 1.7));
+
+ // A slow wander around a fixed home, so the constellation keeps its
+ // shape while nothing in it is ever quite still.
+ vec2 home = (h - 0.5) * 2.0 * u_spread;
+ vec2 pos = home + vec2(sin(t + h.x * 6.28), cos(t * 0.83 + h.y * 6.28)) * 0.08;
+
+ float pulse = 0.7 + 0.3 * sin(t * 2.0 + fi) * u_twinkle;
+ float size = u_size * (0.5 + h.x) * pulse;
+
+ vec2 q = (p - pos) / max(size, 1e-3);
+ float d = castChorus(q) * size;
+
+ float a = inkMask(d, uv);
+ col = mix(col, pal(i + 1), a);
+
+ // The light between them: what makes this a constellation and not a
+ // scatter of dots.
+ glow += u_link * 0.006 / (0.01 + abs(d));
+ }
+
+ col += pal(2) * glow * 0.35;
+ col += sigGrain(uv);
+ return vec4(inkValue(col), 1.0);
+}
+`,
+};
+
+export default constellation;
diff --git a/flow-state/src/scenes/stage/procession.js b/flow-state/src/scenes/stage/procession.js
new file mode 100644
index 0000000..53ded15
--- /dev/null
+++ b/flow-state/src/scenes/stage/procession.js
@@ -0,0 +1,74 @@
+// STAGE: a procession. The protagonist, repeated across a marching lattice.
+//
+// The stage decides the lattice, the march and the scale falloff. It does not
+// decide what is marching — that is the song's cast, and with a different
+// identity this is a completely different image rather than the same image in
+// another palette.
+
+export const procession = {
+ name: 'Procession',
+ family: 'structural',
+ kind: 'fragment',
+ consumes: ['cast', 'ink'],
+ texture: 0.4,
+ traits: ['shape', 'camera', 'style'],
+
+ params: {
+ columns: { type: 'int', range: [2, 9], default: 4, uniform: 'u_columns', bias: 'density' },
+ depth: { type: 'int', range: [2, 8], default: 4, uniform: 'u_depth', bias: 'density' },
+ march: { type: 'float', range: [0.05, 1.2],default: 0.3, uniform: 'u_march', bias: 'motion', rate: true },
+ size: { type: 'float', range: [0.1, 0.5], default: 0.28,uniform: 'u_size' },
+ stagger: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_stagger' },
+ recede: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_recede' },
+ spin: { type: 'float', range: [0, 1.5], default: 0.2, uniform: 'u_spin', rate: true },
+ palette: { type: 'palette', count: 5 },
+ },
+
+ reactive: {
+ size: { feature: 'beat', amount: 0.16, response: 'spike' },
+ recede: { feature: 'bandLow', amount: 0.25 },
+ },
+
+ shader: `
+vec4 scene(vec2 uv, vec2 p) {
+ p = sigCamera(p);
+ float t = u_time * u_march + u_seed;
+
+ vec3 col = mix(pal(0) * 0.16, pal(1) * 0.20, uv.y);
+
+ // Rows recede toward the horizon the track shares with every other scene.
+ float horizon = sigHorizonY();
+
+ for (int row = 0; row < 8; row++) {
+ if (row >= u_depth) break;
+ float fr = float(row);
+ // Rows further back are smaller and closer to the horizon.
+ float back = fr / max(float(u_depth), 1.0);
+ float scale = mix(1.0, 0.35, back * u_recede);
+ float y = mix(horizon - 0.9, horizon + 0.15, back);
+
+ for (int c = 0; c < 9; c++) {
+ if (c >= u_columns) break;
+ float fc = float(c);
+ float lane = (fc / max(float(u_columns) - 1.0, 1.0) - 0.5) * 2.4;
+
+ // The march: each row slides at its own pace, wrapping.
+ float phase = fract(t * (0.4 + back * 0.6) + fr * u_stagger + fc * 0.017);
+ float x = lane + (phase - 0.5) * 0.6;
+
+ vec2 q = (p - vec2(x, y)) / max(u_size * scale, 1e-3);
+ q = rot(t * u_spin + fr) * q;
+
+ float d = castMain(q) * u_size * scale;
+ float a = inkMask(d, uv);
+ col = mix(col, pal(row + c + 2), a * (0.35 + 0.65 * (1.0 - back)));
+ }
+ }
+
+ col += sigGrain(uv);
+ return vec4(inkValue(col), 1.0);
+}
+`,
+};
+
+export default procession;
diff --git a/flow-state/src/scenes/stage/soloist.js b/flow-state/src/scenes/stage/soloist.js
new file mode 100644
index 0000000..8f39395
--- /dev/null
+++ b/flow-state/src/scenes/stage/soloist.js
@@ -0,0 +1,60 @@
+// STAGE: the soloist. One member of the cast, large, centred, held.
+//
+// Every video needs a shot where you can actually see what the song is made of.
+// This is that shot: the protagonist at full size, breathing on the bar, with
+// its own echoes behind it.
+
+export const soloist = {
+ name: 'Soloist',
+ family: 'minimal',
+ kind: 'fragment',
+ consumes: ['cast', 'ink'],
+ texture: 0.5,
+ traits: ['shape', 'camera', 'style'],
+
+ params: {
+ size: { type: 'float', range: [0.25, 0.85], default: 0.45, uniform: 'u_size' },
+ echoes: { type: 'int', range: [0, 6], default: 2, uniform: 'u_echoes', bias: 'density' },
+ echoStep: { type: 'float', range: [0.05, 0.5], default: 0.18, uniform: 'u_echoStep' },
+ turn: { type: 'float', range: [0, 0.8], default: 0.12, uniform: 'u_turn', bias: 'motion', rate: true },
+ offset: { type: 'vec2', range: [-0.4, 0.4], default: [0, 0], uniform: 'u_offset' },
+ halo: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_halo', bias: 'energy' },
+ palette: { type: 'palette', count: 4 },
+ },
+
+ reactive: {
+ size: { feature: 'beat', amount: 0.1, response: 'spike' },
+ halo: { feature: 'loudness', amount: 0.4 },
+ },
+
+ shader: `
+vec4 scene(vec2 uv, vec2 p) {
+ p = sigCamera(p);
+ p = sigFolded(p);
+ float t = u_time * u_turn + u_seed;
+
+ vec3 col = mix(pal(0) * 0.14, pal(1) * 0.18, length(p) * 0.5);
+
+ // Echoes first, so the soloist reads in front of them.
+ for (int i = 6; i >= 1; i--) {
+ if (i > u_echoes) continue;
+ float fi = float(i);
+ float size = u_size * (1.0 + fi * u_echoStep);
+ vec2 q = rot(t * (1.0 - fi * 0.15)) * (p - u_offset) / max(size, 1e-3);
+ float d = castMain(q) * size;
+ float a = inkMask(d, uv) * (0.5 / fi);
+ col = mix(col, pal(i + 1), a);
+ }
+
+ vec2 q = rot(t) * (p - u_offset) / max(u_size, 1e-3);
+ float d = castMain(q) * u_size;
+ col = mix(col, pal(2), inkMask(d, uv));
+ col += pal(3) * u_halo * (1.0 - smoothstep(0.0, u_size * 1.5, abs(d))) * 0.5;
+
+ col += sigGrain(uv);
+ return vec4(inkValue(col), 1.0);
+}
+`,
+};
+
+export default soloist;
diff --git a/flow-state/src/scenes/stage/swarm.js b/flow-state/src/scenes/stage/swarm.js
new file mode 100644
index 0000000..3cf1066
--- /dev/null
+++ b/flow-state/src/scenes/stage/swarm.js
@@ -0,0 +1,63 @@
+// STAGE: a swarm. The chorus in numbers, flocking through a flow field.
+//
+// The stage owns the flocking; the song owns what is flocking. A swarm of
+// notched hexagons and a swarm of hollow circles are not the same video, and
+// with the old library they would have been the same scene.
+
+export const swarm = {
+ name: 'Swarm',
+ family: 'organic',
+ kind: 'fragment',
+ consumes: ['cast', 'ink'],
+ texture: 0.5,
+ traits: ['shape', 'camera', 'style'],
+
+ params: {
+ count: { type: 'int', range: [8, 48], default: 24, uniform: 'u_count', bias: 'density' },
+ size: { type: 'float', range: [0.02, 0.14],default: 0.05,uniform: 'u_size' },
+ speed: { type: 'float', range: [0.05, 0.9], default: 0.25,uniform: 'u_speed', bias: 'motion', rate: true },
+ cohesion:{ type: 'float', range: [0, 1], default: 0.5, uniform: 'u_cohesion' },
+ field: { type: 'float', range: [0.4, 3.0], default: 1.2, uniform: 'u_field' },
+ trail: { type: 'float', range: [0, 0.9], default: 0.3, uniform: 'u_trail', bias: 'energy' },
+ palette: { type: 'palette', count: 4 },
+ },
+
+ reactive: {
+ trail: { feature: 'flux', amount: 0.3 },
+ size: { feature: 'bandLow', amount: 0.2 },
+ },
+
+ shader: `
+vec4 scene(vec2 uv, vec2 p) {
+ p = sigCamera(p);
+ float t = u_time * u_speed + u_seed;
+
+ vec3 col = pal(0) * 0.13;
+
+ for (int i = 0; i < 48; i++) {
+ if (i >= u_count) break;
+ float fi = float(i);
+ vec2 h = hash22(vec2(fi + u_seed * 0.5, fi * 2.3));
+
+ // Each member rides the same flow field, which is what makes it a
+ // swarm rather than a scatter — cohesion decides how strictly.
+ vec2 seed = (h - 0.5) * 2.4;
+ vec2 flow = curl(seed * u_field + t * 0.15, t * 0.1);
+ vec2 pos = seed + flow * mix(0.05, 0.45, u_cohesion);
+ pos += vec2(sin(t * 0.7 + fi), cos(t * 0.6 + fi * 1.3)) * 0.06;
+
+ vec2 q = (p - pos) / max(u_size, 1e-3);
+ q = rot(atan(flow.y, flow.x)) * q; // they face where they go
+ float d = castChorus(q) * u_size;
+
+ col = mix(col, pal(i + 1), inkMask(d, uv));
+ col += pal(2) * u_trail * 0.004 / (0.02 + abs(d));
+ }
+
+ col += sigGrain(uv);
+ return vec4(inkValue(col), 1.0);
+}
+`,
+};
+
+export default swarm;
diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js
index 1df5ead..a789cae 100644
--- a/flow-state/tools/lint-scenes.js
+++ b/flow-state/tools/lint-scenes.js
@@ -90,11 +90,15 @@ const CONTRACT_UNIFORMS = new Set([
* cast in the hexagon video and be the one shot that looks filmed elsewhere.
* So the claim is machine-checked against the source rather than trusted.
*/
+// A stage that draws the song's CAST is expressing `shape` more completely than
+// sigShape ever did — the form is the subject rather than a hint applied to one
+// — so castMain/castChorus count as evidence. Likewise inkMask/inkValue are the
+// style trait carried out in full. See scenes/stage/README.md.
const TRAIT_EVIDENCE = {
- shape: /\bsig(Shape|Form)\s*\(/,
+ shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF))\s*\(/,
camera: /\bsigCamera\s*\(/,
space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/,
- style: /\b(sigEdge|sigGrain|sigFolded)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
+ style: /\b(sigEdge|sigGrain|sigFolded|inkMask|inkValue|inkPattern)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
};
console.log('\nscene schema lint');