A fractal picture is a lot of arithmetic. This chapter is about the bill: what it costs, how to skip most of it, what happens when the numbers themselves run out of digits, and how to show a fractal moving in real time without it flickering. It ends inside JellyDazzle, where exactly that flicker was found and fixed.
Common mix-up: when a deep zoom dissolves into flat blocks, raising the iteration limit does not help. The blocks are not missing detail; they are missing digits. Neighbouring pixels have been rounded to the very same number, so they get the very same answer.
An escape-time fractal (Chapter 15) is computed one pixel at a time, and each pixel is a loop: take z, square it, add c, check whether it has escaped, repeat. The total work is roughly the number of pixels times the average number of trips around that loop.
Both numbers are bigger than they look. A 1920 × 1080 frame has 2,073,600 pixels. Points inside the set never escape, so every one of them runs all the way to the iteration limit; with a limit of 1,000, a frame that is mostly inside costs about two billion iterations. And the limit cannot stay at 1,000. The deeper you zoom, the longer points near the boundary take to make up their minds, so deep zooms routinely need tens of thousands of iterations, sometimes millions.
One iteration of z² + c is cheap: with x and y the two parts of z, it is x² − y² + a and 2xy + b, which is three multiplications and a handful of additions if you keep x² and y² around for the escape test too. A modern processor core does a few billion simple operations a second. So a single deep frame is seconds of work on one core, and an animation at 60 frames a second is impossible by brute force. Everything that follows is a way of not doing the brute force.
| Image | Pixels | × 1,000 iterations | × 100,000 iterations |
|---|---|---|---|
| 640 × 360 | 230,400 | 230 million | 23 billion |
| 1920 × 1080 | 2,073,600 | 2.07 billion | 207 billion |
| 3840 × 2160 | 8,294,400 | 8.29 billion | 829 billion |
| 1080p, 4×4 supersampled | 33,177,600 | 33.2 billion | 3.3 trillion |
Worst cases: every pixel running to the limit. Real images escape early over most of the frame, which is why the shortcuts below target the expensive inside points first.
The most expensive pixels are the ones inside the set, because they always run to the limit. Two tricks deal with most of them.
The cardioid and bulb check. The big heart-shaped body of the Mandelbrot set and the round bulb to its left have exact formulas. Before iterating at all, test whether c is inside either one. If it is, the answer is "inside" and the loop never runs. In a view of the whole set, that removes most of the black area for the price of a few multiplications per pixel.
Periodicity checking. Inside the set, most orbits do not wander forever; they settle into a repeating cycle. If the orbit ever comes back to a value it has already had (within a tiny tolerance), it will repeat forever and can never escape. So save z every so often and compare. A common scheme saves it at iterations 1, 2, 4, 8, 16 and so on, which catches a cycle of any length without storing the whole orbit (it is the same idea as Brent's cycle-finding method). Deep inside a bulb the loop stops after a few dozen iterations instead of thousands.
Two more tricks work at the level of the picture rather than the pixel. The Mandelbrot set is symmetric about the real axis, so a view that straddles it only needs half its rows computed. And because the set is connected, a rectangle whose whole border is inside the set is (in exact arithmetic) inside throughout; the Mariani–Silver method computes borders first and fills rectangles whose borders agree, subdividing those that do not. That last one is a gamble at finite resolution: a filament thinner than a pixel can slip through a border unseen, so careful renderers use it only with generous safety margins.
An orbit converging to a cycle approaches it but, in exact arithmetic, never lands on it. In floating point it eventually does, because once the distance falls below the spacing of representable numbers the values become identical. Waiting for that can take a long time near the edge of a bulb, where convergence is slow, so renderers compare with a tolerance a little bigger than the pixel spacing.
The check is not free. It adds a comparison or two to every iteration, and for pixels outside the set it never fires, so it is pure overhead there. Renderers often switch it on only when the previous pixel turned out to be inside, betting that neighbours agree.
Computers store most real numbers in IEEE 754 floating point: a sign, an exponent and a fixed number of significant binary digits, the significand. It is scientific notation in base 2. The exponent lets the number be enormous or tiny; the significand decides how many digits of it are actually kept.
The two common sizes are float32 (single precision), with 24 significant bits, about 7 decimal digits, and float64 (double precision), with 53 significant bits, about 16 decimal digits. Graphics cards are built around float32. Ordinary programming languages, JavaScript included, use float64 by default.
A fixed number of digits means a fixed relative spacing between neighbouring numbers. Near 1, float32 can represent 1 and the next number up, 1.00000012, and nothing in between; that gap is 2⁻²³, about 1.2 × 10⁻⁷. For float64 the gap near 1 is 2⁻⁵², about 2.2 × 10⁻¹⁶. The gap is called the ulp, a unit in the last place.
Now zoom. A view 1,000 pixels wide and w units across puts adjacent pixels w/1,000 apart. While that is much bigger than the ulp, every pixel gets its own coordinate. Once it falls below the ulp, several adjacent pixels round to the same coordinate, compute the same orbit, and get the same colour. The image turns into blocks, and nothing about the iteration count can fix it.
Plug in numbers. For float32 near |c| ≈ 1 and a 1,000-pixel image, the view runs out of digits at a width of about 10⁻⁴, a magnification of a few times 10⁴ compared with the whole set. Where the coordinates are a bit smaller than 1, like the point in the instrument below, it holds out to around 10⁵ to 10⁶. For float64 the same arithmetic gives a width of about 10⁻¹³: magnifications around 10¹³ to 10¹⁵. That is the wall every simple deep-zoom program hits, and it is why famous zoom videos that go to 10¹⁰⁰ or 10¹⁰⁰⁰ need something else.
| Number type | Significant bits | Decimal digits | Runs out near (1,000 px, |c| ≈ 1) | Where you meet it |
|---|---|---|---|---|
| float32 | 24 | about 7 | width 10⁻⁴, zoom ~10⁴–10⁵ | GPUs, WebGL, Metal |
| float64 | 53 | about 16 | width 10⁻¹³, zoom ~10¹³ | CPUs, JavaScript, most fractal programs |
| x87 extended | 64 | about 19 | width 10⁻¹⁶, zoom ~10¹⁶ | Older x86 programs ("long double") |
| double-double | about 106 | about 32 | width 10⁻²⁹, zoom ~10²⁹ | Two float64s added together, in software |
| binary128 (quad) | 113 | about 34 | width 10⁻³¹, zoom ~10³¹ | Mostly software emulation |
| Arbitrary precision | as many as you like | any | no wall, only cost | GMP, MPFR, deep-zoom renderers |
The block rule above is about the starting point, c. The orbit is a second source of error. Every multiplication rounds its result to the nearest representable number, so each iteration adds a tiny error, and the next squaring multiplies the existing error by about |2z|. For points that escape quickly this does not matter. For points near the boundary, which wander for hundreds or thousands of iterations, the error is stretched again and again.
The visible result is that a low-precision render can degrade before it turns into blocks: fine filaments shift, bands go ragged, small features appear in slightly wrong places. Scrub the instrument slowly through 10⁴ to 10⁶ and compare the two strips just before the float32 columns start to merge.
Double-double arithmetic, described by T. J. Dekker in 1971, keeps a number as an unevaluated sum of two float64s, the second holding the rounding error of the first. It roughly doubles the digits for several times the work, and it runs on any hardware that has float64.
Two renders of the same strip of the Mandelbrot set, near a point in Seahorse Valley. The left one does every operation in float32 (each step is rounded with JavaScript's Math.fround, exactly as single-precision hardware would round it). The right one uses float64. Drag the zoom toward 10²⁰ and watch where each one gives up. The "distinct columns" readout counts how many different x-coordinates the strip's pixels actually have.
Center: x = −0.743643887037151, y = 0.131825904205330 (as stored in float64). The ulp shown is for the x-coordinate, the larger of the two. Blocks are wider than tall because y, being smaller, keeps more digits.
The straightforward way past 10¹⁴ is to use more digits. Arbitrary-precision libraries such as GMP and MPFR store a number as a long array of machine words and do the arithmetic in software, as many digits as you ask for. A zoom to 10¹⁰⁰ needs a little over 100 decimal digits, roughly 340 bits, just to tell neighbouring pixels apart.
It works, and it is slow. Each operation now loops over many words, and the cost of a multiplication grows faster than the number of digits (the schoolbook method is quadratic; cleverer methods help only for very long numbers). Deep zooms also need more iterations, so the two costs multiply.
Then K. I. Martin published a method, with a free program called SuperFractalThing, that changed the field. The insight is that all the pixels in a deep view are extraordinarily close together, so their orbits are almost the same. Compute one orbit, the reference orbit, at full arbitrary precision for a point C in the view. For every other pixel c = C + δ, do not compute its orbit; compute only how far it is from the reference, ε, which is tiny, and which can be tracked in ordinary float64.
Each pixel now costs about the same as a float64 pixel, plus one expensive orbit shared by the whole image. Martin's paper added a second trick, series approximation: for the first many iterations, ε is a smooth function of δ and can be written as a short polynomial, εₙ ≈ Aₙδ + Bₙδ² + Cₙδ³, whose coefficients are computed once alongside the reference. Every pixel can jump straight to iteration, say, 40,000 by evaluating the polynomial, and only iterate from there. For deep zooms that skips the vast majority of the work.
The price is glitches. The approximation assumes the pixel's orbit behaves like the reference's. When it does not (typically when the pixel's full value zₙ = Zₙ + εₙ passes much closer to zero than the reference does), the float64 ε loses its precision, and a whole patch of pixels comes out as a flat blob or a wrong copy of something nearby. Renderers detect suspicious pixels with a size test and redo them with a new reference point chosen inside the glitch. Newer renderers avoid most glitches by "rebasing": whenever a pixel's orbit gets closer to zero than its difference from the reference, restart that pixel against the reference orbit from its beginning.
One more wall remains. float64's exponent stops at about 10⁻³⁰⁸, so past a zoom of 10³⁰⁸ even the tiny δ values underflow to zero. Deep-zoom programs handle that by storing δ and ε with a separate, wider exponent: ordinary float64 digits with an extra integer saying how many powers of two to shift.
Substitute εₙ = Aₙδ + Bₙδ² + Cₙδ³ into the perturbation formula and match the powers of δ. The coefficients follow their own little iterations, all starting from zero:
More terms let the skip go further. Some renderers use dozens of terms; others replace the polynomial with "bilinear approximation", which chains short linear steps that are valid over a known radius. All of them are refinements of the same idea from Martin's 2013 note.
Every pixel of an escape-time fractal is independent of every other: no pixel needs another's answer. Programmers call that embarrassingly parallel, and it is a perfect fit for a graphics card, which runs thousands of small threads at once. Splitting rows between processor cores helps too, and so do SIMD instructions that do four or eight pixels' arithmetic in one go.
GPUs have two catches for fractals. First, threads run in lockstep groups (32 threads in an NVIDIA "warp", 32 or 64 on AMD). A group finishes only when its slowest thread does, so one pixel that needs 10,000 iterations holds its 31 neighbours idle while they wait. Fractals are the worst case for this, since the boundary mixes fast and slow pixels at every scale.
Second, GPUs are float32 machines. Many consumer cards run float64 at a small fraction of their float32 speed, often 1/32 or 1/64. Apple's Metal shading language has no double-precision type at all, and WebGL shaders have only 32-bit floats. That is why GPU fractal explorers dissolve into blocks around 10⁵ unless they emulate more precision in software (two float32s as a "double-float", the GPU cousin of double-double) or, better, use perturbation, where the per-pixel work is small numbers that low precision can handle.
| Speed-up | What it exploits | Catch |
|---|---|---|
| Cardioid / bulb test | Exact formulas for the two biggest inside regions | Only for the standard Mandelbrot set |
| Periodicity check | Inside orbits repeat | Overhead on outside pixels |
| Symmetry | Mirror image about the real axis | Only when the view straddles it |
| Mariani–Silver | The set is connected | Can miss sub-pixel filaments |
| Threads, SIMD, GPU | Every pixel is independent | Divergence; GPUs are float32 |
| Perturbation + series | Nearby orbits stay close | Glitches; needs one high-precision orbit |
A real-time visualiser has a hard budget. At 60 frames a second each frame gets 16.7 milliseconds, on a 120 Hz display 8.3, and the fractal has to share that with everything else on screen. When a full picture costs more than the budget, the work has to be spread across frames. That is progressive rendering, and there are two classic ways to do it.
For a still image either is fine. For an animated fractal, where the parameters change continuously, banding has a trap. The picture on screen is a patchwork: rows finished just now, rows finished one sweep ago, rows in between. If the display also fades between redraws (a common choice in visualisers, where trails look good on other patterns), then the freshest band is bright and everything older is dimmer, and the eye reads that as a bright stripe rolling down the screen, several times a second. It looks like flashing. It is not a bug in the fractal; it is a bug in how the picture is assembled.
The fix is to stop showing work in progress. Build each sweep off-screen, in a buffer the viewer never sees. When a sweep finishes, it becomes the newest complete picture. On screen, show a crossfade between the last two complete pictures, moving linearly from the older to the newer over the time the next sweep takes. Every frame changes by a small, even amount everywhere, so motion reads as flow instead of a moving seam. The cost is latency: what you see is about one sweep behind what is being computed.
Anti-aliasing belongs in the same budget. A fractal boundary has detail finer than any pixel, so one sample per pixel gives jagged, sparkling edges that crawl when animated. Supersampling computes several samples per pixel and averages them; n × n samples cost n² times as much, so 4 × 4 is sixteen times the work. Renderers save most of that by sampling adaptively, only where neighbouring pixels disagree, and by jittering sample positions so that the error looks like fine grain rather than stair-steps. In real time, a cheaper cousin is to jitter the sample point a little each frame and let the temporal crossfade average them.
| Strategy | On screen during a sweep | Frame-to-frame change | Latency |
|---|---|---|---|
| Full frame every tick | Always complete | Only the motion itself | None, if it fits the budget (it rarely does) |
| Bands on a fading canvas | A bright fresh band over dimmer, older rows | Large: the band plus the fade | None |
| Off-screen, hard swap | The last complete picture | Zero, then a jump at each swap | One sweep |
| Off-screen + linear crossfade | A blend of the last two pictures | Small and even | About one sweep |
A Julia set whose constant drifts slowly, rendered a band of rows per frame, exactly as a real-time visualiser would. Switch between the four strategies and watch the picture and the graph. The readout measures what the eye feels: the average change in each colour channel between one displayed frame and the next, on a 0–255 scale, sampled across the image. Try setting the drift speed to zero: the rolling band keeps rolling even when nothing is moving.
Change is the mean absolute difference per colour channel between consecutive displayed frames. It is this lab's own measure on this lab's image, so the numbers are comparable between modes here, not with any other program's.
JellyDazzle is J's audio-reactive kaleidoscope app for Mac, iPhone and iPad, on the App Store. It runs 24 engine modes written in ARM64 assembly and 638 patterns written in C, and about 20 of those patterns are fractals: a Mandelbrot deep zoom, a Julia morph, a Burning Ship dive, Newton basins, Phoenix, Tricorn, Lyapunov, a Buddhabrot, a flame IFS, an Apollonian gasket, a growing fern, a dragon-curve fold and Sierpinski rain. Everything in this chapter shows up in it, because a visualiser lives inside the real-time budget all the time.
JellyDazzle's fractals render progressively: a band of rows per frame, sweeping down the screen. That is the second strategy above, chosen for the usual reason: a full fractal frame does not fit in a frame's time alongside everything else the app is doing.
In version 3.6.0, that made the fractals flash. A bright band rolled down the screen 6 to 12 times a second, because the canvas decayed between redraws: the freshest rows were bright, the older ones had faded, and the boundary between them swept down the picture over and over. It is precisely the "bands on a fading canvas" row of the table, and the lab above reproduces it.
Version 3.6.1 fixed it with the two ideas from this chapter: build each sweep off-screen, and crossfade the last two finished pictures linearly, so the shape flows instead of flashing. On the fractals that were measured, the frame-to-frame change dropped from about 9–16 to under 1. That is more than a tenfold drop in how much the image changes between frames, which is the difference between a picture that strobes and one that breathes.
Each appearance of a fractal pattern is driven by a seed, a number that picks its parameters: which Julia constant, which part of the Burning Ship to dive toward, which flame variations. A seed is what makes the twentieth Julia morph of an evening different from the first. In 3.6.5 an audit found patterns that ignored their seed, so they looked identical every time they appeared, and fixed 61 of them. It is a reminder that "random" in a visualiser is a contract that has to be tested, not assumed.
The colours come from the live palette: 180 colour schemes, interpolated in OKLab, Björn Ottosson's perceptual colour space from 2020 (Chapter 16 explains why that matters), and the C key changes it. Palette cycling is part of the app's inheritance. JellyDazzle traces its heritage to DAZZLE.EXE, from 1990, and is an homage to James R. Shiflett's DOS program, from the era when changing a few palette registers could make a whole screen of colour move for free.
| Version | What changed | Effect |
|---|---|---|
| 3.6.0 | Fractals drawn a band of rows per frame onto a canvas that decayed between redraws | A bright band rolling down the screen 6–12 times a second |
| 3.6.1 | Each sweep built off-screen; the last two finished pictures crossfaded linearly | Frame-to-frame change from about 9–16 to under 1 on the measured fractals |
| 3.6.5 | Seed audit | 61 patterns that ignored their seed fixed |