Track 6 — Shaders
Everything through Track 5 is EEL: small equations, evaluated on the CPU-side VM, one value per knob per frame (or per pixel, at most). Shaders are different in kind, not just in syntax — GLSL code that runs on the GPU, once per screen pixel, every frame. This is where reaction-diffusion, edge detection, and fractal iteration live: effects that need every pixel to see its neighbors. EEL's per-pixel context can't do that.
64% of the bundled catalog uses this pair of shaders. This is the cliff every existing MilkDrop resource has left unbridged — the authoring docs assessment found exactly two worked shader examples in the entire canonical reference. Here's the missing tutorial.
Lesson 0 · A language note, stated plainly
MilkDrop's original shaders are HLSL (DirectX). Stims compiles a GLSL 1.20 dialect, not HLSL. Presets written against real MilkDrop/Winamp use HLSL syntax that Stims's parser accepts where the two languages overlap (which is most day-to-day code — the examples below are valid in both). Where they diverge, Stims has firm limits: no matrix uniforms, no texture2D() with an explicit LOD argument, and no gl_FragCoord. Every example in this track stays inside those limits and inside GLSL 1.20 (texture2D, not texture).
Lesson 1 · The smallest possible warp shader
[warp_shader]
ret = uv;
Compare this against the shaderless bench — they should look identical. uv is the pixel's own texture coordinate, 0..1 across the screen; ret is the coordinate the warp shader hands back for sampling the previous frame. Returning uv unchanged is a no-op, same as dx=0; dy=0; zoom=1 in EEL — but now you've seen the minimum shape every warp shader must have: read (or derive) a coordinate and assign it to ret.
Lesson 2 · Distorting the sample coordinate
vec2 center = uv - vec2(0.5, 0.5);
float dist = length(center);
float ripple = 0.01 * sin(dist * 40.0 - time * 2.0);
vec2 dir = center / (dist + 0.0001);
ret = uv + dir * ripple;
This is Track 4's radial-rings trick, rewritten for the GPU:
center/dist— the same recentering move as Track 4's off-center-current lesson, just written with GLSL'svec2/length()instead of hand-rolledsqrt(x*x+y*y).ripple— a sine of distance, the same idea assin(rad*12+time*0.5)in EEL; the coefficient ondistsets how many rings fit, and the coefficient ontimesets their speed. The minus sign sends these rings outward, the opposite of Track 4's+time.dir = center / (dist + 0.0001)— a unit vector pointing away from center; the+0.0001is the same divide-by-zero guard Track 4's off-center lesson used, just in GLSL now.ret = uv + dir * ripple— push the sample coordinate along that direction by the ripple amount. This is what "warp" means concretely: you're not moving pixels; you're changing which previous-frame pixel gets sampled into this one.
The EEL and GLSL versions of the same idea read almost identically once you know the vocabulary swap: rad/ang become length()/normalize-by-division, and dx/dy become an offset added to uv.
Lesson 3 · The smallest possible composite shader
[comp_shader]
vec3 c = texture2D(sampler_main, uv).rgb;
ret = c * vec3(1.0, 0.85, 0.6);
comp_shader runs after the warp shader and after waves/shapes are drawn — it's the last stop before the pixel hits the screen. sampler_main is the composited frame so far; texture2D(sampler_main, uv).rgb reads this pixel's color. Multiplying by vec3(1.0, 0.85, 0.6) is a tone-map: full red, 85% green, and 60% blue — a warm amber grade over everything Tracks 1–5 already produced. Where warp shaders answer "sample from where?", comp shaders answer "what color should this end up?"
Lesson 4 · Reading neighbors — the thing EEL can't do
float dx = texture2D(sampler_main, uv + vec2(1.0 / texsize.x, 0.0)).x - texture2D(sampler_main, uv + vec2(-1.0 / texsize.x, 0.0)).x;
float dy = texture2D(sampler_main, uv + vec2(0.0, 1.0 / texsize.y)).x - texture2D(sampler_main, uv + vec2(0.0, -1.0 / texsize.y)).x;
float edge = length(vec2(dx, dy)) * 6.0;
vec3 sharpv = texture2D(sampler_main, uv).rgb;
vec3 blurred = texture2D(sampler_blur1, uv).rgb;
ret = abs(sharpv - blurred) * edge;
▶ Run edge detection — Pattern 10 from the coding guide.
texsize is the render target's pixel dimensions, so 1.0 / texsize.x is exactly one texel's width. dx/dy here sample one texel to each side and subtract — a gradient. Where the image is flat, both differences are near zero; where there's a hard edge, one of them spikes. length(vec2(dx, dy)) combines both directions into a single edge strength.
The second half is a high-pass filter: sampler_blur1 is a pre-blurred version of the same frame that Stims computes for you every frame (part of the built-in blur chain, alongside blur2/blur3 at increasing radii). sharpv - blurred cancels out everything low-frequency (broad color regions) and leaves only fine detail — multiplying that by edge picks out detail specifically at hard boundaries. This is the one thing this entire track exists for: no EEL context — not even per-pixel — can read a neighboring pixel's value. Only a shader can, because only the shader runs after the whole frame already exists.
Lesson 5 · Crossing the q-var bridge into GLSL
The same bridge from Tracks 4 and 5 — smooth in per_frame, write a q-var — works into shaders too. q1–q8 (and up to q32) are available as float uniforms inside both warp_shader and comp_shader:
per_frame_1=ra=6/fps;
per_frame_2=bass_avg=bass_avg*(1-ra)+ra*bass;
per_frame_3=q1=bass_avg;
[warp_shader]
vec2 center = uv - vec2(0.5, 0.5);
float dist = length(center);
float ripple = (0.005 + 0.02 * q1) * sin(dist * 40.0 - time * 2.0);
vec2 dir = center / (dist + 0.0001);
ret = uv + dir * ripple;
▶ Run the audio-reactive ripple
Nothing new syntactically — q1 is read on the GLSL side exactly like any other identifier, because as far as the shader is concerned it's just a uniform the compiler wires up automatically. This is the whole answer to "how do shaders hear the music": they don't listen — per_frame does, and hands the result across the same q-register bridge every other layer uses.
Engine limits worth knowing before you write your own
- 16 texture samplers total, not the arbitrary custom-texture loading original MilkDrop allowed.
sampler_main(frame buffer),sampler_blur1–sampler_blur3(the blur chain, used above),sampler_noise_lq/sampler_noisevol_hq(2D/3D noise), plus named procedural textures (perlin,simplex,voronoi,pattern,fractal, …). The full list and the alias table for legacy/non-standard names are in the coding guide. - No external texture loading. Every texture is pre-authored and shipped with the app; presets can't reference a URL or a data URI.
sampler_pw_main/sampler_pc_mainresolve topreviousTex— Stims fuses the warp and comp passes into one, unlike the original two-pass pipeline, so "the previous warp pass's output" and "the previous frame's output" are the same buffer here.texture2D()only — no LOD argument, notexture(). No matrix uniforms, nogl_FragCoord.
The full cross-engine picture — what changes if the same preset runs in Winamp, projectM, or Butterchurn instead — is in Track 8.
What you can now build
You can now read and write both halves of the modern preset pipeline: EEL equations for motion, audio, and per-pixel variation, and GLSL shaders for anything that needs to see the whole frame — edge detection, blur-based effects, reaction-diffusion, and fractal iteration. Between this track and Tracks 1–5, there is no longer a construct in the bundled catalog you can't at least partially read.
Next: Track 7 — Taste, where the question stops being "what does this line do" and becomes "why is this preset good."