Chapter 18 · Part V · Color & computer fractals

Computing fractals

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.

24 vs 53significand bits in float32 and float64: about 7 and about 16 decimal digits
2.07 billioniterations for one 1920 × 1080 frame if every pixel runs to a 1,000-iteration limit
~9–16 → <1JellyDazzle's frame-to-frame change on the measured fractals, before and after the 3.6.1 crossfade fix

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.

The bill

Pixels × iterations

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.

ImagePixels× 1,000 iterations× 100,000 iterations
640 × 360230,400230 million23 billion
1920 × 10802,073,6002.07 billion207 billion
3840 × 21608,294,4008.29 billion829 billion
1080p, 4×4 supersampled33,177,60033.2 billion3.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.

Cheaper loops

Skipping work in the escape-time loop

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.

q = (x − ¼)² + y² inside if q·(q + x − ¼) ≤ ¼·y²
period-2 bulb: (x + 1)² + y² ≤ 1/16
Here c = x + iy. The first test is the main cardioid, where orbits settle on a single fixed point; the second is the disc of radius ¼ around −1, where they settle into a cycle of two.

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.

Go deeper: why periodicity checking needs a tolerance, and what it costs

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.

Running out of digits

float32, float64, and where each breaks

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.

blocks appear when w / pixels < ulp(|c|)
w is the width of the view in the complex plane. The ulp depends on the size of the coordinate: near |c| ≈ 1 it is 2⁻²³ for float32 and 2⁻⁵² for float64; for smaller coordinates it is proportionally smaller.

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 typeSignificant bitsDecimal digitsRuns out near (1,000 px, |c| ≈ 1)Where you meet it
float3224about 7width 10⁻⁴, zoom ~10⁴–10⁵GPUs, WebGL, Metal
float6453about 16width 10⁻¹³, zoom ~10¹³CPUs, JavaScript, most fractal programs
x87 extended64about 19width 10⁻¹⁶, zoom ~10¹⁶Older x86 programs ("long double")
double-doubleabout 106about 32width 10⁻²⁹, zoom ~10²⁹Two float64s added together, in software
binary128 (quad)113about 34width 10⁻³¹, zoom ~10³¹Mostly software emulation
Arbitrary precisionas many as you likeanyno wall, only costGMP, MPFR, deep-zoom renderers
Go deeper: errors grow along the orbit, so trouble starts before the blocks

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.

Instrument 1

Watch the digits run out

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.

View width–
Pixel spacing–
float32 ulp at x–
float64 ulp at x–
Distinct columns, float32–
Distinct columns, float64–
Iteration limit–
Render–

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.

Past the wall

Arbitrary precision and perturbation

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.

εₙ₊₁ = 2·Zₙ·εₙ + εₙ² + δ
Zₙ is the reference orbit (computed once, with many digits, then rounded to float64), εₙ is this pixel's difference from it, and δ is the pixel's offset from the reference point. Subtracting the reference's own Z² + C from the pixel's z² + c leaves exactly this. Its numbers are small, so float64 handles them with full relative precision.

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.

Go deeper: the series approximation coefficients

Substitute εₙ = Aₙδ + Bₙδ² + Cₙδ³ into the perturbation formula and match the powers of δ. The coefficients follow their own little iterations, all starting from zero:

Aₙ₊₁ = 2ZₙAₙ + 1
Bₙ₊₁ = 2ZₙBₙ + Aₙ²
Cₙ₊₁ = 2ZₙCₙ + 2AₙBₙ
Computed once per image, alongside the reference orbit. The approximation is trusted while the higher terms stay negligible compared with the lower ones, checked at the corners of the image.

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.

Many hands

GPUs and parallelism

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-upWhat it exploitsCatch
Cardioid / bulb testExact formulas for the two biggest inside regionsOnly for the standard Mandelbrot set
Periodicity checkInside orbits repeatOverhead on outside pixels
SymmetryMirror image about the real axisOnly when the view straddles it
Mariani–SilverThe set is connectedCan miss sub-pixel filaments
Threads, SIMD, GPUEvery pixel is independentDivergence; GPUs are float32
Perturbation + seriesNearby orbits stay closeGlitches; needs one high-precision orbit
Real time

Progressive rendering, and why it can flash

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.

  • Coarse to fine. Compute every eighth pixel and draw it as a big block, then every fourth, and so on, as the 3D viewer in Chapter 14 does. The PNG image format's Adam7 interlacing works on the same principle, in seven passes. You see the whole picture at once, blurry, then sharpening.
  • Bands. Compute a band of rows per frame, top to bottom, and keep sweeping. Simple, cache-friendly, and the cost per frame is fixed by the band height.

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.

StrategyOn screen during a sweepFrame-to-frame changeLatency
Full frame every tickAlways completeOnly the motion itselfNone, if it fits the budget (it rarely does)
Bands on a fading canvasA bright fresh band over dimmer, older rowsLarge: the band plus the fadeNone
Off-screen, hard swapThe last complete pictureZero, then a jump at each swapOne sweep
Off-screen + linear crossfadeA blend of the last two picturesSmall and evenAbout one sweep
Instrument 2

The progressive render lab

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.

Frame-to-frame change–
Sweeps per second–
Render cost per frame–
Pixels computed per frame–

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.

A case study

How JellyDazzle draws fractals live

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.

Bands per frame

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.

The 3.6.0 flash

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.

The 3.6.1 fix

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.

Seeds

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.

Colour

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.

VersionWhat changedEffect
3.6.0Fractals drawn a band of rows per frame onto a canvas that decayed between redrawsA bright band rolling down the screen 6–12 times a second
3.6.1Each sweep built off-screen; the last two finished pictures crossfaded linearlyFrame-to-frame change from about 9–16 to under 1 on the measured fractals
3.6.5Seed audit61 patterns that ignored their seed fixed
Where this comes from

Sources