Milkdrop preset  ·  projectM  ·  making-of

mercurytesseract

A hypercube in liquid metal. Thirty-two edges unioned as one liquid, turning in two planes at once.

32 edges  ·  16 vertices  ·  solved per pixel
turns in x-y and z-w  ·  pulses with the bass
scroll
a note before you start

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, verbatim

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

render passes
7
from a fused potato to liquid metal
silent failures
1
the engine rendered something that wasn’t the preset
gpu cost
0%
same frame rate as an empty preset

01 — the decision

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:

stagewhat it does
1 · per_frameequations, once per frame
2 · per_pixela UV warp field over a mesh
3 · warp shadersamples last frame through that field — this is the feedback loop
4 · waveswaveforms and custom shapes drawn on top
5 · compositeturns 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.


02 — the maths

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.


03 — the trap

The engine that lies to you

A soft grey cloud of blobs on black — not the intended preset.
First load. This is not the preset — and it is not an error screen either.

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:

LOAD FAILED — screenshots below would be the PREVIOUS preset. { "ok": false, "stderr": ["[Composite Shader] … HLSL parsing failed."] }

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:

OK +setup OK +vertices FAIL +edges <-- here

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


04 — render one

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.

Left: fat fused tubes. Right: slender liquid tubes.
Radius 0.038 → 0.0165. Critically, the smooth-minimum blend radius had to come down with it (0.021 → 0.0125) — that constant fills the corner where two tubes meet, and left large while the tubes get thin, the cells weld shut anyway.

05 — the material

Slender, and disappointingly matte

A slender tesseract wireframe that reads as dark plastic.
Real blacks, the inner cube visible — and it reads as dark plastic.

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.


06 — the bug

The one you can only see at 300%

That render looked good at a glance. Zoomed into a joint, it wasn’t.

Left: hard creases radiating from each joint. Right: smooth liquid fillets.
Hard creases radiating out of every vertex, and flat plates where four tubes met. Two separate causes.

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.


07 — the pulse

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 doeshow
the figure inflatesq17 = 1 + 0.055*swell + 0.05*flash on the vertex coordinates
the tubes gain massq19 = 0.0165*(1 + 0.20*swell + 0.24*flash) on tube radius
a swell travels outwarda ring resets on each onset and thickens the liquid as it passes
the figure recoilsa ~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.


08 — proof

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.

Six frames across the rotation cycle, all legible.
Six phases across one full cycle. The inner cell grows, inverts through the outer one, and re-forms — and never turns to mush.
bass response
+15.3%
lit-pixel area swing, sampled 18× across several beats at 128 BPM
frame rate
120/s
identical to a trivial preset — three texture fetches in the whole thing
framing
72–85%
figure height across eight phases, after measuring it at 90% and pulling the scale back

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.


09 — the card

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.

1280×720 · 8s · 60fps · the same clip this page serves as og:video

10 — take-aways

Three findings that transfer

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.