A raymarched Mandelbulb in WGSL
Rendering the power-8 Mandelbulb — the 3D analog of the Mandelbrot set — as a distance estimator raymarched in a fragment shader, with orbit-trap coloring that tracks the active theme.
The Mandelbulb is the scene in /lab that I keep coming back to. It is a genuine 3D fractal — not a height-mapped texture, not a trick, but an actual surface you can fly into and watch resolve into ever-finer spikes. It reuses the exact same fullscreen-triangle and raymarch scaffolding as the metaballs and tunnel scenes; the only thing that changes is the distance function. That is the whole appeal of distance-estimated rendering: swap one function and you get an entirely different universe.
What the Mandelbulb is
The Mandelbrot set lives in 2D: iterate z = z^2 + c over complex numbers and ask whether the sequence escapes to infinity. The Mandelbulb is the 3D extension. There is no clean 3D analog of complex multiplication, so it cheats — it treats a 3D point in spherical coordinates and defines a "power" operation that scales the radius and multiplies the two angles. The canonical version uses power 8, which gives the bulbous, brain-coral look with eight-fold symmetry. Lower powers look smoother and less interesting; power 8 is the one everyone renders.
The distance estimator
You cannot raymarch a fractal by stepping a fixed amount — you would either crawl forever or tunnel straight through the surface. Instead you need a distance estimator: a function that, given a point in space, returns a safe lower bound on the distance to the nearest part of the surface. For escape-time fractals there is a beautiful closed form using the running derivative of the iteration.
You iterate as normal, but alongside the point z you track dr, the magnitude of the derivative of the map. After the iteration escapes (or you hit the cap), the distance estimate is 0.5 * log(r) * r / dr:
fn mandelbulb(pos: vec3f) -> vec2f {
var z = pos;
var dr: f32 = 1.0;
var r: f32 = 0.0;
var trap: f32 = 1e10;
let power: f32 = 8.0;
let iters: i32 = i32(clamp(6.0 + u.knob * 6.0, 4.0, 14.0));
for (var i: i32 = 0; i < iters; i = i + 1) {
r = length(z);
if (r > 2.0) { break; }
let theta = acos(z.y / r);
let phi = atan2(z.z, z.x);
dr = pow(r, power - 1.0) * power * dr + 1.0;
let zr = pow(r, power);
let nt = theta * power;
let np = phi * power;
z = zr * vec3f(sin(nt) * cos(np), cos(nt), sin(nt) * sin(np)) + pos;
trap = min(trap, length(z));
}
let d = 0.5 * log(r) * r / dr;
return vec2f(d, trap);
}The function returns two values packed in a vec2f: the distance estimate in .x and an orbit-trap value in .y (more on that below). The theta/phi lines are the spherical decomposition; the dr update is the chain rule applied to the power operation; the final formula is the standard analytic distance estimate for this class of fractal.
The raymarch loop
From here it is the same machine as every other SDF scene in the lab. A fullscreen triangle covers the screen, the fragment shader builds a ray from the camera through each pixel, and a loop marches forward by the distance estimate each step:
var t: f32 = 0.0;
var hit: bool = false;
var trap: f32 = 0.0;
for (var i: i32 = 0; i < 96; i = i + 1) {
let p = ro + rd * t;
let m = map(p);
if (m.x < 0.0015) { hit = true; trap = m.y; break; }
if (t > 6.0) { break; }
t = t + m.x * 0.85;
}Two details earn their keep. The step is m.x * 0.85 rather than the full distance — distance estimators for fractals slightly overestimate near the spiky bits, and stepping the full amount makes the surface dissolve into noise, so I take 85% of each step as a safety margin. And 96 iterations is the march cap; fractal surfaces need more steps than a smooth sphere because the rays graze a lot of near-misses around the spikes.
The normal comes from central differences — six extra distance evaluations sampling the gradient — the same calcNormal trick used everywhere else in the sandbox.
Orbit-trap coloring
A fractal rendered with flat lighting is a gray blob. The trick that makes it read as structure is orbit trapping: during the iteration, track the closest the orbit ever came to the origin (trap = min(trap, length(z))). Different surface points have wildly different trap values, and that variation paints the fractal. I use it to drive the mix between the two theme tints:
let mixT = clamp(trap * 0.7, 0.0, 1.0);
let surface = mix(u.tintA, u.tintB, mixT);Pick CRT from the theme switcher and the bulb goes phosphor green; pick Sunset and the same trap field paints amber and coral. The geometry is identical — only the two uniform colors change.
The iteration knob
The lab's slider maps to the iteration count: clamp(6.0 + u.knob * 6.0, 4.0, 14.0). This is a direct quality-versus-cost dial. Each additional iteration sharpens the spikes and reveals finer self-similar detail, because the distance estimate gets more accurate near the true surface. It also costs a full extra pass of pow, acos, and atan2 per ray, per march step — the most expensive arithmetic in the shader. At 4 iterations the bulb is a soft lump; at 14 it is crisp and crystalline and the GPU is working noticeably harder. Most of the time 10-ish is the sweet spot.
Slow rotation
There is no orbit camera. The whole point is rotated slowly around the Y axis before the distance function runs, so the bulb turns on its own and you see it from every side without touching anything:
let a = u.time * 0.25 * spinMul;
let q = rotY(p, a + mx);
return mandelbulb(q);Horizontal pointer movement adds to that angle so you can spin it by hand, and audio level nudges the spin speed when the mic is on. But left alone it just rotates, which is exactly what you want from something this hypnotic.
Try it — and crank the iteration slider — at /lab.