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;

▶ Run the identity warp

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;

▶ Run the ripple

This is Track 4's radial-rings trick, rewritten for the GPU:

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);

▶ Run the tint

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

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."