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. Keyboard: Space runs or pauses, . steps, r resets.
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
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import { MAGMA, sampleInto } from "../render/colormaps"; |
| 3 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 4 | |
| 5 | const 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 | */ |
| 13 | export 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 | |
| 173 | function 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.