This guide explains how to create music-reactive WebGL visualizations for the Paper Cranes system. These shaders run in the browser, react to audio input, and can be used to drive LED lights (Hue, Nanoleaf) through screen scraping.
- System Overview
- Shader Basics
- Available Uniforms
- Utility Functions
- Audio Feature Deep Dive
- Choosing Unique Features — how to pick independent features for multi-element shaders
- Design Patterns
- Common Techniques
- Testing Your Shader
- Common Pitfalls
- Example Shaders to Study
- Debugging Twitchy Shaders — diagnosing and fixing jerky, flashy, or chaotic visuals
Paper Cranes is a real-time music visualization system that:
- Captures audio from the microphone
- Performs FFT analysis and extracts 14 audio features
- Passes features as uniforms to GLSL fragment shaders
- Renders at 60fps with WebGL2
- Supports frame feedback (previous frame access) for temporal effects
The shader files live in /shaders/ and are loaded via query parameter: ?shader=filename (without .frag extension).
Going beyond the basics? Once you've got the fundamentals here, see advanced-shader-techniques.md for musical signal conditioning, ChromaDepth that pops, drag/pinch navigation, breaking tiling, and the verification workflow.
Create a .frag file in the /shaders/ directory. The system uses a ShaderToy-compatible format:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// fragCoord: pixel coordinates (0 to iResolution.xy)
// fragColor: output color (RGBA, 0.0 to 1.0)
vec2 uv = fragCoord / iResolution.xy; // Normalized 0-1
fragColor = vec4(uv.x, uv.y, 0.5, 1.0);
}// Normalized UV (0 to 1, origin bottom-left)
vec2 uv = fragCoord / iResolution.xy;
// Centered UV (-0.5 to 0.5, origin center)
vec2 centered = uv - 0.5;
// Aspect-corrected centered (-aspect to aspect, -1 to 1)
vec2 aspectCorrected = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;| Uniform | Type | Description |
|---|---|---|
iTime |
float | Time in seconds since start |
iResolution |
vec3 | Screen resolution (width, height, 0) |
iFrame |
int | Frame counter |
iMouse |
vec4 | Mouse position and click state |
time |
float | Alias for iTime |
resolution |
vec2 | Screen size (x, y) |
frame |
int | Alias for iFrame |
| Uniform | Description |
|---|---|
iChannel0-3 |
Texture samplers (ShaderToy compatibility) |
prevFrame |
Previous frame texture (for feedback effects) |
initialFrame |
First frame / fallback texture |
Each of the 14 audio features has 11 statistical variations, giving 154 audio uniforms total. See Audio Feature Deep Dive for details.
The 14 core features:
bass,mids,treble(frequency bands)energy(overall loudness)pitchClass(detected note 0-11, normalized to 0-1)spectralCentroid,spectralSpread,spectralSkew,spectralKurtosis(spectral shape)spectralFlux(rate of timbral change)spectralRolloff(high frequency cutoff)spectralRoughness(dissonance)spectralEntropy(chaos/unpredictability)spectralCrest(peakiness)
Statistical variations for each feature:
bass- raw valuebassNormalized- min-max normalized to 0-1bassMean- historical averagebassMedian- historical medianbassMin,bassMax- historical rangebassStandardDeviation- variabilitybassZScore- standardized (-1 to 1 roughly), detects anomaliesbassSlope- linear regression slope (is bass rising or falling over the history window?)bassIntercept- regression intercept (predicted value at start of window)bassRSquared- regression fit (0-1, how steady/linear the trend is)
| Uniform | Type | Description |
|---|---|---|
beat |
bool | True when beat detected |
iRandom |
float | Random value that changes each frame |
touch |
vec2 | Touch/mouse position (normalized 0-1) |
touched |
bool | Whether user is touching/clicking |
knob_1 to knob_200 |
float | MIDI controller knobs (0-1) |
These are auto-injected and available in all shaders:
Prefer Oklch/Oklab over HSL. Oklch produces perceptually uniform gradients — no unexpected dark bands between hues, no brightness shifts when rotating hue. HSL is available for compatibility but Oklch is the better default for new shaders.
// Oklch — polar perceptual color space (RECOMMENDED)
// vec3(L, C, h) where L=lightness(0-1), C=chroma(0-~0.37), h=hue(radians)
vec3 rgb2oklch(vec3 c) // RGB to Oklch
vec3 oklch2rgb(vec3 lch) // Oklch to RGB
vec3 oklchmix(vec3 c1, vec3 c2, float t) // Mix with shortest-path hue interpolation
// Oklab — cartesian perceptual color space
vec3 rgb2oklab(vec3 c) // RGB to Oklab (L, a, b)
vec3 oklab2rgb(vec3 lab) // Oklab to RGB
vec3 oklabmix(vec3 c1, vec3 c2, float t) // Mix in Oklab space
// Conversions between Oklab and Oklch
vec3 oklab2oklch(vec3 lab) // Oklab to Oklch
vec3 oklch2oklab(vec3 lch) // Oklch to Oklab
// HSL (legacy — prefer Oklch for new shaders)
vec3 hsl2rgb(vec3 hsl) // HSL to RGB
vec3 rgb2hsl(vec3 rgb) // RGB to HSL
vec3 hslmix(vec3 c1, vec3 c2, float t) // Mix colors in HSL spaceAll color functions have vec4 overloads that pass alpha through.
| Task | Best Space | Why |
|---|---|---|
| Hue rotation | Oklch | Just add to h — perceptually uniform |
| Color mixing/gradients | Oklch or Oklab | No unexpected dark bands like HSL |
| Boost saturation | Oklch | Scale C without affecting lightness |
| Brighten without washing out | Oklch | Change L only |
| Create a color from L/C/hue | Oklch | oklch2rgb(vec3(0.7, 0.15, angle)) |
| Quick additive glow | RGB | col += glow (oklch breaks with additive) |
| Simple lerp (mask on/off) | RGB | mix(bg, col, mask) is fine |
| Porting a ShaderToy shader | HSL | Match the original; convert to Oklch later if needed |
Rule of thumb: Start with Oklch. Fall back to RGB for additive blending. Use HSL only when porting existing code that already uses it.
// Rotate hue
vec3 lch = rgb2oklch(color);
lch.z += 1.0; // rotate ~57 degrees
color = oklch2rgb(lch);
// Boost saturation
lch.y *= 1.5;
// Brighten
lch.x = min(lch.x + 0.1, 1.0);
// Create vivid color from scratch
vec3 col = oklch2rgb(vec3(0.7, 0.15, 1.0)); // bright, vivid, yellow-green hue
// Mix two colors with proper hue interpolation
vec3 result = oklchmix(red, blue, 0.5); // goes through purple, not greyvec4 getLastFrameColor(vec2 uv) // Sample previous frame (UV 0-1)
vec4 getInitialFrameColor(vec2 uv) // Sample initial framefloat random(vec2 st, float seed) // Deterministic random with seed
float random(vec2 st) // Random using iRandom
float staticRandom(vec2 st) // Consistent random (seed=0)float mapValue(float val, float inMin, float inMax, float outMin, float outMax)
// Remaps value from input range to output range with clampingvec2 centerUv(vec2 res, vec2 coord) // Center coordinates
vec2 centerUv(vec2 coord) // Using global resolutionfloat pingpong(float t) // Oscillates 0→1→0
float animateSmooth(float t) // Cubic smoothstep
float animateBounce(float t) // Bouncing animation
float animatePulse(float t) // Sine wave pulse
// Easing functions (all take t from 0-1):
float animateEaseInQuad(float t)
float animateEaseOutQuad(float t)
float animateEaseInOutQuad(float t)
float animateEaseInCubic(float t)
float animateEaseOutCubic(float t)
float animateEaseInOutCubic(float t)
float animateEaseInExpo(float t)
float animateEaseOutExpo(float t)
float animateEaseInOutExpo(float t)
float animateEaseInSine(float t)
float animateEaseOutSine(float t)
float animateEaseInOutSine(float t)
float animateEaseInElastic(float t)
float animateEaseOutElastic(float t)
float animateEaseInOutElastic(float t)| Variation | Use Case | Example |
|---|---|---|
Normalized |
Smooth 0-1 modulation | bassNormalized * 0.5 for half-intensity effect |
ZScore |
Detect spikes, drops, anomalies | if (energyZScore > 0.5) for detecting loud moments |
Mean/Median |
Baseline behavior, slow changes | Background color based on spectralCentroidMean |
Min/Max |
Historical context | Scale effects relative to bassMax |
StandardDeviation |
Detect stability vs volatility | More chaos when energyStandardDeviation high |
Slope |
Detect rising/falling trends | energySlope > 0.0 = energy building up over time |
Intercept |
Trend baseline/extrapolation | Use with slope to predict where a feature is heading |
RSquared |
Trend confidence (0-1) | energyRSquared > 0.5 = steady trend, not chaotic noise |
| Raw value | Direct analysis (less common) | Usually prefer Normalized |
Good pairings (independent, use together for variety):
bass+treble(opposite frequency ranges)spectralCentroid+spectralRoughness(pitch vs dissonance)spectralEntropy+spectralCrest(chaos vs peakiness)spectralFlux+spectralRolloff(change rate vs cutoff)pitchClass+spectralKurtosis(pitch vs distribution shape)
Bad pairings (correlated, avoid using together):
energy+bass(both increase with loud low frequencies)spectralCentroid+pitchClass(both relate to pitch)spectralSpread+spectralEntropy(wider = more complex)
For a deeper guide on choosing unique features, energy decorrelation, hidden correlations, and a proven 8-feature set, see Choosing Unique Features.
Choose features from DIFFERENT domains for variety:
| Domain | Features |
|---|---|
| Frequency Bands | bass, mids, treble |
| Spectral Shape | spectralCentroid, spectralSpread, spectralSkew, spectralKurtosis |
| Spectral Quality | spectralRoughness, spectralEntropy, spectralCrest |
| Temporal | spectralFlux |
| Tonal | pitchClass, spectralRolloff |
| Energy | energy |
For visuals that will drive LED lights, avoid rapid changes. Use heavy feedback blending:
// Get previous frame
vec3 prev = getLastFrameColor(uv).rgb;
// Compute new color
vec3 newColor = /* your fractal/effect */;
// Blend heavily toward previous (90% old, 10% new)
vec3 color = mix(prev, newColor, 0.1);Map audio to structural parameters rather than directly to color for smoother results:
// GOOD: Audio affects fractal parameters
#define WARP_DEPTH mapValue(bassNormalized, 0., 1., 0.3, 0.8)
#define SPIRAL_TIGHTNESS mapValue(spectralCentroidNormalized, 0., 1., 1.2, 3.5)
// AVOID: Audio directly controlling color
vec3 color = vec3(bassNormalized, trebleNormalized, midsNormalized); // Too flashy// ZScore for detecting moments (drops, spikes)
if (energyZScore > 0.5) {
// Something special happens
}
// Normalized for smooth continuous modulation
float radius = 0.5 + spectralCentroidNormalized * 0.3;Use Slope, Intercept, and RSquared to make visuals that respond to where the music is heading, not just where it is now:
// Slope: positive = rising, negative = falling
// Use it to drive evolution direction
#define EVOLVE_DIRECTION (energySlope * 10.0) // Scale up — raw slope values are small
// RSquared: 0 = chaotic, 1 = steady trend
// Use it to control confidence/stability of the visual response
#define TREND_CONFIDENCE (energyRSquared)
// Combine: only evolve strongly when trend is confident
#define CONFIDENT_EVOLUTION (energySlope * energyRSquared * 10.0)
// Detect musical sections:
// Confident build = positive slope + high rSquared
// Confident drop = negative slope + high rSquared
// Chaos/transition = low rSquared (any slope)
#define IS_BUILDING (energySlope > 0.001 && energyRSquared > 0.4)
#define IS_DROPPING (energySlope < -0.001 && energyRSquared > 0.4)
#define IS_CHAOTIC (energyRSquared < 0.2)
// Different features have different trend meanings:
#define GETTING_BRIGHTER (spectralCentroidSlope > 0.0) // Timbre brightening
#define BASS_BUILDING (bassSlope > 0.0) // Low-end building
#define TEXTURE_EVOLVING (spectralEntropySlope) // Complexity changingKey insight: Slope values are small (they represent change per history-window index), so multiply by 5-20 to get useful visual ranges. RSquared is already 0-1 and works well as a confidence gate.
Use irrational ratios (golden ratio, sqrt(2), etc.) to prevent repetitive loops:
#define PHI 1.61803398875
// These will never sync up, creating endless variation
float x = sin(iTime * 0.13);
float y = cos(iTime * 0.17 * PHI);
float z = sin(iTime * 0.11 * sqrt(2.0));Combine multiple fractal calculations at different scales:
vec4 layer1 = fractalFunction(uv, time);
vec4 layer2 = fractalFunction(uv * 2.1, time * PHI);
vec4 layer3 = fractalFunction(uv * 0.5, time * 0.7);
// Blend based on fractal depth/characteristics
vec3 color = mix(mix(color1, color2, blend2), color3, blend3);Raw normalized values respond linearly across the full 0-1 range, which means the fractal/visual is always moving, even during quiet passages. Use smoothstep to create a dead zone below a threshold, so the visual stays calm during normal moments and only comes alive when features push into their upper range:
// Linear: always reacting, even to small values
#define J_REAL_SHIFT (bassNormalized * 0.03)
// Thresholded: calm below 40th percentile, S-curved response above
#define J_REAL_SHIFT (smoothstep(0.4, 1.0, bassNormalized) * 0.03)The S-curve from smoothstep also makes the onset of movement gentle rather than abrupt. Tune the lower edge (0.3-0.5) to control how much of the feature's range is "dead" vs "active."
This pattern works especially well for:
- Fractal shape parameters (Julia set constants)
- Camera/zoom movement
- Drift and rotation speed
- Any parameter where constant low-level motion is distracting
// Combine thresholded features from different domains for rich reactive movement
#define ZOOM_AUDIO (smoothstep(0.5, 1.0, bassNormalized) * 0.08)
#define ROT_AUDIO (smoothstep(0.3, 1.0, spectralFluxNormalized) * 0.12)
#define DRIFT_X (smoothstep(0.3, 1.0, spectralKurtosisNormalized) * 0.05)The same audio feature tells a very different visual story depending on which statistical variation you use:
| Variation | Character | Best For |
|---|---|---|
ZScore |
Spiky, beat-reactive, flashy | Event detection, beat pulses, dramatic moments |
Normalized |
Smooth, flowing, continuous | Fractal shape, color evolution, gradual movement |
Slope * RSquared |
Trend-aware, anticipatory | Building/dropping detection, confident evolution |
Rule of thumb: If your visual parameter changes the overall feel of the image (shape, color, zoom), use Normalized or Slope * RSquared. If it should punctuate moments (flash, pulse, size bump), use ZScore.
A common mistake is using z-scores for everything — this makes the entire visual flashy and tiring to watch. Reserve z-scores for a few specific "event" parameters (mammoth size on beat, beat color shift) and use normalized values for the continuous movement (fractal shape, rotation, drift).
// GOOD: normalized for continuous movement, z-score for punctuation
#define FRACTAL_MORPH (smoothstep(0.3, 1.0, spectralKurtosisNormalized) * 0.03)
#define MAMMOTH_BEAT_PULSE (clamp(bassZScore, 0.0, 1.0) * 0.2)
// BAD: z-scores everywhere = constant visual spiking
#define FRACTAL_MORPH (spectralKurtosisZScore * 0.03)
#define MAMMOTH_BEAT_PULSE (bassZScore * 0.2)Color changes are the most noticeable form of "flashiness." Multiple color-affecting parameters can stack up and produce jarring shifts even when each individual effect seems subtle.
Sources of color flash (audit all of these):
- Hue shift on beat (e.g.,
bHSL.x -= 0.05) - Edge glow intensity scaling with audio
- Depth-to-hue mapping range (how much of the color wheel is used)
- Audio-driven hue drift over time
- Saturation boost from audio features
- Feedback blend amount (less feedback = faster color change)
Guidelines:
- Keep beat hue shifts small (0.01-0.03, not 0.05+)
- Keep beat lightness boosts subtle (1.02-1.05x, not 1.1+)
- Cap additive glow terms:
min(glow, vec3(0.25))notvec3(0.5) - Use a narrower hue range in your color mapping (30-50% of the color wheel, not 75%)
- Prefer
NormalizedoverZScorefor any color-affecting parameter - Use
Slope * RSquaredfor hue drift — it only shifts color during confident trends, not random fluctuations
// Subtle beat color shift
if (beat) {
vec3 bHSL = rgb2hsl(max(col, vec3(0.001)));
bHSL.x = fract(bHSL.x - 0.02); // small hue nudge
bHSL.z = min(bHSL.z * 1.03, 0.6); // gentle brightness, hard cap
col = hsl2rgb(bHSL);
}
// Narrow, seed-varied palette instead of full rainbow
float hueRange = 0.3 + seed * 0.2; // 30-50% of wheel
float hue = fract(depth * hueRange + seed2 * 0.5);Different music genres have different spectral signatures. Choose features that match what's most expressive in your target genre:
Dubstep / Bass Music:
spectralKurtosis— most dynamic feature (CoV=1.118), measures spectrum peakedness. Great for shape morphing.spectralRoughness— the "wub" and grit. Drives distortion/warping effects.bass— the sub-bass drops. Best for size/zoom pulses.spectralFlux— transients and hits. Good for rotation/drift speed.
Ambient / Atmospheric:
spectralEntropy— chaos vs order. Drives complexity.spectralCentroid— brightness shifts. Good for color temperature.spectralSpread— harmonic width. Drives spatial effects.Slope+RSquaredvariants — slow, confident evolution.
EDM / House:
spectralFlux— strongest beat correlate (2.37x spike on beats).energy— overall loudness drives intensity.bass+treble— opposing frequency bands for contrast.beat— reliable pulse for rhythmic effects.
mat2 rot(float a) {
float c = cos(a), s = sin(a);
return mat2(c, -s, s, c);
}
// Usage
vec2 rotatedUV = rot(iTime * 0.1) * uv;vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x*b.x - a.y*b.y, a.x*b.y + a.y*b.x);
}
vec2 cdiv(vec2 a, vec2 b) {
float d = dot(b, b);
return vec2(a.x*b.x + a.y*b.y, a.y*b.x - a.x*b.y) / d;
}
vec2 cpow(vec2 z, float n) {
float r = length(z);
float theta = atan(z.y, z.x);
return pow(r, n) * vec2(cos(n*theta), sin(n*theta));
}vec2 julia(vec2 z, vec2 c, int maxIter) {
for (int i = 0; i < maxIter; i++) {
z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
if (dot(z, z) > 4.0) break;
}
return z;
}Track minimum distances during iteration for smooth coloring:
float minDist = 1e10;
vec2 z = startZ;
for (int i = 0; i < maxIter; i++) {
z = iterate(z);
minDist = min(minDist, abs(z.x)); // Line trap
// or: minDist = min(minDist, length(z - trapPoint)); // Point trap
}
float color = exp(-minDist * 10.0); // Glow effectfloat noise(vec2 p) { /* your noise function */ }
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * noise(p);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}
// Warp coordinates
vec2 warpedUV = uv + vec2(fbm(uv * 3.0), fbm(uv * 3.0 + 5.0)) * 0.3;vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
// Rich preset
vec3 color = palette(t,
vec3(0.5), vec3(0.5), vec3(1.0),
vec3(0.0, 0.1, 0.2));vec2 kaleidoscope(vec2 uv, float segments) {
float angle = atan(uv.y, uv.x);
float segment = 6.28318 / segments;
angle = mod(angle + segment * 0.5, segment) - segment * 0.5;
angle = abs(angle);
return vec2(cos(angle), sin(angle)) * length(uv);
}float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5*(b-a)/k, 0.0, 1.0);
return mix(b, a, h) - k*h*(1.0-h);
}npm run dev # Starts server on localhost:6969Access your shader: http://localhost:6969/?shader=your-shader-name
| Parameter | Description | Example |
|---|---|---|
shader |
Shader file to load | ?shader=fractal-abyss |
noaudio |
Disable audio (use defaults) | ?shader=test&noaudio |
fullscreen |
Fullscreen mode | ?fullscreen=true |
fft_size |
FFT window size | ?fft_size=2048 |
smoothing |
Audio smoothing factor | ?smoothing=0.2 |
| Any numeric param | Becomes a float uniform | ?bassMedian=0.5 |
Any numeric query parameter automatically becomes a shader uniform, and query params override measured audio values. This means you can test exactly how your shader responds to different musical conditions without a microphone:
?shader=my-shader&noaudio=true&fullscreen=true&bassMedian=0.5&energyMedian=0.7
Test your shader against these profiles to catch issues like washout, dead zones, or lack of visual variety:
Silent (baseline) — verify the shader looks good with no audio at all:
?shader=my-shader&noaudio=true&fullscreen=true
Heavy bass — deep, bassy music:
?shader=my-shader&noaudio=true&fullscreen=true&bassMedian=0.8&energyMedian=0.7&spectralCentroidMedian=0.2&spectralEntropyMedian=0.3&spectralRoughnessMedian=0.6
Bright / chaotic — busy, high-frequency content:
?shader=my-shader&noaudio=true&fullscreen=true&spectralCentroidMedian=0.8&spectralEntropyMedian=0.9&trebleSlope=0.5&trebleRSquared=0.8&energyMedian=0.5
Confident energy build — a steady crescendo:
?shader=my-shader&noaudio=true&fullscreen=true&energySlope=0.6&energyRSquared=0.9&energyMedian=0.6&bassMedian=0.5&bassSlope=0.4&bassRSquared=0.85&spectralCentroidMedian=0.5&spectralFluxZScore=1.5
Energy drop — fading out or post-drop:
?shader=my-shader&noaudio=true&fullscreen=true&energySlope=-0.5&energyRSquared=0.8&energyMedian=0.3&spectralCentroidMedian=0.6&spectralEntropyMedian=0.7
- Washout: Do bright regions blow out to white? If so, reduce additive luminance terms or tighten your clamp.
- Dead zones: Are there profiles where the shader looks nearly identical to silence? Your audio mapping may be too subtle.
- Color variety: Does the shader look meaningfully different across profiles? If heavy bass and bright chaotic look the same, your color mapping may depend on too few features.
- Contrast: Every profile should have visible dark regions. If you lose all contrast, your luminance contributions are stacking up.
Since time is just a uniform, you can override it with a query param to freeze the shader at a specific moment in seconds:
?shader=my-shader&noaudio=true&time=12.5
This renders the shader as if 12.5 seconds have elapsed — time stops advancing. Useful for:
- Debugging time-dependent effects — freeze at the exact moment something looks wrong
- Comparing states — screenshot at
time=5,time=30,time=120to see how the shader evolves - Testing periodicity — check if your shader loops cleanly by comparing
time=0withtime=6.283(2π) - Tuning constants — freeze time and adjust other params to isolate time-dependent behavior from audio-dependent behavior
Combine with audio overrides to test a specific shader state completely:
?shader=my-shader&noaudio=true&time=60&bassNormalized=0.8&energyZScore=0.5
- Check browser console for shader compilation errors
- Start simple - get basic output before adding complexity
- Test with audio profiles (above) - don't just test with
?noaudioand call it done - Visualize individual features to understand their behavior:
// Debug: visualize a single audio feature
fragColor = vec4(vec3(bassNormalized), 1.0);Problem: Colors exceed 1.0 and clip to white.
Solution: Use tone mapping:
color = color / (1.0 + color * 0.3); // Soft clamp
color = clamp(color, 0.0, 1.0);Problem: Colors change too quickly, especially with audio.
Solutions:
- Use heavy feedback blending (90%+ previous frame)
- Map audio to structure, not color directly
- Use
Normalizedinstead ofZScorefor color - Slow down time:
float t = iTime * 0.3;
Problem: NaN or Inf from dividing by zero.
Solution: Always protect divisions:
float d = max(dot(z, z), 0.0001);
float result = x / d;Problem: log() of zero or negative produces NaN.
Solution:
float safeLog = log(max(value, 0.0001));Problem: Colors drift toward white or black over time with feedback.
Solution: Gently pull toward target values (Oklch keeps lightness and chroma independent):
vec3 prevLch = rgb2oklch(prev);
prevLch.y = mix(prevLch.y, 0.12, 0.01); // Pull chroma toward vivid
prevLch.x *= 0.995; // Slight darkening to prevent white-out
prev = oklch2rgb(prevLch);Problem: Audio uniforms seem static.
Causes:
- Microphone not permitted
- Audio muted at OS level
- Using raw values instead of Normalized/ZScore
Debug:
// Visualize if audio is working
fragColor = vec4(energy, spectralFlux, bass, 1.0);Problem: Using distance/depth for color but it's all one hue.
Causes:
- Visible surfaces are at similar distances from camera
- Depth range parameters don't match visible range
- Using ray distance when world position would work better
Debug approach:
// Output raw depth as grayscale to see if it varies
float normDist = result.dist / MAX_DIST;
fragColor = vec4(vec3(normDist), 1.0); // Should show gradientSolutions:
- Tighten the depth range to match what's visible:
#define DEPTH_MIN 1.5 // Start of visible range
#define DEPTH_MAX 4.0 // End of visible range
float normDist = (dist - DEPTH_MIN) / (DEPTH_MAX - DEPTH_MIN);- Try different depth sources - ray distance isn't always best:
// Ray distance (from camera)
float depth = result.dist / MAX_DIST;
// World Z position (depth into scene)
float depth = (p.z - Z_MIN) / (Z_MAX - Z_MIN);
// Orbit trap (varies with fractal structure!)
float depth = sqrt(trap.trapMin) * TRAP_SCALE;
// Distance from world origin
float depth = length(p) / MAX_RADIUS;- Mix multiple sources for richer variation:
float hue = depthHue * 0.5 + trapHue * 0.3 + normalHue * 0.2;Key insight: If your camera sees a "flat" view of a fractal (all surfaces at similar ray distances), use orbit traps or world position instead of ray distance for color variation.
- Compact, elegant code
- Orbit tracking for smooth coloring
- Audio mapped to narrow parameter ranges
- No feedback, pure mathematical beauty
Key technique: Angle-based coloring from orbit position:
P = sqrt(z + (z - z*z*z) * cos(atan(Z.y, Z.x) - vec4(0, 2.1, 4.2, 0)));- Julia set distortion
- White-out prevention system
- HSL color manipulation
- Beat-reactive ripples
- Multiple fractal layers at different scales
- Heavy feedback for slow evolution
- Domain warping with FBM
- Rich jewel-tone palette
- Audio affects structure, not color
- Designed for LED light sync
Key techniques:
- 92% feedback blend for slow evolution
- Three fractal layers (2x complex exponential + Mandelbrot)
- Incommensurate frequencies (PHI-based) for aperiodicity
- Orbit trap glow effects
-
Create file:
/shaders/your-name.frag -
Start with template:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
float t = iTime * 0.3; // Slow time
// Your visualization here — build color in Oklch for perceptual uniformity
float hue = t * 0.5; // Hue in radians
float chroma = 0.12 + bassNormalized * 0.05; // Saturation
float lightness = 0.5 + energy * 0.2; // Brightness
vec3 color = oklch2rgb(vec3(lightness, chroma, hue));
// Feedback for smooth evolution
vec3 prev = getLastFrameColor(fragCoord / iResolution.xy).rgb;
color = mix(prev, color, 0.1);
fragColor = vec4(color, 1.0);
}-
Test:
http://localhost:6969/?shader=your-name -
Iterate: Add complexity gradually, test frequently
-
Polish: Add tone mapping, vignette, prevent white-out
The easiest way to toggle between constant values and audio-reactive uniforms is the comment swap pattern:
// ============================================================================
// TUNABLE PARAMETERS - swap constants for audio uniforms
// ============================================================================
// Active: audio-reactive
#define SCALE_MOD (-spectralEntropyZScore * 0.08)
// #define SCALE_MOD 0.0
// Active: constant (for testing)
// #define HUE_SHIFT (pitchClassNormalized * 0.3)
#define HUE_SHIFT 0.0
// Active: audio-reactive
#define BRIGHTNESS (1.0 + bassZScore * 0.15)
// #define BRIGHTNESS 1.0Why this works:
- Comment/uncomment one line to toggle
- Test without audio:
?noaudio=true - Tune constant values before mapping to audio
- Documentation stays inline with the code
- Easy to see what's currently active at a glance
Organize by function for clarity:
// ============================================================================
// COLOR PARAMETERS
// ============================================================================
#define HUE_BASE 0.0
#define HUE_RANGE 0.7
#define SATURATION 0.95
// ============================================================================
// STRUCTURE PARAMETERS
// ============================================================================
#define DEPTH_MIN 1.5
#define DEPTH_MAX 6.0
// ============================================================================
// AUDIO-REACTIVE PARAMETERS
// ============================================================================
#define ZOOM (spectralFluxZScore * 0.18)
// #define ZOOM 0.0For more complex parameter relationships, use #ifdef blocks to switch entire parameter sets:
- Testable states - Use query params to test specific parameter combinations
- No microphone needed - Develop without audio setup
- Reproducible - Same URL = same visual state for debugging
- Playwright-friendly - Automated visual testing with different knob values
- Easy transition - Just flip a
#defineto switch to audio mode
// ============================================================================
// KNOB MODE: Use query params to test different states
// Example: ?shader=my-shader&knob_1=0.5&knob_2=0.8
// ============================================================================
// Uncomment to enable knob testing mode
// #define KNOB_MODE
#ifdef KNOB_MODE
// Knob declarations
uniform float knob_1; // PARAM_A: describe what 0 and 1 mean
uniform float knob_2; // PARAM_B: describe what 0 and 1 mean
// Map knobs to aesthetic ranges
#define PARAM_A mapValue(knob_1, 0., 1., 0.3, 0.8)
#define PARAM_B mapValue(knob_2, 0., 1., 1.0, 3.0)
#else
// AUDIO MODE: Map audio features to the same parameters
#define PARAM_A mapValue(bassNormalized, 0., 1., 0.3, 0.8)
#define PARAM_B mapValue(spectralCentroidNormalized, 0., 1., 1.0, 3.0)
#endif- Start with
#define KNOB_MODEuncommented - Test via URL:
?shader=my-shader&knob_1=0.2&knob_2=0.8&noaudio=true - Find aesthetic ranges by adjusting knob values
- Document each knob with comments explaining what low/high values produce
- Switch to audio by commenting out
#define KNOB_MODE - Choose appropriate audio features from different domains (see Feature Domains in CLAUDE.md)
Use Playwright to capture different states for visual comparison:
// Navigate with specific knob values
await page.goto('http://localhost:6969/?shader=my-shader&knob_1=0.0&knob_2=0.5&noaudio=true');
await page.screenshot({ path: 'state-low.png' });
await page.goto('http://localhost:6969/?shader=my-shader&knob_1=1.0&knob_2=0.5&noaudio=true');
await page.screenshot({ path: 'state-high.png' });#ifdef KNOB_MODE
uniform float knob_1; // WARP_DEPTH: 0=subtle, 1=heavy warping
uniform float knob_2; // SPIRAL_TIGHTNESS: 0=loose, 1=tight spirals
#define WARP_DEPTH mapValue(knob_1, 0., 1., 0.3, 0.8)
#define SPIRAL_TIGHTNESS mapValue(knob_2, 0., 1., 1.2, 3.5)
#else
#define WARP_DEPTH mapValue(bassNormalized, 0., 1., 0.3, 0.8)
#define SPIRAL_TIGHTNESS mapValue(spectralCentroidNormalized, 0., 1., 1.2, 3.5)
#endif| Knob Range | Typical Use |
|---|---|
knob_1-7 |
Primary shader parameters |
knob_70-79 |
Secondary/fine-tune parameters |
knob_14-22 |
MIDI controller banks |
The /solo Claude Code skill automates the knob swap pattern. It can:
- Knobify a shader (
/solo knobs <path>) — extract all tunable parameters into#defines withknob_*uniforms, add commented-out audio alternatives, refactor any bareknob_*references into named#defines, and generate a preset URL. - Switch to audio mode (
/solo audio <path>) — swap every#definepair so the audio-reactive line is active and the knob line is commented out. - Auto-toggle (
/soloor/solo <path>) — detect the shader's current mode and switch to the other one. If it's in knob mode, switch to audio; if in audio mode, switch to knobs. If the shader hasn't been knobified yet, knobify it.
The skill defaults to the first .frag file modified in the git worktree, so you can often just run /solo with no arguments.
The pattern /solo produces:
// Shoulder shrug: 0=relaxed, 1=shrugged up
#define SHOULDER_Y (-0.02 + knob_1 * 0.015)
// #define SHOULDER_Y (-0.02 + max(bassZScore, 0.0) * 0.015)This is a flat comment-swap pattern (no #ifdef). Each #define has exactly one knob line and one audio line — toggle by commenting/uncommenting. The /solo skill does this swap for every pair in the shader at once.
ChromaDepth 3D glasses use color to create a depth illusion: red appears closest, green is middle depth, and blue/violet appears farthest. Black is neutral. To create a ChromaDepth version of an existing shader, you replace its color palette with a depth-mapped rainbow while keeping its structure, audio reactivity, and visual effects intact.
The core function maps a 0-1 depth value to the chromadepth rainbow using HSL:
vec3 chromadepthColor(float t, float sat, float lit) {
t = clamp(t, 0.0, 1.0);
// Hue: 0=red(near) → 0.75=violet(far)
float hue = fract(t * 0.75 + seed2 * 0.15); // seed2 shifts the mapping
return hsl2rgb(vec3(hue, sat, lit));
}Use HSL, not Oklch for chromadepth. The chromadepth glasses work with the physical light spectrum (red→green→blue), so HSL's hue wheel maps directly. Oklch's perceptual uniformity isn't needed here — you want the raw spectral order.
The key decision is mapping your shader's visual structure to depth (0=near, 1=far):
For fractals (Julia/Mandelbrot):
- Interior (didn't escape) → red/warm (depth 0.0-0.35) = near
- Boundary (escaped slowly) → green/cyan (depth 0.35-0.55) = middle
- Exterior (escaped fast) → blue/violet (depth 0.55-0.85) = far
vec3 fractalChromadepth(float tO, float tX, float tY, float tC, float iter, bool escaped) {
tO = sqrt(tO); tX = sqrt(tX); tY = sqrt(tY);
float depth, brightness;
if (!escaped) {
// Interior — orbit traps create structural detail
float trapDetail = min(tX, tY);
float trapBlend = mix(tO, trapDetail, 0.5 + seed * 0.3);
depth = clamp(trapBlend * (0.35 + seed * 0.1), 0.0, 0.35);
brightness = 0.45 + tO * 0.15 + trapDetail * 0.15;
} else {
// Exterior — iteration count maps to distance
float escapeFrac = clamp(iter / 80.0, 0.0, 1.0);
depth = mix(0.85, 0.4, pow(escapeFrac, 0.5 + seed * 0.3));
brightness = mix(0.1, 0.6, pow(escapeFrac, 0.6));
}
float sat = 0.92 - depth * 0.06;
float lit = clamp(brightness * 0.55, 0.05, 0.55);
return chromadepthColor(depth, sat, lit);
}For raymarching (3D fractals, SDFs):
- Use ray distance, world position, or orbit traps for depth
- Mix multiple depth sources for richer variation
- See
shaders/wip/chromadepth/1.fragfor a Mandelbox example
Use seed, seed2, seed3, seed4 uniforms to make each device display a unique variant:
| Seed | Best Use | Example |
|---|---|---|
seed |
Fractal shape, orbit trap weighting | mix(tO, trapDetail, 0.5 + seed * 0.3) |
seed2 |
Hue offset in depth mapping | fract(t * 0.75 + seed2 * 0.15) |
seed3 |
Zoom level, rotation angle, drift phase | seed3 * PI * 2.0 + iTime * 0.008 |
seed4 |
Secondary effects (tendrils, background) | seed4 * PI * 2.0 + iTime * 0.025 |
- Copy the original shader and rename it (e.g.,
wooli/1.frag→wooli/chromadepth-1.frag) - Replace the color palette function (e.g.,
icyColor()) with achromadepthColor()+ depth mapping function - Convert oklch/oklab operations to HSL — feedback decay, hue aging, etc. should use
rgb2hsl/hsl2rgb - Make edge glow red/orange (hue ~0.0-0.1) so it pops forward in chromadepth
- Make background elements blue/violet (hue ~0.6-0.7) so they recede
- Beat flash: shift toward red instead of just brightening —
bHSL.x = fract(bHSL.x - 0.05)makes the beat "pop forward" - Keep all structural code unchanged — masks, fractal iteration, feedback, infinity zoom, etc.
- Update metadata tags — add
chromadepth, 3dto@tags - Clamp final output to 0-1 — chromadepth needs clean colors, no HDR blowout
- High saturation (~0.9+) — desaturated colors lose the depth effect
- Moderate lightness (0.05-0.55) — too bright washes out; too dark loses color
- No white — white has no depth in chromadepth; clamp lightness
- Black = neutral — use black for areas with no depth information (background)
- Vignette helps — darkening edges keeps the depth effect centered
| Shader | Type | Notes |
|---|---|---|
claude/chromadepth-julia.frag |
2D Julia set | Simple chromadepth, orbit traps |
claude/chromadepth-mandelbrot.frag |
2D Mandelbrot | Deep zoom, feedback |
wip/chromadepth/1.frag |
3D Mandelbox | Raymarching, multiple depth sources |
wooli/chromadepth-1.frag |
Seeded Julia + image mask | Full conversion from oklch palette |
wooli/chromadepth-2.frag |
Seeded Julia + scrolling line | Scrolling tapestry with chromadepth |
Understanding the audio processing pipeline helps explain why your shader might feel sluggish or jittery:
Audio data passes through three independent smoothing stages before reaching your shader:
-
FFT window (
fftSize, default 8192) — determines frequency resolution. Larger = better bass detail but more historical audio per frame. At 44.1kHz, 8192 samples ≈ 186ms of audio. -
AnalyserNode smoothing (
smoothingTimeConstant, default 0.4) — browser-level exponential average on raw FFT bins. Each frame:output = old × 0.4 + new × 0.6. -
Feature smoothing (default 0.25, z-scores get 2.5x multiplier) — exponential smoothing applied to computed features before they become shader uniforms.
The statistical features (mean, median, z-score) computed over the 500-frame history window provide additional smoothing by their nature.
The first ~2 seconds of audio are jittery because statistical features (z-scores, normalized values) are unstable with few history samples. The system applies a warmup ramp that dampens z-scores and slopes toward 0, and normalized values toward 0.5, gradually fading in over ~120 frames.
If your shader looks wild on startup but settles down, this is expected behavior.
Browser audio processing features designed for voice calls can destroy music visualization:
- Auto Gain Control (AGC) — compresses dynamics, squashing the transients that beat detection needs. Disabled by default.
- Noise Suppression — targets non-voice sounds like bass and transients. Disabled by default.
- Echo Cancellation — can interfere with audio analysis. Disabled by default.
All three can be re-enabled via query params (?autoGainControl=true) if needed for specific scenarios (e.g., very quiet room).
When creating shaders for driving Hue/Nanoleaf lights via screen scraping:
- Avoid rapid changes - LEDs have latency, fast changes look bad
- Use saturated colors - Desaturated colors look washed out on LEDs
- Large color regions - Small details are lost in screen sampling
- Smooth gradients - Harsh edges create flickering
- Test at low resolution - Screen scraping often downsamples significantly