Notes · Mandelbrot

Zooming past the edge of arithmetic

Every deep zoom video you have seen has the same problem hiding inside it: computers run out of numbers long before the fractal runs out of detail. Here is what breaks, and the trick that fixes it.

The set itself

Pick a point on the plane. Treat it as a number c and run this loop: start at zero, square, add c, repeat.

z₀ = 0
z₁ = z₀² + c
z₂ = z₁² + c
z₃ = z₂² + c … and so on

Two things can happen. The numbers can stay small forever — that point is in the Mandelbrot set, and we paint it black. Or they can run away to infinity — that point is out, and we colour it by how many steps it survived before escaping.

That is the whole definition. A loop with one multiplication and one addition. Everything else — the spirals, the seahorses, the tiny copies of the whole shape buried miles down — falls out of those two operations. Nobody designed the detail; it is a consequence.

STAYS BOUNDED — INSIDE THE SET RUNS AWAY — OUTSIDE
Same rule, two starting points. The distance from zero either settles or explodes — and the boundary between those two fates is the fractal.

Why zooming breaks

To zoom in, you shrink the gap between neighbouring pixels. At 10× magnification adjacent pixels differ in the fourth decimal place. At 10¹⁵ they differ in the sixteenth.

And sixteen is roughly where a computer's standard number type gives up. A double — the number type behind almost all arithmetic you will ever encounter — carries about 15 to 17 significant decimal digits. Past that there is nothing left to store the difference in.

What that actually looks like Two neighbouring pixels round to the same number. They then follow identical paths through the loop and get identical colours. Blocks of the image turn flat, detail stops appearing however far you go, and the fractal appears to simply end. It has not ended — you have run out of arithmetic.
MethodDigitsUsable zoom
GPU float32~710⁵
Emulated double on GPU~1010⁶
float64 (CPU)~1610¹³
Perturbation (this page)as many as you like10²⁶ measured

The obvious fix is to use bigger numbers everywhere — hundreds of digits per pixel. That works, and it is unbearably slow: a million pixels × thousands of iterations × arithmetic hundreds of times more expensive than the hardware's native speed. Minutes per frame, not milliseconds.

The trick

Here is the insight, and it is genuinely elegant. Neighbouring points behave almost identically. Two pixels a hair apart trace nearly the same path through the loop. So instead of computing every pixel from scratch at enormous precision, compute one point properly — and describe every other pixel as a small deviation from it.

Call the carefully computed point C, and its path Z₀, Z₁, Z₂… — the reference orbit. Any nearby pixel is c = C + δc, where δc is a tiny offset. Its path is z = Z + δ. Substitute into the loop and expand:

zn+1 = zn² + c
Zn+1 + δn+1 = (Zn + δn)² + (C + δc)
expand the square
Zn+1 + δn+1 = Zn² + 2Znδn + δn² + C + δc
but Zn² + C is just Zn+1, so it cancels from both sides

δn+1 = 2·Zn·δn + δn² + δc

That last line is the whole method. It is a rule for updating the difference directly, without ever computing the pixel's own value at high precision.

And the payoff: δ is tiny, so it needs almost no digits — ordinary fast arithmetic holds it fine. Z has already been computed once, expensively, and gets reused by every pixel on screen. The expensive part is paid once per frame instead of once per pixel.

The trade in one sentence One point computed with a thousand digits, a million points computed with seven, instead of a million points computed with a thousand.

Where it goes wrong

There is a catch, and it took the fractal community years to solve properly.

The method assumes the pixel stays close to the reference. Sometimes it does not: the reference orbit swings far from zero while the pixel's true value passes very near it. Now you are computing a small number as the difference between two large ones, and the precision you were relying on evaporates. These pixels come out as ugly flat blotches — glitches.

The first fix, from a poster known as Pauldelbrot, was to detect glitched pixels and re-render them against a second reference chosen from among them, repeating until clean. It works, but it means several passes and bookkeeping about which pixels belong to which reference.

The renderer here uses a later and much neater idea, usually credited to Zhuoran: rebasing. If the pixel's true value ever becomes smaller than its own offset, the reference has stopped being useful — so throw it away and start again, treating the current value as a fresh offset from step zero of the same orbit. No second reference, no extra passes, no glitches. A few lines of code where the old approach needed a subsystem.

How this one is built

1 · The reference, in a worker

The reference orbit is computed in a background thread using BigInt fixed-point arithmetic — integers scaled by a power of two, giving as many digits as the depth requires. Precision is set from the zoom level: roughly 3.3 bits per decimal digit, plus a safety margin. It runs off the main thread so the interface never freezes.

bits = ⌈log₂(10) × zoom exponent⌉ + 96 ≈ 3.32 digits-to-bits, plus guard

2 · The pixels, on the GPU

The finished orbit is uploaded to the graphics card as a texture — a long list of Z values. Every pixel then runs the δ recurrence in parallel, reading Z from that texture. This is what makes it interactive rather than a render job.

3 · Scaling

One more wrinkle. At extreme depth δc is around 10⁻³⁰, and float32 cannot even represent numbers that small. So everything is rescaled: distances are measured in pixels rather than in units of the complex plane, which puts δ comfortably near 1. The scale factor is folded back in only where the maths requires it.

4 · Colour

Escape counts are integers, so colouring by them directly gives visible bands. The fix is the smooth iteration count, which turns the step number into a continuous value using how far past the escape threshold the point landed:

ν = n + 1 − log₂( log|zn| )

The renderer also tracks the derivative alongside the value, which gives an estimate of each pixel's distance to the boundary of the set. That is what the contour setting uses — it darkens near the edge and makes the filaments legible instead of aliased. It also means detail stays crisp at any magnification, because it measures geometry rather than counting steps.

What it still cannot do

This implementation holds up cleanly to about 10²⁶, starts losing detail by 10²⁸, and is flat by 10³⁰. I measured that rather than guessed it. The limit is not the reference orbit — that can go as deep as you like. It is that the orbit is stored as float32, carrying only about seven digits, and that error compounds through the 2·Z·δ term over thousands of iterations.

Going deeper means storing each offset as a mantissa plus its own separate exponent, instead of relying on the hardware's. Fractal software calls this floatexp, and with it people have reached 10¹⁰⁰⁰ and further. The other missing piece is series approximation, which skips thousands of early iterations by approximating them with a polynomial — a large speed win, and orthogonal to everything above.

Both are on the list. Neither changes the idea, only how far it reaches.

Sources worth reading

The perturbation approach came out of the fractalforums community rather than academia — largely amateurs, working it out in public over about a decade. K. I. Martin's 2013 note SuperFractalThing Maths is the origin; Pauldelbrot's glitch criterion and Zhuoran's rebasing came later on the same forums. Claude Heiland-Allen has written the clearest technical accounts of the modern versions.

← Open the explorer