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');