Epic 3, first slice: the song brings its own cast

Four stages, an identity layer, and an A/B that says the idea is right and the
reason it works is not the reason I expected.

A stage has no image of its own. It owns arrangement — a procession, a
constellation, a soloist, a swarm — and what it arranges comes from the track:
`castMain` and `castChorus` for the forms, `inkMask` and `inkValue` for the hand
they are drawn in. The identity generates a protagonist and a chorus with sides,
notches and hollows, plus an ink treatment of weight, edge, fill, outline and
posterisation. All of it travels as uniforms, so it is data rather than code and
a stage consumes it without knowing any other stage exists.

The protagonist IS the signature form rather than a second opinion about it.
They were separate draws in the first version, which let a track built on
hexagons put a round protagonist on screen — the signature said one thing and
the picture said another, and the shape trait stopped meaning anything for
stages. The cast now reads its geometry from the personality live and adds the
notches and hollows that turn a shape into a character.

Measured across seven songs, three arms, same instrument:

    arm                              floor   observed   spread
    stages, four of them            0.0801     0.1133   +0.0332
    legacy scenes, four of them     0.0830     0.1122   +0.0292
    the unrestricted generator      0.1058     0.1146   +0.0088

The prediction in EPIC-3 §7 was that sharing a cast would drop the floor
sharply. The floor did drop sharply — by a quarter — but nearly all of that came
from casting FEWER scenes per video, not from sharing content: the legacy arm,
four ordinary scenes with no cast at all, gets most of the same benefit. Stages
add about 14% on top of that.

That is worth knowing before building the other four registers. The single
largest available win was hiding in the roster size all along, and it costs one
number to take.

One result runs the wrong way and is recorded rather than explained: coupling is
+0.29 on the legacy arm and -0.18 on the stages. At twenty-one pairs neither is
distinguishable from zero, so it is a flag for a larger sample rather than a
finding.

Also fixed: a backtick inside the shader preamble's template literal, which
closed it and made every check page hang on "starting…" with no error in the
console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-17 21:17:56 +02:00
parent 75e0f0ef01
commit ec38def1a9
15 changed files with 1069 additions and 11 deletions

330
flow-state/EPIC-3.md Normal file
View File

@ -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.

View File

@ -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 = `<pre class="scene-gate">${lines.join('\n')}</pre>`;
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';

View File

@ -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 };
}

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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)}`;
}

View File

@ -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'}` +

View File

@ -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 = [];

View File

@ -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.

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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