Skip to content

Mandelbrot Set

One rule, infinite coastline.

Intermediate / complex-plane / iteration / fractal

Iterate z = z squared plus c and ask whether the sequence escapes. Points inside stay bounded forever and form the set; points outside escape at wildly different speeds, and the escape time becomes color. Zoom in anywhere on the boundary and the detail never runs out.

Tagged: escape-time, fractal, self-similarity, complex numbers

Live simulation

Drag to pan; the zoom and center sliders move deeper into the boundary.

Speed

Drag to pan; the zoom and center sliders move deeper into the boundary.

Mathematics

The Mandelbrot set is the set of complex numbers c for which the orbit of z = 0 under z ↦ z² + c stays bounded. Everything you see is escape time: how many iterations a point survives before |z| exceeds 2.

z_{n+1} = z_n² + c, z_0 = 0

c ∈ M ⇔ sup_n |z_n| ≤ 2

smooth color: ν = n + 1 − log₂(log |z_n| / log 2)

Two facts make the pictures honest. First, |z| > 2 guarantees escape, so the loop is finite. Second, the fractional escape count ν makes bands smooth instead of banded - the color you see is a continuous measurement of dynamics, not a paint job. The boundary of M has Hausdorff dimension 2 and is exactly where the interesting computation lives.

How it works

A 280-wide buffer is iterated per pixel (up to the iteration cap) only when zoom, center, or the cap changes; the counts live in a float array and are re-mapped through a slowly cycling palette every tick, so the image is alive without recomputing. The whole recompute at default settings is a few milliseconds.

Drag pans the view in plane coordinates; zoom doubles the magnification per step. Inside the set (never escaping) is rendered at palette zero.

Implementation

src/engine/experiments/mandelbrot.ts · 176 lines · the module running above

TypeScript · fixed-timestep engine

