Hard-won techniques for shaders that feel musical, alive, and intentional — and the specific failure modes that make them feel cheap. This complements:
- debugging-twitchy-shaders.md — fixing jitter/strobe (the basics of smoothing).
- unique-feature-guide.md — picking decorrelated audio features.
- MAKING_A_NEW_SHADER.md — uniforms, color spaces, basic ChromaDepth mapping.
Reference shaders to study: redaphid/iris/1.frag (+ controllers/wavelet-ease.js),
redaphid/chromadepth-lattice/*.frag, tesla/chromadepth/*.frag.
The difference between "twitchy" and "musical" is almost entirely what signal you feed in and how motion is integrated. Rules, in priority order:
- Smoothed features for anything continuous. Raw
*ZScore/*Normalizedjump 0.12–0.17 per frame. Use thewavelet-easecontroller's spring-smoothed*Springuniforms and EMA-smoothedspectralCrestSmooth/spectralRoughnessSmooth. Raw is only for tiny local transients (a single glint, a kick-zoom that springs back) — never for whole-frame color/brightness/geometry. - Motion = monotonic phase accumulators, never
iTime*rate.iTime*ratejumps the angle byiTime*Δratewhenever the rate changes, and that jump grows with iTime → the spin appears to accelerate over a set. Use the controller'sspinPhase/morphPhase/flowPhase/huePhase(integratedphase += rate*dt). The shader reads them as monotonic clocks. - Audio modulates RATE / SHAPE, never the ANGLE. Adding audio to a rotation angle makes it rock backward when the feature falls. Modulate the spin rate, or shear/scale the shape instead.
quietGateeverything. Multiply every audio offset byquietGate(0 in silence → 1 loud) so a quiet room's mic noise (which blows up Normalized/z-score ranges) can't drive motion.- Coherent feature→region families. Drive related parts with related features:
PITCH (
melodyFlow, centroid) → color; LEVEL (bass/mids/treble/energy) → size/depth; TEXTURE (crest/roughness/flux) → detail/sparkle; SHAPE (spread/rolloff/skew/kurtosis) → spacing/reach.
Beyond per-frame jitter, these structural discontinuities cause occasional snaps:
fract()wrap on a traveling wave. A wave positioned byfract(phase)teleports when phase wraps 1→0. Fix: window it so its amplitude is zero at the seam — multiply bysin(phase*PI)(or a smoothstep that hits 0 at both ends). The wrap becomes invisible.- Transients injected into a phase. Adding a raw kick/z-score into a phase jumps the position non-monotonically (a "shiver"). Scale the amplitude with the transient, never the phase.
- Discrete-band hue popping. Compositing N layers each painted one discrete hue: when animation flips which layer dominates, the hue jumps a whole band. Either keep front-most discrete (sharp — see ChromaDepth) or map a continuous field (for non-depth-locked color).
- Hashed seed re-rolls. A seed that hard-jumps (e.g. on a drop) cuts the structure. Only re-roll on rare events, and/or crossfade.
Basic red→violet mapping is in MAKING_A_NEW_SHADER. The advanced part:
- Make it pop: (a) color from front-to-back ray/structure distance so the nearest thing is
red and it recedes to violet; (b) a hot red fresnel rim on silhouettes (throws the edge toward
you); (c) a blue inner-depth where surfaces face you (you "see into" it) → real near/far
volume. See
tesla/chromadepth/diamond.frag. - HUE MUST STAY LOCKED TO DEPTH. Every kind of variation — per-area, per-device (seeds), audio,
evolution — must touch structure / brightness / warp / depth-distribution, never rotate the
hue, or
red=nearbreaks. (This is the one place ChromaDepth diverges from iris, which rotates hue freely for art.) - Do NOT average depth across layers. Coverage- or occlusion-weighted averaging pulls everything to the middle of the gradient → green/pastel mush, killing the pop. Use discrete front-most compositing for the full, sharp red→violet range. (Averaging is acceptable only for a free, non-depth-locked palette.)
- Keep saturation high, lightness bounded (no white-out), background dark/violet so it floats.
To read from across a room: high-lightness neon palette + a LIT background field (no black voids — the whole screen emits light) + a glow lift (gamma-up ~0.8 then a ~1.15 gain) + minimal vignette. Tradeoff: brightness vs saturation — pushing L and bloom too far desaturates to pastel, so keep the loud-section bloom gentle and let color stay saturated.
A kick should move the whole frame: a smooth swell (spring) for the build + a snappy thump
(raw waveletBassZScore/wavelet_bassHit) on the hit, both decaying. A "subwoofer-cone"
dilation/zoom-in punch that springs back reads as the bass hitting you. Smooth for the swell, raw
only for the transient — and the transient drives amplitude/zoom, not a phase.
- Pan needs STATE → a controller. A stateless shader can only do an absolute touch→position map,
which snaps to the finger and resets. A controller (see
controllers/lattice-nav.js) accumulatesnavX/navYfrom drag deltas (+navZoomfrom pinch), with momentum, so it never snaps back. - Match pan-speed units.
screens-per-swipe = SPEED / (shader's view-window in the same units). If the shader samples a0.07-wide window and the controller adds1.0per swipe, that's ~14 screens/swipe (wildly fast). SetSPEED ≈ window(~0.07 → 1 screen/swipe). Divide drag by zoom so it stays screen-consistent at any zoom. Expose it as a knob. - Per-AREA rotation inverts the pan axis. Varying the lattice's rotation by world position makes "drag right" move content left in regions rotated ~180°. Vary cell size/color by position instead. Also avoid a whole-field time rotation — it slowly drifts the pan axis too.
- Kill the "constantly panning" feel. Separate idle geographic motion (translation/rotation drift) from in-place reactivity. For a stable, explorable world, remove the idle drift/churn and let life come from color/breathing/pulse that don't translate the geography.
Periodic folds (mirror-fold / fract) tile when you zoom out. The obvious fix — a large-scale,
low-frequency, incommensurate domain warp + per-region cell-size variation — was tried on
chromadepth-lattice/6 and did not satisfy (reverted). Honest takeaways:
- A bounded warp only softens the tiling; it doesn't remove it. The warp is itself
periodic/finite, and the underlying fold is still
fract-periodic, so deep zoom-out still reveals repetition — now smeared, which can read as mushy rather than varied. - A bigger warp distorts cells into melt and, past a Jacobian of ~1, folds back on itself (which also reverses local pan direction).
- If you genuinely need non-repeating terrain, change the base, don't paper over a periodic one:
use a non-periodic field (value/FBM noise) as the structure, or accept the tiling as a feature
(kaleidoscopic symmetry) and add variety through color / lighting / landmarks instead of
geometry. The per-area
regionHuecolor drift and world-space landmarks (§9) carry "every area is different" far more convincingly than warping the geometry did.
(Slow audio features — the *Mean history-averages and wavelet-ease's evoFlow/evoWarp/evoPlasma
drifters — are still a great randomness source for evolving variety without jitter; that part
wasn't the problem. They're just better spent on color/landmarks than on a warp that fights a
periodic fold.)
Four random floats generated once per device and persisted in localStorage (auto-declared
uniforms). Seed the palette (base hue, saturation) and the structure (rotation/twist, the
"region map" phase, warp phase) so every phone shows a one-of-a-kind variant. A URL seed= overrides
them (pin a specific look for presets/NFC tags). wooli/chromadepth-* use seed2 for palette bias.
- A path that reads against ANY palette: invert the color (
1.0 - col) along a winding corridor. Inversion is always high-contrast, and since it inverts the structure it appears to follow the lattice. Gate it to where structure is bright (onStruct = high lum) so it traces the lines. - Landmarks as world-space features (a function of world position → stable places you can return to, unique per device via a seed-hash). Add a far halo so you can see one coming and navigate toward it.
- Screenshot every iteration (headless Chromium / Playwright vs the dev server). Reactivity bugs (no red, washed pastel, banding) jump out of a still; motion bugs do not — so also...
- A/B audio-reactive states by injecting features — there's no mic in headless:
Capture a "rest" frame vs a "kick/loud" frame to see the reactivity.
await page.evaluate(f => Object.assign(window.cranes.manualFeatures, f), { quietGate: 1, energySpring: 0.6, waveletBassSpring: 0.9, melodyFlow: 0.7 })
- Test touch/knob controllers by driving real pointer/touch events (
page.mouse/ touch) and reading backwindow.cranes.manualFeatures/window.cranes.controllerFeatures. - Deterministic stills:
?time=<seconds>holdsiTime(pair with?noaudio=true). Note that controller phases still advance in real time even whentime=is held. - Validate before committing:
node scripts/validate-shader.js <path>(GLSL compile + warnings). Watch for reserved-word collisions (e.g.patchis reserved) and duplicate-uniform redefinitions.