// shader guide
everything you need to write audio-reactive visuals for the player. shaders are GLSL ES 3.00 fragment shaders rendered on a fullscreen quad — if you've written for Shadertoy or Unity, you'll be home in five minutes.
quick start
open /player → shaders tab → + new. the editor live-compiles as you type; errors show up with line numbers. the smallest possible shader:
void main() {
vec2 uv = gl_FragCoord.xy / uResolution; // 0..1
fragColor = vec4(uv, uBands.x, 1.0); // bass in the blue channel
}
you write only the body — no #version, no precision,
no uniform declarations. the player prepends a header with everything below.
output goes to fragColor (vec4, alpha ignored).
uniforms
| uniform | type | meaning |
|---|---|---|
uResolution | vec2 | canvas size in physical pixels |
uTime | float | seconds since page load — never stops, even when paused |
uTrackTime | float | current position in the playing track, seconds |
uDuration | float | track length in seconds (0 when nothing loaded) |
uTimeDelta | float | seconds since the previous frame |
uFrame | float | frame counter since shader compile |
uBands | vec4 | smoothed band levels 0..1: x bass · y low mid · z high mid · w treble |
uChrono | vec4 | time integral of uBands — "audio time" that accelerates with the music and never jumps back. use for rotation/travel speed instead of uTime * uBands.x |
uEnergy | float | overall RMS loudness 0..1 |
uBeat | float | jumps to 1 on a bass onset, decays exponentially to 0 |
uDrops | float | count of detected drops (strong onsets, min 2.5s apart; forced every 40s on quiet material). hash it for a per-drop random seed — see the mutator built-in |
uDropAge | float | seconds since the last drop — drive flashes and morphs from it |
uAudioTex | sampler2D | 512×6 audio data texture (see below) |
uAudioST | sampler2D | 512×2 Shadertoy-layout music texture |
uPrevFrame | sampler2D | the previous rendered frame — feedback buffer for trails and persistence |
uImage | sampler2D | user image texture attached via img in the editor (1×1 white if none) |
uImageSize | vec2 | attached image size in px |
band ranges (approximate, AudioLink-style): bass 20–250 Hz,
low mid 250–1000 Hz, high mid 1–4 kHz, treble 4–13 kHz.
smoothing is fast-attack / slow-release, so uBands is already
pleasant to look at without extra filtering.
helper functions
declared in the header, ready to call:
float fft(float x); // spectrum at x = 0..1 (linear bins, ~0..12 kHz), returns 0..1
float wave(float x); // waveform at x = 0..1, returns -1..1
float bandHist(float band, float age);
// band 0..3 (bass..treble), age 0.0 = now → 1.0 = ~8.5 s ago, returns 0..1 examples:
// spectrum curve as a line
float v = fft(uv.x);
float line = exp(-abs(uv.y - v * 0.7) * 60.0);
// oscilloscope
float w = wave(uv.x) * 0.25 + 0.5;
float scope = exp(-abs(uv.y - w) * 80.0);
// bass trail scrolling into the past
float trail = bandHist(0.0, uv.x);
note: fft() takes a linear frequency axis — most of the musical
action lives in the first ~15%. for display, remap with a power curve:
fft(pow(uv.x, 2.0)).
audio texture layout
uAudioTex is 512×6, single channel (.r). rows bottom to top:
| row | v coordinate | content |
|---|---|---|
| 0 | 0.5 / 6.0 | FFT spectrum, 512 bins |
| 1 | 1.5 / 6.0 | waveform, 512 samples (0.5 = silence) |
| 2 | 2.5 / 6.0 | bass history (x = age) |
| 3 | 3.5 / 6.0 | low mid history |
| 4 | 4.5 / 6.0 | high mid history |
| 5 | 5.5 / 6.0 | treble history |
the helpers sample exact row centers so there's no bleed between rows. if you sample manually, use those v values. history spans ~8.5 s at 60 fps (512 frames), newest at x = 0.
feedback & trails
every frame is rendered into an offscreen buffer, and the previous frame is
always available as uPrevFrame. that's how you build trails, motion
smear, persistence, reaction–diffusion — anything that accumulates over time:
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
// draw something fresh
float f = exp(-abs(uv.y - 0.5 - wave(uv.x) * 0.2) * 60.0);
// previous frame, slightly zoomed toward center = trails flying outward
vec2 fbUv = (uv - 0.5) * 0.995 + 0.5;
float prev = texture(uPrevFrame, fbUv).a * 0.95; // 0.95 = decay
float acc = max(f, prev);
fragColor = vec4(vec3(acc), acc); // keep the raw field in alpha
}
the alpha channel is preserved in the feedback buffer but ignored on screen —
the usual trick is to keep your raw accumulator in .a and the pretty
colorized version in .rgb (see the kaifu built-in: spectrum arms
accumulate in alpha, gradient-map coloring reads it back).
transform fbUv before sampling for directional motion: zoom = radial
trails, rotation = spirals, offset = wind.
custom controls (@param / @color)
declare a uniform with an annotation comment and the player generates UI for it automatically — the tune button appears on the stage:
uniform float uSpeed; // @param 1.0 0.0 4.0 ← default min max
uniform float uCount; // @param 3.0 1.0 8.0 1.0 ← + step (1.0 = integer slider)
uniform float uScale; // @param 2.0 ← default only (range auto)
uniform vec3 uTint; // @color #a78bda ← color picker values are saved per shader and sync live to the output window — tweak colors mid-stream without touching code. reset in the panel returns defaults.
image texture
the img button in the editor attaches an image from disk to the current
shader — available as uImage (and iChannel2 in
shadertoy-style code). stored locally in the browser, synced to the output window.
classic use: gradient maps —
// colorize luma through a horizontal gradient image (vizzy-style)
float l = dot(someColor, vec3(0.299, 0.587, 0.114));
vec3 mapped = texture(uImage, vec2(l, 0.5)).rgb;
click img again to detach. with no image attached uImage
is 1×1 white, so multiplying by it is always safe.
porting from shadertoy
paste Shadertoy code as-is. if the source defines mainImage() and has no
main(), the player automatically adds a compat layer:
iTime → uTime
iResolution → vec3(uResolution, 1.0)
iChannel0/1 → uAudioST (512×2 music texture)
iChannel2 → uImage (attached image texture)
iChannel3 → uPrevFrame (feedback buffer)
iTimeDelta → uTimeDelta
iFrame → int(uFrame)
iMouse → vec4(0.0)
iSampleRate → 44100.0 uAudioST matches Shadertoy's music texture exactly:
texture(iChannel0, vec2(x, 0.25)).x is spectrum,
vec2(x, 0.75) is waveform. any shader written against
soundcloud input on Shadertoy works unchanged.
what doesn't port:
- multipass shaders (Buffer A/B/C/D) — single pass only, for now
- image/video/cubemap channels — the only input is audio
iMouseinteraction — fixed at zero- keyboard texture, VR entry points
bonus: since the compat layer is just defines, you can freely mix — use
mainImage() structure and still call uBeat or bandHist().
coming from audiolink
if you write AudioLink shaders for VRChat, the mapping is direct:
| audiolink | player |
|---|---|
AudioLinkData(ALPASS_AUDIOLINK + uint2(0, 0)).r | uBands.x (bass) |
ALPASS_AUDIOLINK band history (x offset) | bandHist(band, age) |
ALPASS_DFT | fft(x) (linear, not note-space) |
ALPASS_WAVEFORM | wave(x) |
AudioLinkGetChronotensity(...) | uChrono — same idea: per-band accumulated time, computed on the CPU |
| filtered/smoothed bands | uBands is already smoothed |
main difference: no persistent state between frames (single stateless pass),
so effects that accumulate (chronotensity, trails) either use
bandHist or fake it with time-warping.
recipes
// beat pulse — scale from center on kick
vec2 uv = (gl_FragCoord.xy * 2.0 - uResolution) / uResolution.y;
uv *= 1.0 - uBeat * 0.08;
// bass-driven camera speed — WRONG way:
float z = uTime * (0.5 + uBands.x * 2.0); // jerky — bass multiplies all of time
// RIGHT way — uChrono is the integral, so bass changes speed, not position:
float z2 = uTime * 0.5 + uChrono.x * 2.0; // smooth acceleration
// waveform ring
float a = atan(uv.y, uv.x) / 6.2831853 + 0.5;
float r = 0.6 + wave(a) * 0.1;
float ring = exp(-abs(length(uv) - r) * 50.0);
// treble sparkle
float sp = step(0.995, fract(sin(dot(floor(uv * 80.0), vec2(12.9898, 78.233))) * 43758.5453)
+ uBands.w * 0.003);
// track progress bar
float progress = uDuration > 0.0 ? uTrackTime / uDuration : 0.0;
// genome pattern (see the mutator built-in): per-drop random values that
// morph smoothly into the next genome — stateless evolution
float gene(float i) {
float prev = fract(sin(dot(vec2(uDrops - 1.0, i), vec2(12.99, 78.23))) * 43758.5);
float cur = fract(sin(dot(vec2(uDrops, i), vec2(12.99, 78.23))) * 43758.5);
return mix(prev, cur, smoothstep(0.0, 1.2, uDropAge));
} obs / streaming workflow
- on /player hit output ↗ — a clean window opens: no UI, just the visual. double-click it for fullscreen.
- hover the output window: 720p / 1080p / 1440p presets resize it to an exact capture size (shown in the corner), or drag it manually.
- in OBS add a Window Capture source and pick that window ("player output").
- audio: capture your desktop audio as usual (the player outputs through your default device).
- the /player tab can be backgrounded freely — audio frames keep pumping to the output while music plays (audible tabs aren't throttled by chrome).
if the output window gets covered by a game or another fullscreen app, chrome stops rendering occluded windows and the capture freezes. two ways out:
- pip button (recommended) — moves the visual into an always-on-top Picture-in-Picture window. it's never occluded and never throttled; capture that window in OBS and put whatever you want over it.
- launch chrome with
--disable-backgrounding-occluded-windows --disable-features=CalculateNativeWinOcclusion— then regular windows keep rendering under cover. heavier hammer, affects the whole browser.
performance notes
- the canvas renders at device-pixel-ratio capped at 2. fullscreen 4k + heavy raymarching = your GPU's problem, budget accordingly.
- keep loops bounded and small — this is a fullscreen pass, every instruction runs per-pixel.
uBands+bandHistare almost always enough; samplefft()per-pixel only when the visual is literally a spectrum.- shaders are plain GLSL ES 3.00: no
#include, and derivatives (dFdx/fwidth) work as usual.
troubleshooting
- black screen, editor says ok — you're probably outputting alpha 0 somewhere or dividing by zero on the first frame (uDuration is 0 with no track).
- visual frozen — visuals animate from
uTimeeven without audio; if literally frozen, check the console for a lost WebGL context. - no audio reaction — press play. analysis starts with the first playback (browser autoplay policy: the audio graph can't exist before a user gesture).
- library empty after restart — hit unlock; the browser requires one gesture to re-grant file access per session. chromium-only feature — firefox falls back to session-only files.