This was born out of a love for old-school Winamp visualizations and hypercubes. I got to learn about the math behind shaders and multidimensional objects. So much fun. I hope you enjoy it.
“make a milkdrop visual that looks smooth and liquid and acts like a hypercube rotating on the w-z and x-y axes and it pulses with the bass”
the brief, verbatimThree requirements, and they pull in different directions. A hypercube is a wireframe — 16 vertices, 32 straight edges, the most rigid thing there is. “Smooth and liquid” is the opposite: soft, merging, no corners anywhere.
The whole build is the search for a form that satisfies both, and the answer turned out to be mercury — a metal that holds a shape and still wants to be a drop.
Where does a hypercube live?
MilkDrop runs a fixed pipeline every frame, and picking the wrong stage for an effect costs you the whole build:
| stage | what it does |
|---|---|
| 1 · per_frame | equations, once per frame |
| 2 · per_pixel | a UV warp field over a mesh |
| 3 · warp shader | samples last frame through that field — this is the feedback loop |
| 4 · waves | waveforms and custom shapes drawn on top |
| 5 · composite | turns the buffer into the pixels you see |
The obvious route is stage 4: draw the 32 edges as line strips. Cheap, idiomatic — and it gives you lines. A line, however you blur it, never becomes a liquid. You’d be smearing a wireframe, not rendering a material.
So the geometry went into the composite shader as a signed distance field. That buys the one thing lines can’t: a real surface, with a real normal, that can be lit like metal. The two shaders then split by job — the warp shader owns the wake, the soft mass the figure sheds as it turns; the composite owns the material.
Sixteen vertices, no lookup table
A tesseract vertex is just a sign pattern of four bits, so vertex i decodes with arithmetic:
float4 v4 = (float4(fmod(fi,2.0), fmod(floor(fi*0.5),2.0),
fmod(floor(fi*0.25),2.0), fmod(floor(fi*0.125),2.0))*2.0 - 1.0)*q17;
Then the two rotations the brief named, a viewing tilt, and — the important part — two perspective divides: 4D→3D through w, then 3D→2D through z.
float x1 = v4.x*q9 - v4.y*q10; // x-y plane
float y1 = v4.x*q10 + v4.y*q9;
float z1 = v4.z*q11 - v4.w*q12; // z-w plane
float w1 = v4.z*q12 + v4.w*q11;
...
float k4 = 1.0/max(3.00 - w1, 0.40); // 4D -> 3D
float k3 = 1.0/max(3.40 - zb*k4, 0.40); // 3D -> 2D
The nested divide is what produces the famous cube-inside-a-cube: vertices further away in w shrink before the 3D projection ever sees them. And k4*k3 — how near a vertex is in both projections at once — became the most useful number in the shader. It drives tube radius, which tube wins an overlap, and brightness.
The tilt is not decoration. Without it the tesseract collapses into four concentric squares.
Rotating only in x-y and z-w leaves both planes axis-aligned with the projection. A fixed tilt, with a 30-second drift on top, is what makes the same two rotations read as depth.
The engine that lies to you
The composite shader failed to compile. The warp shader compiled fine, so libprojectM quietly fell back for the composite stage and carried on rendering. What you’re looking at is the wake layer alone, accumulating in the feedback buffer. In a running session with a previous preset loaded, you’d have got that previous preset instead — equally silently.
A broken preset does not look broken. It looks like a working preset, so you screenshot it, judge it, and start tuning a file the engine never loaded. The local harness exists for exactly this — it diffs the engine’s error counter across the load and filters stderr:
No line number. No message. So: probe eleven suspect HLSL features in isolation — arrays, dynamic indexing, nested loops, exp2, float4 constructors from fmod. All eleven passed. Then load cumulative prefixes of the real shader:
…then bisect inside that stage down to a single line. The culprit:
float rad = q19*pow(pz*7.7, 0.55)*ring;
rad and ang are pre-declared shader inputs in projectM, exactly like ret is. Redeclaring one kills the whole shader with no diagnostic. Renaming it to trad fixed it outright.
It’s a tesseract, and it’s a potato
The geometry was right first time — a genuine 4D perspective projection, already rotating correctly. Everything else was wrong. Tube radius 0.038 in a frame 1.0 tall meant the 32 tubes fused into one lumpy mass; the specular clipped to pure white across whole regions; the wake flooded the frame to a blue haze.
Slender, and disappointingly matte
Blinn-Phong alone doesn’t make metal. Metal is almost entirely reflection. So the body colour became an environment looked up by the surface normal: dark below, cool grey above, and one bright horizon band.
float hz = (nrm.y + 0.06)*5.2;
float3 env = lerp(float3(0.010, 0.016, 0.034), float3(0.26, 0.33, 0.45),
saturate(nrm.y*0.70 + 0.52));
env += float3(0.62, 0.70, 0.84)*exp(-hz*hz)*0.62;
Because the normal sweeps a full hemisphere across a tube’s cross-section, that one band draws itself as a highlight running the length of every tube. It says “metal” far louder than the specular does, and it costs four instructions.
The one you can only see at 300%
That render looked good at a glance. Zoomed into a joint, it wasn’t.
Cause 1 — renormalising the gradient
The surface normal comes from the blended gradient of the distance field. On the saddle between two tubes the two gradients point in opposite directions and cancel — and that is correct: a fillet there really is flat. Renormalising divides that near-zero vector back up to unit length, amplifying numerical noise into a knife edge. The fix is a deletion:
- float2 gn = G/max(length(G), 1e-5);
- float3 nrm = normalize(float3(gn*sl*1.25, 1.0));
+ float3 nrm = normalize(float3(G*sl*1.25, 1.0)); // use G at its own length
Cause 2 — the joints had nowhere to bulge
Surface height was computed against a single tube’s radius, so wherever the union ran deeper than one tube, the height saturated and the joint rendered as a flat lid. Letting the effective radius grow with the depth of the union turns each junction into one swollen drop:
float Reff = max(RB, -Dm);
float u = saturate(1.0 + Dm/max(Reff, 1e-4));
That is the moment it stopped being a wireframe with a shader on it and started being liquid.
Reacting without blinking
The house rule on this project: audio reactivity is expressed through geometry and material, never brightness. Anything that blinks per frame reads as cheap. So the bass moves matter, four ways.
| what the bass does | how |
|---|---|
| the figure inflates | q17 = 1 + 0.055*swell + 0.05*flash on the vertex coordinates |
| the tubes gain mass | q19 = 0.0165*(1 + 0.20*swell + 0.24*flash) on tube radius |
| a swell travels outward | a ring resets on each onset and thickens the liquid as it passes |
| the figure recoils | a ~2% scale dip toward the eye |
All of it runs on an adaptive peak follower, so quiet material drives it as hard as loud material does, and the loudness signal carries a ~3-second release so the response is continuous rather than snappy. The one place an onset touched brightness — a 2× specular boost — got pulled back to 1.3× in the final pass. It was reading as a flash.
Verifying, rather than admiring
Does it survive every phase of the z-w rotation? The figure turns through itself every ~11 seconds; a projection that degenerates at some angle only shows up if you look.
That last one came out of the same check. By eye the composition had looked fine; measured at eight rotation phases it was reaching 90% of the frame at its widest. The piece is supposed to hold generous negative space. Scale went 1.42 → 1.32. That is the kind of thing you only catch by measuring it.
And the link preview
A preview card has one job at 350px wide: say what this is, beautifully. Built in HyperFrames — full-bleed footage of the figure, a type column that reveals in the same register as the preset itself: slow, masked, never a flash. It loops on an 8-second breath.
Three findings that transfer
radandangare pre-declared shader inputs. Redeclaring either kills the shader silently, exactly like redeclaringret.- Never renormalise a blended distance-field gradient. Where it cancels, the surface is genuinely flat; renormalising there draws a crease down every seam of the union.
- Let the effective radius grow with the depth of a union (
max(R, -D)) and blobby joints come out domed instead of flat-lidded.
The finished piece
presets/bb - mercury tesseract.milk — 350 lines, two shaders, no lookup tables, no external textures.
A cube inside a cube in mercury, turning in two planes at once. The x-y rotation takes about 60 seconds; the z-w rotation pushes the inner cube out through the outer one every 11. A slow noise volume displaces the whole domain so the silhouette undulates and the tubes gather and thin along their length. Where edges meet, surface tension pulls them into a drop. The bass swells it.