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>
125 lines
5.4 KiB
JavaScript
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;
|