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
co-authored by Claude Opus 5
parent 75e0f0ef01
commit ec38def1a9
15 changed files with 1069 additions and 11 deletions
+14
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 = [];
+23
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.
@@ -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;
+74
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;
+60
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;
+63
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;