music-video-gen/flow-state/src/scenes/shader/balance-stack.js
Dejvino 2d3ed8eb02 Finish the migration: all 65 scenes on the identity artifacts
Two helpers made the bulk of it mechanical. `inkStroke` is a drop-in for
sigEdge — the same line at the identity's weight rather than the track's — and
`castForm` is a drop-in for sigForm, same signature so call sites do not change
shape. With those in place the substitution table is one-to-one:

    sigForm(  -> castForm(     sigShape( -> castMain(     sigEdge( -> inkStroke(

Forty-seven scenes went through that pass in one run: twenty-six take the cast
and the ink, twenty-one take the ink alone. Then the gate ran over all sixty-five
and found three the pass had broken, which is the entire reason it exists.

Spectrum Sculpture rendered pure black. It had been using sigShape as a RADIAL
METRIC rather than drawing it, and the cast carries notches and a hollow — an
annulus used as a radius turns a sculpture inside out. Reverted to sigShape and
dropped to ink only. The lesson generalises: a scene that reads a form as
geometry is not a scene that draws it, and the classifier cannot tell those
apart from the source.

Eclipse Field stopped honouring its `style` trait. It opts out of surface grain,
so sigEdge was its only style evidence, and the ink replaced it. The trait claim
is now dropped — and so is the lint change that had let inkMask count as style
evidence, which was wrong and was hiding exactly this. A trait is a property of
the track a scene may honour; an artifact is content it draws. Taking the ink
says nothing about whether a scene responds to u_sigLine.

Circuit Bloom went empty at the bottom of its `grown` range, where the pads were
carried by a hairline and the ink's stroke is thinner than the edge it replaced.
Now filled as well as stroked.

Also: the backtick check now covers every shader literal rather than only the
preamble, because a mechanical pass over sixty files reintroduced one
immediately. Three rounds lost to that typo is enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:39:19 +02:00

126 lines
5.5 KiB
JavaScript

// Minimal family: four or five of the track's signature form stacked one on
// another on an empty plain, leaning further than they should, with a low sun
// throwing the whole stack across the ground.
//
// Salt Flat is the other minimal with a horizon and an object, and its object is
// one, tiny and miles away: the subject there is the emptiness. Here the subject
// is close, it is the only thing in the frame, and it is a structure with a
// problem — it leans, and the low sun throws it across the whole plain. Nearly
// still, and the stillness is tense rather than calm.
//
// No declared slow axis. The sun crossing is the one that should qualify — it
// regrades the entire sky — and it measures 0.93x against the noise floor,
// because in a frame this empty the camera's drift moves the average about as
// much as anything in the scene can. Left undeclared rather than overclaimed.
export const balanceStack = {
name: 'Balance Stack',
family: 'minimal',
kind: 'fragment',
consumes: ['cast', 'ink'],
traits: ['shape', 'camera', 'space', 'style'],
params: {
count: { type: 'int', range: [2, 6], default: 4, uniform: 'u_count', bias: 'density' },
lean: { type: 'float', range: [-0.5, 0.5], default: 0.12, uniform: 'u_lean' },
block: { type: 'float', range: [0.08, 0.4], default: 0.2, uniform: 'u_block' },
taper: { type: 'float', range: [0.5, 1.0], default: 0.82, uniform: 'u_taper' },
sun: { type: 'float', range: [-1.0, 1.0], default: -0.6, uniform: 'u_sun' },
shadow: { type: 'float', range: [0, 1], default: 0.6, uniform: 'u_shadow' },
rim: { type: 'float', range: [0, 1.5], default: 0.7, uniform: 'u_rim', bias: 'energy' },
sway: { type: 'float', range: [0.01, 0.2], default: 0.04, uniform: 'u_sway', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
rim: { feature: 'loudness', amount: 0.3, response: 'smooth' },
shadow: { feature: 'bandLow', amount: 0.2, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_sway + u_seed;
p = sigCamera(p);
float ground = sigHorizonY() * 0.4 - 0.35;
float above = p.y - ground;
// Sky and plain. Two flat tones and a line: everything else in the frame is
// the stack.
// The sun's position lights the whole frame, not just the stack: the glow
// sits where it is on the horizon and the plain takes its colour from it.
// That is what makes the sun crossing the sky the scene's long journey.
float sunX = u_sun * 1.2;
float toSun = abs(p.x - sunX);
// The sky is graded ACROSS the frame toward the sun as well as up it, so
// where the sun stands recolours everything rather than adding a spot.
vec3 sky = mix(pal(1) * 0.22, pal(0) * 0.07, sat(above * 0.5 + 0.1));
vec3 warm = mix(pal(3) * 0.3, pal(2) * 0.22, sat(above * 0.6 + 0.2));
vec3 col = mix(sky, warm, sat(1.0 - toSun * 0.55));
col += pal(3) * exp(-abs(above) * 8.0) * exp(-toSun * 0.8) * 0.9;
col += pal(3) * exp(-abs(above) * 2.0) * exp(-toSun * 1.6) * 0.35;
if (above < 0.0) {
col = mix(pal(2) * 0.16, pal(0) * 0.09, sat(-above * 1.6));
col += pal(3) * exp(above * 4.0) * exp(-toSun * 1.2) * 0.5;
}
// The sun is low and to one side; everything about the light comes from it.
vec2 lightDir = normalize(vec2(u_sun, 0.55));
// The shadow first, so the stack sits on top of its own.
float shade = 0.0;
float y = ground;
float x = 0.0;
// lint: fixed-cost — at most six blocks in the stack
for (int i = 0; i < 6; i++) {
if (i >= u_count) break;
float fi = float(i);
float size = u_block * pow(u_taper, fi);
// Stacked by accumulation, so each block actually rests on the one
// below whatever the taper is doing.
y += size;
x += u_lean * size * 1.6 + sin(t * 1.3 + fi * 0.7) * 0.012 * fi;
float h = y;
// Cast along the ground, stretched by how high the block sits.
vec2 at = vec2(x - lightDir.x * (h - ground) * 2.2, ground - 0.02);
vec2 q = (p - at) / vec2(size * 2.4, size * 0.32);
shade = max(shade, exp(-dot(q, q) * 1.6));
y += size;
}
col = mix(col, pal(0) * 0.05, shade * u_shadow * smoothstep(0.02, -0.02, above));
// The stack. Drawn back to front, which for a vertical pile is bottom up.
y = ground;
x = 0.0;
// lint: fixed-cost — the same six blocks
for (int i = 0; i < 6; i++) {
if (i >= u_count) break;
float fi = float(i);
float size = u_block * pow(u_taper, fi);
y += size;
x += u_lean * size * 1.6 + sin(t * 1.3 + fi * 0.7) * 0.012 * fi;
vec2 at = vec2(x, y);
float d = castMain((p - at) / size) * size;
// Body: shaded across the form, so it has a lit side and a dark side.
float lit = sat(dot(normalize(p - at + 1e-4), lightDir) * 0.5 + 0.5);
vec3 body = mix(pal(0) * 0.08, pal(2) * 0.4, lit) * (0.65 + 0.35 * (1.0 - fi * 0.12));
col = mix(col, body, smoothstep(0.004, -0.004, d));
// Rim: the sun catching the edge, in the track's line weight.
col += pal(4) * inkStroke(d) * (0.3 + u_rim * 0.9) * (0.4 + lit * 0.8);
y += size;
}
col = sigAir(col, p, smoothstep(0.1, 1.5, abs(p.x) * 0.7 + sat(above * 0.5)));
col += sigGrain(uv);
return vec4(inkValue(col), 1.0);
}
`,
};
export default balanceStack;