The Mandelbrot set is generated by a one-line iteration, but rendering
a smooth, real-time zoom into its boundary — a billion times deeper
than the initial view — requires a fragment shader, careful escape-time
coloring, and eventually tricks to outrun 32-bit float precision. This
tutorial builds a full GPU fractal-zoom renderer from scratch.
1Map screen pixels to the complex plane
A full-screen quad and a single fragment shader are all you need — no
geometry, no lighting, just per-pixel math. Set up a WebGL2 context
with one triangle covering the viewport, then map each fragment's
screen coordinate to a point c in the complex plane,
centered on a zoom target and scaled by a zoom factor:
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl2');
const vertexSrc = `#version 300 es
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
// Full-screen triangle: 3 vertices covering the clip-space square
const verts = new Float32Array([-1,-1, 3,-1, -1,3]);
// Uniforms passed per frame:
// uCenter: vec2 (real, imag) — the point being zoomed into
// uZoom: float — current zoom scale (view half-width in complex units)
// uAspect: float — canvas.width / canvas.height
Why a fragment shader: the Mandelbrot iteration is
embarrassingly parallel — every pixel's escape count is completely
independent of every other pixel's. A GPU with thousands of shader
cores evaluates the whole frame simultaneously, which is why a
fragment shader renders in milliseconds what a single-threaded CPU
loop takes seconds to compute.
2The escape-time fragment shader
Inside the fragment shader, convert the fragment's normalized device
coordinate to a complex number c, then iterate
z = z² + c up to a maximum iteration count, tracking
whether |z| exceeds the escape radius (2.0 is
mathematically sufficient, since any orbit that crosses |z|>2
diverges to infinity):
#version 300 es
precision highp float;
out vec4 fragColor;
uniform vec2 uCenter;
uniform float uZoom;
uniform float uAspect;
uniform int uMaxIter;
uniform vec2 uResolution;
void main() {
vec2 uv = (gl_FragCoord.xy / uResolution) * 2.0 - 1.0;
vec2 c = uCenter + vec2(uv.x * uAspect, uv.y) * uZoom;
vec2 z = vec2(0.0);
int iter = 0;
for (int i = 0; i < 1000; i++) {
if (i >= uMaxIter) break;
// Complex square: (a+bi)^2 = a^2 - b^2 + 2abi
float a2 = z.x * z.x, b2 = z.y * z.y;
if (a2 + b2 > 4.0) break; // |z| > 2 test avoids sqrt
z = vec2(a2 - b2, 2.0 * z.x * z.y) + c;
iter++;
}
float t = float(iter) / float(uMaxIter);
fragColor = vec4(iter == uMaxIter ? vec3(0.0) : vec3(t, t * 0.5, 1.0 - t), 1.0);
}
GLSL has no dynamic loop bounds: the for
loop condition must use a compile-time constant upper bound (here
1000) with an early break once uMaxIter is
reached — WebGL shader compilers reject loops bounded by a uniform
directly on many platforms.
3Smooth (continuous) coloring
Coloring pixels by raw integer iteration count produces visible
"banding" — hard rings between color zones. The fix is the
continuous (smooth) escape-time formula, which uses
the fractional part of how far past the escape radius the orbit
landed to interpolate between iteration bands:
// After the loop, if the orbit escaped (iter < uMaxIter):
float logZn = log(a2 + b2) / 2.0;
float nu = log(logZn / log(2.0)) / log(2.0);
float smoothIter = float(iter) + 1.0 - nu;
// Use smoothIter (a continuous float) instead of the integer iter
// as input to a color palette function — e.g. a cosine palette:
vec3 palette(float t) {
vec3 a = vec3(0.5), b = vec3(0.5), c = vec3(1.0);
vec3 d = vec3(0.00, 0.10, 0.20);
return a + b * cos(6.28318 * (c * t * 0.05 + d));
}
fragColor = vec4(palette(smoothIter), 1.0);
Cosine palettes (Inigo Quilez's formulation) generate
smooth, cyclic, artifact-free gradients from just four vec3
parameters, avoiding both banding and the need for a texture lookup
table — ideal for a GPU shader with no texture sampling overhead.
4Deep zoom: fighting float32 precision loss
GLSL's highp float is typically a 32-bit IEEE float with
about 7 decimal digits of precision. Once the zoom scale shrinks
below roughly 1e-5 of the initial view (around zoom
level 10,000×), neighboring pixels start mapping to the
same floating-point value of c, and the image
degrades into blocky, pixelated noise — long before you reach the
truly interesting filament structure deep in the boundary.
The standard fix is double-float (df64) emulation:
representing each coordinate as a pair of float32 values (a
"high" and "low" part) whose sum reconstructs roughly 14-15 digits
of precision, using error-free transformation algorithms:
// Double-float addition (Dekker's algorithm), operating on vec2(hi, lo) pairs
vec2 dfAdd(vec2 a, vec2 b) {
float s = a.x + b.x;
float v = s - a.x;
float e = (a.x - (s - v)) + (b.x - v) + a.y + b.y;
return vec2(s, e - (s - (s + e))); // renormalize
}
// Double-float multiplication (Veltkamp splitting)
vec2 dfMul(vec2 a, vec2 b) {
float p = a.x * b.x;
float e = fma(a.x, b.x, -p) + a.x * b.y + a.y * b.x;
return vec2(p + e, e - ((p + e) - p));
}
// z = z*z + c now performed with dfAdd/dfMul on vec2 pairs instead of
// native float multiplication — roughly 2x the ALU cost, but pushes
// usable zoom depth from ~1e5 to beyond ~1e14
Trade-off: double-float arithmetic costs roughly 5-8×
more ALU instructions per iteration than native float. Beyond ~1e14
zoom, even df64 runs out of headroom — production deep-zoom fractal
renderers (like Kalles Fraktaler) switch to perturbation theory,
computing one high-precision reference orbit on the CPU and
cheap float32 deltas from it on the GPU, enabling zooms past
10^1000×.
5Animating the zoom
Animate by exponentially decreasing uZoom each frame —
linear interpolation looks wrong because zooming is inherently
multiplicative (each frame should shrink the view by a constant
ratio, not a constant absolute amount):
let zoom = 2.5; // initial half-width in complex-plane units
const zoomRate = 0.985; // shrink by 1.5% per frame ≈ nice cinematic pace
const center = [-0.7436447860, 0.1318252536]; // a seahorse-valley target
function frame(time) {
zoom *= zoomRate;
const maxIter = Math.min(2000, 80 + Math.floor(-Math.log2(zoom) * 12));
gl.uniform2f(uCenterLoc, center[0], center[1]);
gl.uniform1f(uZoomLoc, zoom);
gl.uniform1i(uMaxIterLoc, maxIter);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (zoom > 1e-13) requestAnimationFrame(frame); // stop before df64 limit
}
requestAnimationFrame(frame);
Adaptive iteration count: deeper zooms need more
iterations to resolve fine boundary detail — scaling
maxIter logarithmically with zoom depth (as above) keeps
frame time roughly constant instead of either wasting cycles at
shallow zoom or under-resolving detail at deep zoom.
Frequently Asked Questions
What will I learn in this tutorial?
Render deep, real-time Mandelbrot zooms on the GPU using a WebGL fragment shader: complex-plane mapping, escape-time coloring, double-float emulation for deep zoom, and smooth interpolation.
What topics are covered in this tutorial?
This tutorial covers: Map screen pixels to the complex plane, The escape-time fragment shader, Smooth (continuous) coloring, Deep zoom: fighting float32 precision loss, Animating the zoom.
What tools and technologies does this tutorial use?
This tutorial uses WebGL, GLSL, Fragment Shaders.
How long does this tutorial take?
This tutorial takes approximately 50 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.