← player

// 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

uniformtypemeaning
uResolutionvec2canvas size in physical pixels
uTimefloatseconds since page load — never stops, even when paused
uTrackTimefloatcurrent position in the playing track, seconds
uDurationfloattrack length in seconds (0 when nothing loaded)
uTimeDeltafloatseconds since the previous frame
uFramefloatframe counter since shader compile
uBandsvec4smoothed band levels 0..1: x bass · y low mid · z high mid · w treble
uChronovec4time integral of uBands — "audio time" that accelerates with the music and never jumps back. use for rotation/travel speed instead of uTime * uBands.x
uEnergyfloatoverall RMS loudness 0..1
uBeatfloatjumps to 1 on a bass onset, decays exponentially to 0
uDropsfloatcount 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
uDropAgefloatseconds since the last drop — drive flashes and morphs from it
uAudioTexsampler2D512×6 audio data texture (see below)
uAudioSTsampler2D512×2 Shadertoy-layout music texture
uPrevFramesampler2Dthe previous rendered frame — feedback buffer for trails and persistence
uImagesampler2Duser image texture attached via img in the editor (1×1 white if none)
uImageSizevec2attached 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:

rowv coordinatecontent
00.5 / 6.0FFT spectrum, 512 bins
11.5 / 6.0waveform, 512 samples (0.5 = silence)
22.5 / 6.0bass history (x = age)
33.5 / 6.0low mid history
44.5 / 6.0high mid history
55.5 / 6.0treble 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
  • iMouse interaction — 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:

audiolinkplayer
AudioLinkData(ALPASS_AUDIOLINK + uint2(0, 0)).ruBands.x (bass)
ALPASS_AUDIOLINK band history (x offset)bandHist(band, age)
ALPASS_DFTfft(x) (linear, not note-space)
ALPASS_WAVEFORMwave(x)
AudioLinkGetChronotensity(...)uChrono — same idea: per-band accumulated time, computed on the CPU
filtered/smoothed bandsuBands 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

  1. on /player hit output ↗ — a clean window opens: no UI, just the visual. double-click it for fullscreen.
  2. hover the output window: 720p / 1080p / 1440p presets resize it to an exact capture size (shown in the corner), or drag it manually.
  3. in OBS add a Window Capture source and pick that window ("player output").
  4. audio: capture your desktop audio as usual (the player outputs through your default device).
  5. 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 + bandHist are almost always enough; sample fft() 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 uTime even 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.