Inside Mandelbrot Metal 3.0’s double-double reference orbits, adaptive Float/quad-single GPU perturbation, bounded Metal scheduling, and automatic recovery — and why arbitrary precision belongs in the reference orbit, not every pixel
When I launched Mandelbrot Metal in October 2025, it stood apart not only as a visual exploration of the Mandelbrot and Julia sets but also as a computer science project built around demanding numerical methods and high-performance GPU rendering. Since then, I have been developing new algorithms, precision techniques, and rendering code designed to dramatically push both speed and scale.
The result is what I call the Infinity Engine: a new rendering architecture that extends Mandelbrot Metal beyond its original limits while preserving the responsiveness and visual quality that define the app. It is the foundation of Mandelbrot Metal 3, launching this October on the first anniversary of the original release.
I have already written about the mathematical building blocks behind extreme zooms — double-double arithmetic, perturbation and Taylor series, reference orbits, and the ways perturbation fails in real systems.
This article starts where those explanations stop.
The interesting problem is no longer deriving the recurrence. It is building a production renderer around it: one that fits Apple GPU hardware, survives numerical failure, preserves the established image, and never traps the user in a slower path just because the new math is more ambitious.
That renderer is the Infinity Engine in Mandelbrot Metal 3.0.
The name does not mean infinite precision. No finite machine has that. It describes an engine organized around an open-ended precision ladder rather than one hard-coded zoom boundary.
Version 2.2 had a practical product ceiling of approximately 10¹⁶×. Version 3.0 removes that policy boundary. Its viewport-scale model can represent values as large as 10³⁰⁰, but that does not guarantee 300 digits of resolved fractal detail. Meaningful depth still depends on coordinate precision, reference-orbit accuracy, iteration count, local dynamics, and the error budget of the active render path.
That distinction matters. A very large zoom number is easy. A trustworthy image at that zoom is the actual engineering problem.
Exponent range and precision are different resources. An IEEE 754 Double can store a scale near 10³⁰⁰, yet it still carries only about 53 significand bits. A tiny offset can be representable on its own and still disappear when it is added to an ordinary-sized center coordinate:
let center = -0.743643887037151
let pixelOffset = 1.0e-100
print(pixelOffset.isFinite) // true
print(center + pixelOffset == center) // true: the offset was rounded away
The viewport model needs a large exponent range to truly describe a journey. The rendering engine needs additional significand precision so adjacent pixels at that destination remain different coordinates.
Development status: Infinity Engine v1 is implemented in build 548 and has completed the 24-test deterministic suite plus clean forward- and reverse-order physical-device benchmark passes on an iPhone 17 Pro Max running iOS 26.6.1. iPadOS, StoreKit sandbox, and the full high-resolution export matrix remain release gates. The arbitrary-precision reference backend and BLA acceleration discussed later are roadmap directions, not v3.0 shipping claims.
The Constraint That Defined the Design
I began with a non-negotiable rule:
If the Infinity Engine cannot render a scene at least as well and as fast as the previous deep renderer, Mandelbrot Metal must use the previous renderer.
That immediately ruled out the most obvious implementation: arbitrary-precision arithmetic for every pixel.
Suppose an image contains three million pixels and a difficult boundary requires 50,000 iterations. A brute-force arbitrary-precision renderer is not just doing more accurate arithmetic. It is performing variable-width addition and multiplication potentially hundreds of billions of times. Dynamic limb counts increase register pressure, memory traffic, and control-flow divergence — the exact costs a wide GPU is least equipped to hide.
The better decomposition is asymmetric:
- Compute one expensive reference orbit accurately.
- Encode it in a form Metal can consume efficiently.
- Let every GPU thread evolve only its local deviation.
- Detect when the local approximation has left its safe numerical region.
- Rebase or recover without leaving the GPU.
- Fall back automatically if performance or correctness cannot be maintained.
Precision is concentrated where it has the greatest leverage. Parallelism remains where it has the greatest payoff.
What “Metal-First” Means Here
Is the Infinity Engine entirely in Metal?
The honest answer is: the pixel-dominant work is.
In v3.0, Swift computes a single reference orbit on the CPU using double-double arithmetic. Metal then performs the per-pixel coordinate offset, quad-single perturbation recurrence, escape test, rebasing decision, glitch recovery, smoothing, and color output.
That division is intentional. One CPU orbit is amortized over every pixel in the frame. Moving it to Metal would make the architecture more ideologically pure, but not necessarily faster. The design goal is not to maximize the percentage of source code written in a shader language. It is to minimize the expensive work multiplied by the pixel count.
Stage 1: Build One Double-Double Reference Orbit
Double-double arithmetic represents a value as the unevaluated sum of two IEEE 754 doubles:
x = x.hi + x.lo
The two components provide roughly 106 bits of significand precision when arithmetic is implemented with error-free transforms.
The v3.0 renderer evaluates the familiar orbit once at the reference coordinate c₀:
Z[0] = 0
Z[n+1] = Z[n]² + c₀
Here is an abridged version of the production Swift code. Buffer allocation and upload bookkeeping are omitted:
private struct InfinityReferenceOrbitPoint {
var real: SIMD4<Float>
var imag: SIMD4<Float>
}
private func buildReferenceOrbit(c0: SIMD2<Double>, maxIt: Int) {
var zx = DD(0.0), zy = DD(0.0)
let cx = DD(c0.x), cy = DD(c0.y)
var orbit: [InfinityReferenceOrbitPoint] = []
for _ in 0..<maxIt {
let x2 = ddMul(zx, zx)
let y2 = ddMul(zy, zy)
let xy = ddMul(zx, zy)
zx = ddAdd(ddSub(x2, y2), cx)
zy = ddAdd(ddMul(xy, DD(2.0)), cy)
orbit.append(.init(
real: ddFloatExpansion(zx),
imag: ddFloatExpansion(zy)
))
let radius2 = ddAdd(ddMul(zx, zx), ddMul(zy, zy))
if radius2.hi > 4.0 ||
(radius2.hi == 4.0 && radius2.lo > 0.0) {
break
}
}
uploadToMetal(orbit)
}
Computing the orbit is only half the problem. Uploading each component as a single Float would destroy most of the precision before the GPU even began.
Instead, each double-double value is decomposed into four non-overlapping Float limbs:
@inline(__always)
func ddFloatExpansion(_ value: DD) -> SIMD4<Float> {
var residual = value
var limbs = SIMD4<Float>(repeating: 0)
for index in 0..<4 {
let limb = Float(residual.hi)
limbs[index] = limb
residual = ddSub(residual, DD(Double(limb)))
}
return limbs
}
The crucial detail is the residual subtraction. The code never collapses hi + lo into a single Double and then converts that result to Float. Each emitted limb is removed in double-double precision, preserving information that would otherwise disappear at the CPU/GPU boundary.
The resulting buffer is also friendly to the GPU: every reference point is two aligned float4 values, one for the real component and one for the imaginary component. This is the same data-oriented principle I discussed in Thinking in Data Layout, Not Objects. The numerical and memory representations have to be designed together.
Stage 2: Reconstruct Quad-Single Values in Metal
On the GPU, four non-overlapping Float limbs become a quad-single expansion. This is finite precision — not arbitrary precision — and it does not have a fixed IEEE-style bit guarantee. Useful accuracy depends on limb non-overlap, cancellation, renormalization, operation ordering, and the points where the implementation reduces an expanded value to Float for a diagnostic.
The primitive behind accurate multiplication is an error-free product transform. Metal’s fused multiply-add gives the rounded product and its residual:
struct qs4 {
float4 limb;
};
inline float qs_two_prod(float a, float b, thread float &error) {
const float product = a * b;
error = fma(a, b, -product);
return product;
}
Addition and multiplication combine these residuals and renormalize the result back into four ordered, non-overlapping limbs. The implementation is more expensive than ordinary FP32, but it remains uniform, fixed-width arithmetic. That distinction is important on a GPU.
Arbitrary precision asks each thread to manage a variable number of limbs. Quad-single gives every thread the same register shape and the same instruction sequence.
Stage 3: Keep the Quadratic Perturbation Term
My earlier articles described the common first-order approximation:
δz[n+1] ≈ 2Z[n]δz[n] + δc
Infinity Engine v1 does something different. It retains the quadratic term:
δz[n+1] = 2Z[n]δz[n] + δz[n]² + δc
For the quadratic Mandelbrot recurrence, that equation is the full algebraic perturbation recurrence relative to the reference orbit. Its evaluation is still finite precision and still inherits reference error, but it no longer introduces the first-order omission of δz².
The central part of the Metal implementation looks like this:
qs4 linearX, linearY;
qs_complex_mul(zrefX, zrefY, dzx, dzy, linearX, linearY);
linearX = qs_mul_two(linearX);
linearY = qs_mul_two(linearY);
qs4 squareX, squareY;
qs_complex_mul(dzx, dzy, dzx, dzy, squareX, squareY);
qs4 nextDX = qs_add(qs_add(linearX, squareX), dcx);
qs4 nextDY = qs_add(qs_add(linearY, squareY), dcy);
actualX = qs_add(nextRefX, nextDX);
actualY = qs_add(nextRefY, nextDY);
This is where the Infinity Engine earns its name architecturally. The renderer is not tied to “use doubles until they fail” or “use first-order perturbation until it fails.” It can independently select a reference representation and a per-pixel representation, provided the boundary between them is explicit.
Stage 4: Treat Numerical Failure as a Normal State
Perturbation is local. Even the algebraically complete recurrence, evaluated in finite precision, can lose a useful relationship to its reference orbit.
Two symptoms matter in v3.0:
- The perturbation becomes large relative to the reference value.
- Cancellation produces a suspiciously small reconstructed magnitude — a known glitch pattern.
The qualified renderer does not pay the complete quad-single cost for every pixel. Each thread first tries a reference-relative Float perturbation tier. That tier escalates when its state is non-finite, when the perturbation grows beyond 6.25 percent of the reference magnitude, or when severe cancellation makes the reconstructed value suspiciously small:
int it = iterateInfinityFastPerturbation(
dcFast, referenceOrbit, referenceCount, maxIt
);
if (it < 0) {
it = iterateInfinityPerturbation(
dcQuadSingle, referenceOrbit, referenceCount, maxIt
);
}
The negative result is a routing signal, not an iteration count. The pixel restarts in the complete QS recurrence, so the inexpensive tier is never allowed to hand a partially corrupted state to the high-precision tier.
Once in QS, the kernel evaluates the wider rebase conditions shown below. The recurrence stays in QS, while these current diagnostics intentionally reduce the expanded values to Float:
const float referenceFloatX = qs_to_float(nextRefX);
const float referenceFloatY = qs_to_float(nextRefY);
const float deltaFloatX = qs_to_float(nextDX);
const float deltaFloatY = qs_to_float(nextDY);
const float referenceMag2 =
fma(referenceFloatX, referenceFloatX,
referenceFloatY * referenceFloatY);
const float deltaMag2 =
fma(deltaFloatX, deltaFloatX,
deltaFloatY * deltaFloatY);
const bool largeDelta =
deltaMag2 > max(1.0e-30f, referenceMag2 * 0.25f);
const bool glitch =
referenceMag2 > 1.0e-20f &&
actualMag2 < referenceMag2 * 1.0e-7f;
if ((largeDelta || glitch) && completed < maxIt) {
dzx = actualX;
dzy = actualY;
refIndex = 0;
}
Resetting the reference index effectively rebases the current pixel around the beginning of the reference orbit. The actual orbit value becomes the new deviation state; the next full quadratic update reconstructs the pixel orbit using the reference anchor again.
If the reference buffer ends, a value becomes non-finite, or the resource state is invalid, the kernel recovers directly in quad-single where possible rather than silently coloring corrupted data. If the Metal command itself fails or produces an unusable completion result, the renderer can route later frames to the established CPU path.
The larger lesson is simple:
A numerical method is not production-ready when it works. It is production-ready when its failure is observable, bounded, and recoverable.
Stage 5: Put a Circuit Breaker Around Performance
Correct pixels are not enough in an interactive app. A renderer that is mathematically elegant but slower than the previous release is still a regression.
The v3.0 release process compares representative scenes against the 2.2 baseline. At runtime, a second safety mechanism watches each Infinity command. The 250 millisecond value is deliberately strict, but it is a diagnostic and release signal — not a live discard rule. A valid slow Metal frame is retained. Throwing it away and rendering the same image again on the CPU would only add more latency.
Only a failed Metal command or an unusable non-finite/non-positive completion result opens the runtime circuit:
let diagnosticMiss = commandFailed ||
!gpuSeconds.isFinite ||
gpuSeconds <= 0 ||
gpuSeconds > 0.250
let runtimeFailure = commandFailed ||
!gpuSeconds.isFinite ||
gpuSeconds <= 0
recordInfinityDiagnostic(passed: !diagnosticMiss)
if runtimeFailure {
infinityCircuitOpen = true
infinityModeActive = false
uniforms.infinityMode = 0
requestRedraw() // established CPU deep renderer takes over
}
The proof that the new engine beats 2.2 comes from paired, full-app, physical-device benchmarks — not a timer within a single kernel. The circuit handles a different problem: a command-buffer failure or corrupted timing state that was not represented in the lab.
There is no modal error and no demand that the user understand render paths. The image continues through the known-good renderer.
Export is qualified independently as well. A 6K or 8K export has a smaller complex-plane step than the live canvas, so the renderer re-evaluates Infinity eligibility for the output dimensions rather than assuming that a path that qualifies for the screen also qualifies for the file.
Stage 6: Bound the Metal Work Without Leaving Metal
A single full-screen, high-iteration dispatch can exceed iOS’s practical command-duration limit even when its recurrence is correct. I found such a failure in a rejected reverse-order development run: the monolithic command reached a Metal status error after roughly 14.5 seconds.
The qualified live renderer therefore divides a final Infinity frame into 128-row stripes. Every stripe has its own command buffer and uniform origin. It writes to a private stripe texture, then blits that stripe into a private full-frame texture. Only the last command blits the assembled image to the drawable and presents it.
let stripeHeight = 128
for y in stride(from: 0, to: drawableHeight, by: stripeHeight) {
let rows = min(stripeHeight, drawableHeight - y)
let command = makeInfinityStripeCommand(y: y, rows: rows)
command.compute(into: stripeTexture)
command.blit(
from: stripeTexture,
to: fullFrameTexture,
destinationY: y
)
if y + rows == drawableHeight {
command.blit(from: fullFrameTexture, to: drawableTexture)
command.present(drawable)
}
command.commit()
}
On a successful Infinity frame, neither CPU fractal math nor texture readback participates in this chain. The CPU-built DD reference orbit is already resident in a Metal buffer; coordinate offsets, recurrence, recovery, color, frame assembly, and presentation all stay on the GPU.
Generation tracking makes the chain cancellable without making it fragile. A genuine viewport or iteration change invalidates the old stripe sequence. A no-op SwiftUI refresh caused by the rendering overlay does not. That distinction resolved a subtle regression in which the app could repeatedly cancel identical work, leaving a black canvas.
Interaction Is a Separate Performance Contract
Fully settled quality should not dictate finger latency. While a pan or pinch is active, v3.0 submits a disposable Metal preview at one-third linear resolution, one sample per pixel, and at most 320 iterations, with Infinity and 3D lighting temporarily disabled. Reference-orbit rebuilding is deferred until the gesture commits.
Double-tap follows the same rule: it immediately submits the lightweight preview, then schedules full refinement after 120 milliseconds. When the gesture ends, the renderer restores the full drawable, the bookmark or manual iteration count, the selected lighting, and normal Infinity/CPU routing. The rendering overlay remains visible until the complete frame is presented.
This is not a lower-quality product mode. It is temporal separation: cheap feedback while the coordinate is changing, full committed work after it stops.
Real-World v3.0 Versus v2.2.3
The benchmark had to measure what a user actually waits for. Kernel-only timers, offscreen textures, simulator runs, zero-size drawables, and partial stripe completion were rejected.
Release builds v3.0 (build 548) and v2.2.3 were run under separate bundle identifiers on the same iPhone 17 Pro Max running iOS 26.6.1. The foreground MTKView was window-attached and fixed at 440 × 956 points at scale 3—a full 1320 × 2868 drawable. Timing began immediately before the bookmark state application and ended only after the complete render path, final blit, and presentation submission were complete. It included DD reference preparation, recurrence, palette/color work, and 3D lighting where the bookmark requested it. App launch and drawable-readiness waiting were excluded.
Each version ran the same exported ten-scene corpus once in forward order and once in reverse. High Quality Idle was off because it is a global state not contained in the bookmark files. All other bookmark state — iterations, palette, contrast, 3D state, Mandelbrot/Julia family, and Julia parameter — was preserved.
Across all ten scenes, v2.2.3 averaged 203.890 seconds, and v3.0 averaged 42.419 seconds: a 4.81× aggregate speedup and 79.2 percent less elapsed time. Earth Elephants fell from 12.245 to 3.714 seconds. Deep Deep Triplets fell from 115.075 to 24.992 seconds. Valley Of Bulbs fell from 57.711 to 10.827 seconds.
The ordinary low-zoom GPU controls stayed close. Julia Dragon was 1.06× faster, Mini Brot 1.04× faster, and Laser Quad 0.95× — about a two-millisecond difference. That is important because v3 is not purchasing deep speed by meaningfully slowing the established interactive path.
Order mattered most for the v2 CPU path. Deep Deep Triplets ranged from 96.987 to 133.163 seconds in v2, while v3 ranged from 24.919 to 25.066. Valley of Bulbs had the largest v3 spread, 9.491 to 12.164 seconds, consistent with thermal state and scene-dependent GPU cost. Final v3 deep runs remained on Infinity in both directions; no circuit opened.
I also compared a 440 × 956 adaptive Earth Elephants render, rendered byte-for-byte, with a build forced through QS for every pixel. Across 420,640 pixels, there were zero differing pixels and zero channel errors. That is a correctness oracle, not a timing result; the published timings above use the full 1320 × 2868 drawable and the production adaptive path.
These measurements describe build 548 on this device and OS with one sample per Infinity pixel and High Quality Idle Off. They do not predict other devices, exports, deep Julia, or a future arbitrary-precision reference backend.
So Where Does Arbitrary Precision Fit?
Version 3.0 is an adaptive finite-precision engine:
- The viewport scale model extends to 10³⁰⁰.
- The initial reference orbit uses double-double arithmetic, approximately 106 significand bits.
- The Metal perturbation path uses four-Float quad-single expansions whose effective accuracy depends on normalization, cancellation, operation order, and Float-reduced diagnostics.
- The full quadratic perturbation recurrence, rebasing, recovery, and fallback make those bits more useful.
None of those facts make the implementation arbitrary precision.
At a rough first approximation, resolving a magnification M requires a significand budget proportional to:
required bits ≈ ceil(log₂(M)) + guard bits + stability margin
At 10³⁰⁰×:
log₂(10³⁰⁰) ≈ 996.6 bits
That is far beyond double-double or quad-single. The 10³⁰⁰ viewport ceiling therefore means “the state model no longer imposes the old 10¹⁶ wall,” not “v3.0 resolves every pixel at 10³⁰⁰.” A future reference-coordinate-and-orbit backend must provide additional precision.
The key architectural point is that arbitrary precision belongs first in the reference service, not in every pixel thread.
A future Swift-side precision planner could start with the decimal zoom exponent, then add guard bits and a stability margin. This is a roadmap sketch, not v3.0 production code:
import Foundation
func referencePrecisionBits(
decimalZoomExponent: Int,
guardBits: Int = 96,
stabilityMargin: Int = 64
) -> Int {
let depthBits = Int(ceil(
Double(decimalZoomExponent) * log2(10.0)
))
return max(128, depthBits + guardBits + stabilityMargin)
}
let bits = referencePrecisionBits(decimalZoomExponent: 300)
// 1,157 bits: 997 depth bits + 96 guard + 64 stability
An MPFR-class backend could then compute one correctly rounded orbit at the selected precision. The exact library is an implementation decision; the architectural requirement is a backend whose limb count grows with the requested error budget. Its output would not simply be truncated to the existing four-Float format. Depending on depth, the engine could choose among several transport strategies:
- normalized limb windows plus a shared exponent;
- multiple local reference orbits, each keeping pixel deltas small;
- chunked orbit storage with precision selected per iteration range;
- reference compression with deterministic reconstruction;
- a wider fixed expansion for devices that pass the benchmark gate.
The GPU still does the work multiplied by millions of pixels. Arbitrary precision is paid for once per reference — or once per tile — not once per pixel.
This is also where block-linear approximation (BLA) becomes important. Instead of advancing every perturbation one iteration at a time, BLA composes a block of locally linear updates into a transform of the form
δz′ = A·δz + B·δc
Two adjacent blocks can be composed before any pixel enters them:
compose(left, right):
A = right.A · left.A
B = right.A · left.B + right.B
A pixel can then skip a stable run of reference iterations with a small, fixed transform. Orbit reuse prevents small viewport changes from unnecessarily rebuilding expensive high-precision data. Arbitrary precision makes the reference trustworthy; BLA and reuse make that trustworthy reference affordable.
Those are v3.1 directions in the Mandelbrot Metal engineering roadmap: extended reference coordinates, BLA, orbit reuse, device-aware memory tiers, and deeper qualification. Arbitrary precision is a candidate reference backend within that architecture, not a claim about what v3.0 already ships.
Why I Am Not Putting Arbitrary Precision Entirely in Metal — Yet
It is possible to implement arbitrary-precision arithmetic in a Metal compute kernel. A significand can be represented as an array of integer or Float limbs, and addition and multiplication can be built on top of it.
“Possible” is not the same as “appropriate.”
For a full per-pixel implementation, deeper zooms would require more limbs. More limbs mean more storage, more loads, more carry propagation, more multiplications, and often more divergent execution. The renderer would be spending its most scalable resource — millions of GPU threads — on the least scalable representation.
For reference-orbit generation, the tradeoff is more interesting because only one or a small number of orbits are involved. A specialized Metal reference builder may eventually win on some Apple GPU families. If it does, it should be selected on the basis of measurement rather than architectural aesthetics.
That is the Infinity Engine rule in miniature:
New math competes for the frame. It does not inherit the frame.
Precision Is Not a Pro Feature
Mandelbrot Metal 3.0 also moves from paid-up-front to Free + Pro. That raised an important product question: should extreme zoom or the Infinity Engine be part of Pro?
No.
Free and Pro use the same renderer, adaptive precision, built-in palettes, and performance paths. Pro expands the creative workflow with unlimited bookmarks, custom and photo-derived palettes, 3D lighting, and high-resolution export.
Numerical correctness is not an upsell. A scene should not become less accurate due to its owner’s subscription status.
What Ships First — and What Comes Next
The current version 3 roadmap is organized as five independently shippable, eight-week engineering releases:
- 3.0 — Infinity Foundation & Commerce: Infinity Engine v1, Free + Pro, permanent Legacy Pro migration, regression fixtures, and automatic performance fallback.
- 3.1 — Deep-Zoom Acceleration: extended reference coordinates, BLA, orbit reuse, device qualification, diagnostics, and thermal tuning.
- 3.2 — Creation System: versioned scene documents, autosave, editable motion, deterministic video export, color, and materials.
- 3.3 — Breadth & Access: curated formulas, workspace discovery, complete input and accessibility coverage, and migration.
- 3.4 — Reliability & Qualification: frozen formats, complete technical documentation, device-matrix validation, and production qualification.
Each phase is meant to be useful on its own. The engine does not wait for the rest of v3 to become real, and the rest of v3 does not depend on pretending the engine is already arbitrary-precision.
The Real Meaning of “Infinity”
The old way to describe a fractal renderer was by its numeric type:
- Float renderer
- Double renderer
- Double-double renderer
That description becomes less useful as the system grows.
Infinity Engine is better understood as a precision scheduler. It chooses where accuracy is purchased, where parallelism is spent, when a reference remains valid, when a pixel must rebase, when a shader must recover, and when the entire new path must step aside.
The first release uses double-double plus quad-single. Later releases can add arbitrary-precision reference math, BLA, multiple references, or new fixed expansions without replacing the whole renderer.
The potential is not a magical zoom number. It is an architecture that can keep moving the trustworthy boundary outward while preserving three things users notice immediately:
- the image remains correct;
- interaction remains fast;
- failure never becomes their problem.
That is what the Infinity Engine is designed to do.
Until next time,
Michael
Technical references
- Michael Stebel, How Perturbation Theory and the Taylor Series Make Extreme Fractal Zooms Possible
- Michael Stebel, Perturbation Theory, As It Actually Breaks
- Michael Stebel, Reference Orbits Explained
- Mandelbrot Metal, Version 3 Engineering Roadmap
- Apple, Metal resources and Metal Shading Language specification
- GNU MPFR Project, The GNU MPFR Library
- David Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic
Get Version 2.2.3 