Track 1 — How MilkDrop thinks
A MilkDrop preset is a text file of small equations. The engine runs them every frame, sixty times a second, and the numbers they produce steer the picture. Before learning any variable names, learn the one idea that makes every preset make sense.
Lesson 1 · The screen is the memory
Every effect MilkDrop has ever produced — tunnels, trails, plasma, smoke — comes from one move, repeated forever:
- Take the image currently on screen.
- Redraw it, slightly transformed.
- Draw this frame's audio waveform on top.
- Repeat.
Start at the bench: a bright waveform and nothing else. The transform is "do nothing" (zoom=1), so old frames sit exactly where they were and slowly fade.
Now the entire lesson, in one changed line:
zoom=1.01
Each frame, the previous image is redrawn 1% larger. One percent sounds like nothing, but it compounds sixty times a second — after one second the oldest paint is ~80% bigger and still growing. Every waveform the music draws becomes a trail streaming toward you.
Reverse it:
zoom=0.99
Now history collapses toward the center instead.
If you know Shadertoy: a fragment shader there is stateless — every frame is computed from scratch out of
timeand coordinates. MilkDrop is the opposite: every frame is a function of the previous frame. That's why a single constant produces motion, and why intuition from stateless shaders doesn't transfer until you internalize the loop.
Turn one knob. In either run link, edit the zoom value in the editor (it applies as you type — Cmd/Ctrl+Enter forces it). Find the value where motion is barely perceptible. Most of the presets you'll love live within a few percent of 1.
Lesson 2 · The pipeline
The loop has stations, and each section of a preset file runs at exactly one of them:
Diagram (text form)
flowchart LR
A[audio in] --> PF[per_frame equations<br/>once per frame]
PF --> PV[per_pixel equations<br/>once per mesh point]
PV --> WS[warp shader<br/>optional GLSL]
WS --> DRAW[draw waveform,<br/>custom waves, shapes]
DRAW --> CS[comp shader<br/>optional GLSL]
CS --> OUT[screen]
OUT -. becomes next frame's input .-> PF| Stage | Runs | You write it as | Typical job |
|---|---|---|---|
| per-frame | once per frame | per_frame_N= lines |
Read audio, set the motion/color knobs |
| per-pixel | once per mesh point per frame | per_pixel_N= lines |
Vary those knobs across the screen (Track 4) |
| warp shader | every pixel | [warp_shader] block |
Distort the previous frame (Track 6) |
| draw | once per frame | wave/shape settings + equations | Add the new ink: waveform, custom waves, shapes (Track 5) |
| comp shader | every pixel | [comp_shader] block |
Final color grade of everything (Track 6) |
Everything in Tracks 1–2 happens at the first station. zoom in the file is just the starting value of a knob; per_frame code can overwrite it every frame — that's Lesson 4.
Lesson 3 · decay — the fade knob
Before the old image is redrawn, its brightness is multiplied by decay (set in the file as fDecay). It's the eraser that balances the loop's paint.
fDecay=1 // nothing ever fades
▶ Run decay=1.0 — with zoom=1.01, paint accumulates until the screen saturates. No eraser, all paint.
fDecay=0.9 // 10% gone every frame
▶ Run decay=0.9 — history vanishes in a blink; the waveform wears a short comet tail.
The useful range is narrow: 0.96–0.995 covers almost everything. The bench uses 0.98. Motion (zoom, rot, …) spreads paint around; decay decides how long the paint survives the trip. Every trail length you've ever seen in a visualizer is this one number negotiating with the motion knobs.
Lesson 4 · time is the heartbeat
So far the motion is frozen — the same transform every frame. per_frame equations unfreeze it. They run once per frame, before anything is drawn, and can overwrite any knob:
per_frame_1=zoom = 1 + 0.02*sin(time*0.8);
time is seconds since the preset started. sin(time*0.8) swings smoothly between −1 and 1, so zoom breathes between 0.98 and 1.02 — the image inhales and exhales about once every eight seconds.
Three timing variables cover nearly everything:
| Variable | What it is | Reach for it when |
|---|---|---|
time |
seconds, fractional | smooth motion — sin(time*k) is the heartbeat of the entire art form |
frame |
frames since start, integer | counting and stepping — frame%40 fires every 40th frame |
fps |
current frame rate | correcting speed so the preset runs identically on slow and fast machines — the classic idiom is a (75/fps) factor on anything accumulated per frame |
Language notes you now need (the expression language is NS-EEL, inherited from Winamp):
- Every statement is an assignment ending in
;. No declarations — assign to any name and it exists. - Everything is a number. There are no strings or booleans; comparisons like
equal(a,b)andabove(a,b)return 1 or 0, and anything within 0.00001 of zero counts as false. - Names are case-insensitive.
Turn one knob. In the breathing loop, change 0.02 (depth of breath), then 0.8 (breathing rate). Then try zoom = 1 + 0.02*sin(time*0.8) + 0.01*sin(time*1.13); — two sines at unrelated frequencies never quite repeat. That trick has a name and a pedigree; you'll meet it properly in the next track.
What you can now predict
- Why
zoom=1.01makes trails stream rather than jump: compounding. - Why a bright flash lingers:
decayis the only eraser. - Why nothing in MilkDrop needs a "trail" feature: trails are what a feedback loop is.
- What any
per_frameline does: overwrite a knob before this frame's redraw.
Next: Track 2 — Motion. Every remaining motion knob, one at a time, then a line-by-line read of a real Geiss preset.