music-video-gen/flow-state/src/scenes/shader/balance-stack.js
Dejvino e2e5104646 Take the effects off the visualizers and leave them to the grade
Forty-nine scenes carried their own `glow`, every one of the sixty-five added
its own grain, and a handful had bloom, chroma, haze, smear or trail besides —
all of which the post chain already does, with an envelope and a threshold no
scene can see. Two uncoordinated effect layers is the smaller half of the
problem. The larger half is that an effect knob sampled per song makes a scene
look varied across parameter draws while its structure never moves, so the
gallery and the variety harness were both being told a scene was original when
only its halo had changed.

Removed in three shapes. Scene grain goes entirely — the post chain owns it.
Statements that ADD a falloff term scaled by the effect are bloom by another
name and are deleted. What remains is an intensity on something already drawn,
so the uniform is pinned to its default and the knob deleted: the picture
survives, the fake variety does not. Reactive entries driving those params went
too, since an effect modulated by the audio was the most convincing fake of the
lot.

That broke the style trait, which is the interesting part. Surface treatment had
been leaving the scenes for two commits — sigGrain to post, sigEdge to
inkStroke — and removing the last of it left `style` with almost nothing to
express: a style+shape signature had two eligible scenes in the whole library.
The trait is not obsolete, it has moved, so inkStroke and inkMask now read
u_sigLine and u_sigSoft alongside the ink's own weight and edge. A scene drawing
in the song's hand honours the track's line weight by construction, and the
runtime probe confirms it rather than taking it on trust. This reverses a call
made two commits ago for a reason that only became true now.

One check needed re-aiming rather than fixing. Two independently built shows are
two WebGL contexts, and engine/hash.js says at the top that bit-exactness is a
same-context guarantee; the check had been demanding it anyway and getting away
with it because the scenes it happened to cast were bit-stable. A casting change
put smooth-gradient scenes in frame and the last bit moved. Measured before
touching the check: three levels out of 255 across 2.6% of pixels, invisible.
The cross-context comparison is now a distance with a ceiling of four, and
bit-exactness within one show is still demanded by the check below it.

89/89 checks, 11/11 tests, all static gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:21:34 +02:00

125 lines
5.4 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)));
return vec4(inkValue(col), 1.0);
}
`,
};
export default balanceStack;