1import { BaseExperiment } from "../core/base-experiment";
2import { MAGMA, sampleInto } from "../render/colormaps";
3import type { ParameterDef, PointerState, ThemeColors } from "../core/types";
4
5const BUFFER_WIDTH = 280;
6
7/**
8 * Mandelbrot escape-time set. Iteration counts are computed once per
9 * viewport/parameter change into a float buffer (smooth coloring), then
10 * re-mapped through a cycling palette each tick, so the image stays alive
11 * without recomputing the fractal.
12 */
13export class MandelbrotExperiment extends BaseExperiment {
14 readonly id = "mandelbrot";
15
16 private bufferW = 0;
17 private bufferH = 0;
18 private mu = new Float32Array(0);
19 private image: ImageData | null = null;
20 private scratch: HTMLCanvasElement | null = null;
21 private needsCompute = true;
22 private recomputeCount = 0;
23 private cycle = 0;
24 private dragX = 0;
25 private dragY = 0;
26 private dragging = false;
27 private interiorFraction = 0;
28
29 protected params(): ParameterDef[] {
30 return [
31 { key: "zoom", label: "Zoom", min: 0, max: 12, step: 0.1, defaultValue: 0, unit: "×2^" },
32 { key: "centerX", label: "Center x", min: -2, max: 1, step: 0.001, defaultValue: -0.6 },
33 { key: "centerY", label: "Center y", min: -1.4, max: 1.4, step: 0.001, defaultValue: 0 },
34 { key: "maxIter", label: "Iteration cap", min: 32, max: 512, step: 32, defaultValue: 192 },
35 { key: "cycle", label: "Palette cycle", min: 0, max: 1, step: 0.05, defaultValue: 0.15 },
36 ];
37 }
38
39 protected onReset(): void {
40 this.needsCompute = true;
41 this.cycle = 0;
42 }
43
44 protected onParameterChange(key: string): void {
45 if (key !== "cycle") this.needsCompute = true;
46 }
47
48 protected onUpdate(dt: number): void {
49 this.cycle = (this.cycle + this.num("cycle") * dt) % 1;
50 if (this.needsCompute) {
51 this.recompute();
52 this.needsCompute = false;
53 }
54 }
55
56 private recompute(): void {
57 this.bufferW = BUFFER_WIDTH;
58 this.bufferH = Math.max(32, Math.round((BUFFER_WIDTH * this.height) / Math.max(1, this.width)));
59 const size = this.bufferW * this.bufferH;
60 if (this.mu.length !== size) this.mu = new Float32Array(size);
61 const maxIter = this.num("maxIter");
62 const scale = Math.pow(2, -this.num("zoom"));
63 const aspect = this.bufferW / this.bufferH;
64 const spanY = 2.6 * scale;
65 const spanX = spanY * aspect;
66 const cx = this.num("centerX");
67 const cy = this.num("centerY");
68 let interior = 0;
69 for (let py = 0; py < this.bufferH; py++) {
70 const y0 = cy + (py / this.bufferH - 0.5) * spanY;
71 for (let px = 0; px < this.bufferW; px++) {
72 const x0 = cx + (px / this.bufferW - 0.5) * spanX;
73 let x = 0;
74 let y = 0;
75 let it = 0;
76 let x2 = 0;
77 let y2 = 0;
78 while (x2 + y2 <= 64 && it < maxIter) {
79 y = 2 * x * y + y0;
80 x = x2 - y2 + x0;
81 x2 = x * x;
82 y2 = y * y;
83 it += 1;
84 }
85 const i = py * this.bufferW + px;
86 if (it >= maxIter) {
87 this.mu[i] = maxIter;
88 interior += 1;
89 } else {
90 // Smooth coloring: fractional escape iteration.
91 this.mu[i] = it + 1 - Math.log2(Math.max(1e-10, Math.log(x2 + y2) / 2));
92 }
93 }
94 }
95 this.interiorFraction = size > 0 ? interior / size : 0;
96 this.recomputeCount += 1;
97 }
98
99 resize(width: number, height: number): void {
100 this.width = width;
101 this.height = height;
102 this.needsCompute = true;
103 }
104
105 onPointer(state: PointerState): void {
106 const spanY = 2.6 * Math.pow(2, -this.num("zoom"));
107 const spanX = spanY * (this.width / Math.max(1, this.height));
108 if (state.down && !this.dragging) {
109 this.dragging = true;
110 this.dragX = state.x;
111 this.dragY = state.y;
112 }
113 if (this.dragging && state.down) {
114 const dx = ((state.x - this.dragX) / this.width) * spanX;
115 const dy = ((state.y - this.dragY) / this.height) * spanY;
116 if (Math.abs(dx) > 1e-6 || Math.abs(dy) > 1e-6) {
117 this.setInternalValue("centerX", clampNum(this.num("centerX") - dx, -2, 1));
118 this.setInternalValue("centerY", clampNum(this.num("centerY") - dy, -1.4, 1.4));
119 this.dragX = state.x;
120 this.dragY = state.y;
121 this.needsCompute = true;
122 }
123 }
124 if (!state.down) this.dragging = false;
125 }
126
127 render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void {
128 ctx.fillStyle = theme.bg;
129 ctx.fillRect(0, 0, this.width, this.height);
130 if (!this.image || this.image.width !== this.bufferW || this.image.height !== this.bufferH) {
131 this.image = new ImageData(this.bufferW, this.bufferH);
132 this.scratch = null;
133 }
134 const maxIter = this.num("maxIter");
135 const data = this.image.data;
136 for (let i = 0; i < this.mu.length; i++) {
137 const mu = this.mu[i];
138 const t =
139 mu >= maxIter ? 0 : Math.min(1, Math.max(0, Math.sqrt(mu / maxIter) * 0.92 + this.cycle)) % 1;
140 sampleInto(MAGMA, mu >= maxIter ? 0 : t, data, i * 4);
141 }
142 if (!this.scratch) {
143 this.scratch = document.createElement("canvas");
144 this.scratch.width = this.bufferW;
145 this.scratch.height = this.bufferH;
146 }
147 const offCtx = this.scratch.getContext("2d")!;
148 offCtx.putImageData(this.image, 0, 0);
149 ctx.imageSmoothingEnabled = true;
150 ctx.drawImage(this.scratch, 0, 0, this.width, this.height);
151 }
152
153 getMetrics() {
154 const zoom = Math.pow(2, this.num("zoom"));
155 return {
156 zoom: `${zoom < 100 ? zoom.toFixed(1) : zoom.toFixed(0)} ×`,
157 center: `${this.num("centerX").toFixed(3)}, ${this.num("centerY").toFixed(3)}`,
158 maxIter: this.num("maxIter"),
159 interior: `${(this.interiorFraction * 100).toFixed(1)} %`,
160 recomputes: this.recomputeCount,
161 };
162 }
163
164 describe(): string {
165 return `The Mandelbrot set at ${Math.pow(2, this.num("zoom")).toFixed(0)}× zoom with a ${this.num("maxIter")}-iteration cap; drag to pan, sliders to zoom.`;
166 }
167
168 entities(): number {
169 return this.bufferW * this.bufferH;
170 }
171}
172
173function clampNum(value: number, min: number, max: number): number {
174 return value < min ? min : value > max ? max : value;
175}
176

Performance

Time per tick
O(buffer · maxIter) per recompute; O(buffer) per recolor tick
Space
One float per buffer pixel
Notes
Zoom multiplies magnification by 2 per step; deep zooms need both more iterations and more buffer resolution, which is why the iteration cap is exposed.

Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.