music-video-gen/flow-state/src/scenes/shader/signal-decay.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

110 lines
5.0 KiB
JavaScript

// Glitch family: a stack of oscilloscope traces losing signal.
//
// Distinct from Scan Tear (rows displaced sideways), Block Mosh (block-level
// datamosh) and Pitch Shatter (vertical transposition): nothing here is
// displaced at all. The corruption is in the SIGNAL — each trace degrades from a
// clean wave into noise as its lock is lost, and regains it. Loss of lock steps
// on the bar grid, so traces drop out in time rather than flickering.
export const signalDecay = {
name: 'Signal Decay',
family: 'glitch',
kind: 'fragment',
consumes: ['ink'],
traits: ['camera', 'style'],
params: {
traces: { type: 'int', range: [2, 10], default: 5, uniform: 'u_traces', bias: 'density' },
amplitude:{ type: 'float', range: [0.02, 0.3], default: 0.1, uniform: 'u_amplitude', bias: 'energy' },
frequency:{ type: 'float', range: [1, 22], default: 7, uniform: 'u_frequency', bias: 'density' },
loss: { type: 'float', range: [0, 0.9], default: 0.35, uniform: 'u_loss' },
hiss: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_hiss' },
persist: { type: 'float', range: [0, 0.85], default: 0.4, uniform: 'u_persist' },
// The damage front — see the shader. `frontAt` is the slow axis: it
// walks the boundary down the stack over the whole track, so which
// lanes are healthy is a different answer at four minutes than at one.
front: { type: 'float', range: [0, 1], default: 0.7, uniform: 'u_front', bias: 'density' },
frontAt: { type: 'float', range: [-1.3, 1.3], default: 0.0, uniform: 'u_frontAt', slowAxis: true },
speed: { type: 'float', range: [0.1, 2.0], default: 0.6, uniform: 'u_speed', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
amplitude: { feature: 'bandLow', amount: 0.35, response: 'smooth' },
hiss: { feature: 'flatness', amount: 0.3 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
// Quantised era: lock is lost and regained on the eighth-bar grid, so
// dropouts land with the music instead of crawling.
float era = floor(u_barPhase * 8.0) + floor(t * 2.0) * 8.0;
vec3 col = pal(0) * 0.05;
float span = 2.0 / max(float(u_traces), 1.0);
for (int i = 0; i < 10; i++) {
if (i >= u_traces) break;
float fi = float(i);
float centre = -1.0 + span * (fi + 0.5);
float s = u_seed + fi * 27.7;
// How much lock this trace has this era. Below zero it is pure noise.
float lock = sat(hash12(vec2(fi, era)) * 1.4 - u_loss);
// A DAMAGE FRONT across the stack: lock falls away on one side of a
// boundary, so the stack has a shape instead of every lane being an
// independent coin flip. Independent lanes are why this scene measured
// as churn — the arrangement was statistically the same at every moment
// even though individual lanes were dropping in and out constantly.
// Structure is what the eye can follow; per-lane randomness is not.
float side = sat((centre - u_frontAt) * 1.6 + 0.5);
float health = mix(1.0, side, u_front);
lock = sat(lock * health);
// Clean signal: two sines and a slow envelope, so it looks like a
// waveform rather than a test tone.
float clean = sin(p.x * u_frequency + t * 3.0 + s) * 0.6
+ sin(p.x * u_frequency * 2.7 - t * 1.7 + s * 1.3) * 0.4;
// Noise floor: hashed per pixel column and era, held steady within a
// step so it reads as static rather than as a shimmer.
float noise = (hash12(vec2(floor(p.x * 220.0), era + fi)) - 0.5) * 2.0;
float signal = mix(noise, clean, lock);
float y = centre + signal * u_amplitude;
float d = abs(p.y - y);
float w = 0.004 + u_sigLine * 0.012;
float line = smoothstep(w * 2.5, 0.0, d);
vec3 tint = palRamp(fract(s) * 0.4 + 0.15);
col += tint * line * (0.4 + lock * 0.6);
col += tint * exp(-d * 40.0) * 0.25 * lock;
// Hiss band around an unlocked trace: the visual equivalent of the
// sound. Confined to the lane, so it never washes the whole frame.
// Hiss RISES as lock falls, so without this the front cancelled itself
// out: a damaged lane simply traded signal for noise and carried the
// same energy. Beyond the front a lane goes quiet rather than noisy.
if (lock < 0.4) {
float band = exp(-abs(p.y - centre) * 12.0);
col += pal(3) * band * abs(noise) * u_hiss * 0.3 * (1.0 - lock) * health;
}
col *= mix(1.0, 0.25 + 0.75 * health, u_front * 0.6);
}
// Ghost of the previous frame, so a dropout leaves a trail rather than
// vanishing cleanly.
col = max(col, prev(uv) * u_persist);
return vec4(inkValue(col), 1.0);
}
`,
};
export default signalDecay;