Skip to content

From counts to colour

The escape-time chapter ended with the honest measurement: a smooth escape count ν\nu for every exterior pixel. This chapter is about the dressing — the exact pipeline that turns that number into the colours you see, and the algorithms behind it.

The pipeline

For an escaped sample, every engine — CPU, deep-zoom, GPU — begins the same way:

t=νf+φ,base colour=P(t).t=\nu f+\varphi, \qquad \text{base colour}=P(t).

  • ν\nu is the smooth escape count — the measurement, never touched.
  • ff is the colour density (the slider of that name; default 0.150.15): it scales how fast the palette cycles as counts grow. Higher packs the bands tighter; lower stretches one palette period across more of the view.
  • φ\varphi is a phase offset — normally 00, animated when flow is on.
  • PP is the palette: a periodic function from phase to RGB. Built-ins use radians directly; the custom palette converts t/2πt/2\pi to a position around its lookup-table loop.

That is only the base colour. The complete per-sample pipeline is:

orbitνP(t)optional shadingRGB.\text{orbit}\longrightarrow \nu\longrightarrow P(t) \longrightarrow \text{optional shading}\longrightarrow \text{RGB}.

With supersampling, the renderer runs that whole chain independently at every sub-pixel location, averages the floating-point RGB results, and performs one final conversion to 8-bit output. The iteration budget, bailout, scheme ids, palette frequency and shading parameters are shared across backends; colour is not a cosmetic post-process pasted onto an unrelated GPU image.

Three interlocking spiral arms in violet and teal
Triple Spiral Valley. Three-armed spirals wind around each other in the northern antenna region. c ≈ −0.0894 + 0.6543i · span 10⁻³ · open this view in Mandala →

Why the palette must be a loop

Escape counts climb without a fixed ceiling. A pixel far from the set escapes in three steps; a pixel hugging a deep filament can survive hundreds of thousands (up to Mandala's ~200,000-iteration budget). A clamped gradient — dark at count 0, bright at count max — would spend its entire range on the first few bands and paint everything near the boundary one saturated colour. Mapping counts through a cyclic palette instead means every extra band of patience gets fresh colour, at any depth, forever. The trade is honest: colour tells you relative structure (which contour you're on), not absolute escape time.

What a palette actually is

Cosine palettes. Classic, and several of the effect palettes derived from it, begin with three cosine waves — one per colour channel — offset from each other by 2π/32\pi/3:

P(t)=12+12cos ⁣(t+ϕR,G,B),P(t) = \tfrac12 + \tfrac12\cos\!\big(t + \phi_{R,G,B}\big),

with per-palette amplitude and offset tweaks. Nothing is stored: the colour is computed on the spot, it is smooth by construction (no banding from table steps), and it wraps around perfectly — the loop property comes free.

Perceptual maps. The scientific palettes — Viridis, Plasma, Magma — are different: they approximate perceptually uniform colour maps, where equal steps in the data produce equal steps in perceived colour, and lightness rises monotonically (so structure survives even in greyscale and for colour-blind viewers — Viridis is the app's default for exactly that reason). They have no neat closed form, so the engines evaluate fitted polynomial approximations of each channel.

The custom gradient. The palette editor lets you author your own gradient from colour stops. Arbitrary user gradients would be expensive to evaluate inside three different engines (Rust, WGSL, and again per pixel), so the app samples the gradient once into a 256-entry lookup table — a LUT — and hands that same table to every engine, which just indexes into it with wrap-around and interpolation between neighbouring entries. The final stop wraps back to the first, so flow has no seam. Saved gradients, their stops, and the current palette phase travel in bookmarks and share links. One sampling, three consumers, pixel-identical results.

Some palettes deliberately alter the data style after smoothing. Posterize quantises the palette phase; Strobe creates controlled black-and-white bands; Glitch drives the channels at mismatched frequencies. Their hard graphic edges are intentional properties of P(t)P(t), not a return of the accidental integer escape bands that continuous colouring removed.

Flow — animating the phase

The flow toggle animates φ\varphi, sliding the whole palette along the contours of constant escape count. On the GPU only that uniform changes; the single-pass shader reruns the per-pixel iteration and colouring quickly enough to animate it. The coordinates and mathematical measurement are unchanged while the dressing moves. The moving bands trace the level sets around the boundary, making the contour structure visible in a way a static image never quite manages.

The CPU tiers cannot repaint a full deep frame at animation speed, so flow is deliberately paused there: the current phase holds, the control explains why, and animation resumes if a shallower GPU frame takes over. A shared view stores both the frozen phase and whether flow was enabled, so it reopens coherently.

The interior stays black

Every algorithm on this page applies to escaped pixels only. A pixel that never escaped carries no escape count — there is nothing honest to map — so the interior renders solid black in every palette and every mode. Where the picture needs geometry rather than colour, the shading chapter shows the derivative-based tools.

When “escape” is not the outcome

Newton and Nova are the honest exceptions because they ask different questions. They do not send an ordinary smooth escape count through the pipeline above.

For Newton's method, almost every starting point converges to one of the three roots of z31z^3-1. The root index chooses three evenly spaced phases around the selected cyclic palette, giving each basin of attraction its identity. Convergence speed controls brightness: fast basin interiors stay bright, while slow points on the contested basin boundaries fade toward black. Pixels that never converge are black.

Nova can converge, escape, or do neither. A converged pixel starts from its root's basin phase and adds smooth convergence-time banding; an escaped pixel uses escape-speed banding; a pixel that reaches neither verdict is black. Those black undecided regions form the minibrot-like bodies in Nova's parameter plane. Distance and slope shading do not apply to either root-finding colour path—the family outcome already owns the meaning of brightness.

COLOUR HAS A SEMANTIC CONTRACT

For escape-time families, hue means a cyclic function of escape time. For Newton it identifies a root and brightness measures convergence. For Nova it encodes a hybrid outcome. Reusing the same palette does not mean pretending these measurements are the same.

The classic alternative: histogram colouring

One famous algorithm is deliberately absent. Histogram equalisation colours each pixel by its rank: build the distribution of escape counts over the whole frame, then map each pixel to the cumulative fraction of pixels with a smaller count. The appeal is automatic contrast — the palette is always spread evenly over whatever counts the view actually contains, no density slider needed.

The catch is the phrase whole frame. No pixel can be coloured until every pixel has been counted — a two-pass algorithm. Mandala's renderer is a streaming pipeline: the view is cut into tiles, computed in parallel, and each tile is painted the moment it lands (how a frame gets fast). A global histogram would hold every tile hostage to the slowest one — or accept visible seams from per-tile approximations. The cyclic mapping keeps colouring a pure per-pixel function, which is what lets tiles stream, engines agree byte-for-byte, and the GPU colour in the same pass that iterates.

How the engines are kept in agreement

The Rust CPU core and WGSL shader are separate programs, so matching by good intent is not enough. Scheme, shading and interior ids are treated as a numeric contract. Tests compare those ids and shared constants such as bailout and LUT size; pinned WASM tiles guard exact CPU output; full-frame goldens and a GPU-versus-CPU comparison catch visual drift. The goal across a tier handoff is not “similar colours”—it is the same measurement interpreted by the same pipeline.