Tutorial · Intermediate · ~50 min
WebGL · GLSL · Fragment Shaders

Fractal Zoom on the GPU with WebGL

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.