Three new scene shaders to widen the library, now that the main families are settled. Each is a shader plus a params block, registered in the single source of truth (scenes/registry.js), so casting, UI, arc and checks pick them up automatically. - Block Mosh (glitch): a datamosh. Blocks pull the feedback buffer along per-block strokes on a bar-quantized grid so the corruption steps like an edit rather than crawling, and spills on onsets. Fills the most iconic gap in the glitch family. - Neon City (structural): a receding skyline with instanced lit windows, distinct from the rolling ridgelines and the road grid it sits alongside. - Flora (organic): an abstract plant of swaying stalks, teardrop petals and a bloom corona, all stamped in the track's signature shape. Also adds HOWTO-visualizers.md, a quick reference for building the next one.
6.5 KiB
How to build a visualizer (scene)
A scene is a fragment shader plus a params block — nothing else. Uniform binding, the generated UI sliders, seeded per-track sampling, arc drift and the phase-gate checks are all derived from the schema. There is no per-scene wiring.
Reference: src/scenes/shader/kaleido-tunnel.js (simple), block-mosh.js
(feedback/glitch), scan-tear.js (beat-quantised), metaballs.js (loops).
Read these before writing anything.
The shape of a scene
export const myScene = {
name: 'My Scene',
family: 'glitch', // flow | organic | minimal | structural | geometric | glitch
kind: 'fragment',
traits: ['camera', 'style'], // which personality traits it honours (see below)
params: {
density: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_density', bias: 'density' },
speed: { type: 'float', range: [0.1, 2], default: 0.5, uniform: 'u_speed', rate: true },
palette: { type: 'palette', count: 4 },
},
reactive: {
density: { feature: 'bandLow', amount: 0.3, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
return vec4(palRamp(fbm(p * 4.0 + t, 4)), 1.0);
}
`,
};
Register it in src/scenes/registry.js (import + push into MODULES). Done.
The shader contract
You write one function: vec4 scene(vec2 uv, vec2 p).
uvis 0..1 across the frame;pis centred, aspect-corrected, ~-1..1 on the short axis. Work in these, never pixels — scale pixel-sized things byu_pixelScaleso a 720p preview matches a 4K export.- The preamble is injected for you. Never declare
main(),u_resolution,vUv, or any contract uniform yourself.
Uniforms available (see engine/shader-contract.js):
- Frame:
u_time,u_frame,u_progress,u_seed,u_resolution,u_aspect,u_pixelScale,u_opacity. - Audio, filled per frame from the FeatureTrack:
u_loudness,u_rms,u_bandSub/low/mid/high/air,u_flux,u_centroid,u_flatness,u_width,u_beat,u_beatPhase,u_barPhase,u_phrasePhase,u_sectionProgress,u_sectionEnergy,u_buildSlope. - Personality (
u_sigSides,u_sigDrift,u_sigLine, …) — see below. - Feedback:
u_prevsampler, read withprev(vec2 uv)(returns the previous frame's colour; black if none). This is what makes trails/smear/datamosh work for free.
Helpers (already in the preamble):
- Palette:
pal(int),palRamp(float)— always use these, or the look can't recolour the scene. - Noise:
hash11/12/22,vnoise,fbm,curl,rot,kaleido. - Personality:
sigShape/sigForm,sigCamera,sigFolded,sigEdge,sigGrain,sigHorizonY,sigAir. sat(x)=clamp(x, 0, 1).
Param schema fields
type:float|int|bool|vec2|palette.range[min, max]required for numerics;defaultrequired unless you want range[0] (prefer explicit defaults).uniform: the GLSL name. Must beu_*and must not collide with the contract (see "traps").bias: which track-character axis nudges sampling —energy,density,motion. This is how a loud drop gets denser scenes without the scene knowing about audio.rate: true: mandatory if the shader multipliesu_timeby this param.reactive:{ feature, amount, response }.response:linear(default) |spike|smooth|inverse.
Personality traits (traits)
The look generator builds each track on a signature of 1-2 traits, and a scene that doesn't honour all of them is never cast in that track — so the library intentionally shrinks per track. Only declare what you genuinely use:
| trait | what to call | lint evidence |
|---|---|---|
shape |
sigShape / sigForm |
a sigShape/sigForm( call |
camera |
sigCamera(p) |
a sigCamera( call |
space |
sigHorizonY / sigAir |
those calls or u_sigHorizon/Depth/Wash |
style |
sigEdge / sigGrain / sigFolded, or u_sigLine/Soft/Texture/Fold |
the calls / those uniforms |
The lint greps your shader and fails a declared trait with no evidence. Declaring
[] (none) is valid.
Traps that have actually bitten here
- Anything multiplying
u_timemust berate: true. Phase iselapsed × rate; modulating a rate jumps the phase byelapsed × Δrate— a minute in a small wobble throws the image several units between frames, which measured as strobing at 2× the accessibility limit. So don't react a rate param, and add a bounded term instead:u_time * u_speed + u_bandLow * 2.0is fine;u_time * (u_speed + u_bandLow)is not. - Never reuse a contract uniform name (
u_width,u_time,u_seed, …). It's a GLSL redefinition error; the only symptom is a black frame. - Don't modulate whole-frame luminance on the beat. A per-kick min→max→min
cycle is exactly what the WCAG 2.3.1 / Harding 3-flashes-per-second ceiling
bans. Pulse a small local term (per-block tint) instead, and use
smoothresponses on loud things. If it's glitchy, quantise it — `floor(u_barPhase- n) + floor(t * k) * n
makes corruption step on the grid instead of crawling, which both reads better and stays below the flash rate (seescan-tear.js,block-mosh.js`).
- n) + floor(t * k) * n
- Determinism is absolute. No
Math.random(),performance.now(),Date.now(),new Date()— usehash*/fbmfor variation, and let time flow throughu_time/u_frameonly. The lint greps for these. - Set a base image. A scene that only reads
prev()is black for the first frames and fragile under seek. Generate your own field underneath the effect. - Don't hardcode saturated
vec3(r,g,b)literals when you declared a palette — the lint flags more than two.
Families
Chosen by section kind in the arc driver — a breakdown never lands on a strobing glitch scene. Current counts (aim: 4-6 each): flow 2, organic 3, minimal 3, structural 2, geometric 3, glitch 3. The thin families are the best place to contribute next.
Verify
npm run lint:scenes # schema/shader agreement both ways + determinism grep
npm test # audio pipeline (unaffected, but cheap)
Then the GPU gates in the browser at http://localhost:5180/checks.html:
Phase 2 (param range sweep — no NaN/black/white), Phase 5 (flash-rate sweep),
Phase 7 (per-scene acceptance: distinctness, liveness, animation, determinism,
4K budget). These iterate the registry, so a new scene is covered automatically
once registered.