Julia Sets
One parameter, endlessly many fractals.
Intermediate / complex-plane / iteration / fractal
The Mandelbrot set is the catalog of Julia sets: pick a c, iterate z = z^2 + c from every starting point, and each c gives a different fractal. The animate slider orbits c around the classic circle, morphing the set between connected and dust in one continuous motion.
Tagged: filled Julia set, escape time, orbit, connected
Live simulation
The orbit slider morphs c around the classic circle - the set never stops changing. Keyboard: Space runs or pauses, . steps, r resets.
Speed
The orbit slider morphs c around the classic circle - the set never stops changing.
Mathematics
Fix a complex constant c and iterate z <- z^2 + c from every starting point z_0. The filled Julia set is the set of starting points whose orbits stay bounded. Unlike the Mandelbrot set (the parameter-space picture), each c gives its own fractal in the dynamic plane.
z_{n+1} = z_n^2 + c
escape radius 2: |z| > 2 guarantees divergence
classic orbit: c = 0.7885 e^{i theta}, theta advancing
The escape criterion |z| > 2 is algebra, not convention: once |z| exceeds 2 the next squaring pushes it further, monotonically to infinity. The animate slider walks c around the circle of radius 0.7885 - through dendrites, rabbits, and dust - the whole taxonomy of Julia sets in one rotation.
How it works
A 300-wide buffer is iterated per pixel with the current c each frame when animating (a few milliseconds at the default cap), then colored by escape speed through the same perceptual colormap the Mandelbrot page uses.
The c readout on the canvas updates live so the current parameter is never a mystery.
Implementation
src/engine/experiments/julia-set.ts · 131 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, ThemeColors } from "../core/types"; |
| 4 | |
| 5 | const BUFFER_WIDTH = 300; |
| 6 | |
| 7 | /** |
| 8 | * Julia sets of z = z^2 + c: one complex parameter c, infinitely many sets. |
| 9 | * The animate slider orbits c around a circle, so the set morphs continuously. |
| 10 | */ |
| 11 | export class JuliaSetExperiment extends BaseExperiment { |
| 12 | readonly id = "julia-set"; |
| 13 | |
| 14 | private bufferW = 0; |
| 15 | private bufferH = 0; |
| 16 | private mu = new Float32Array(0); |
| 17 | private image: ImageData | null = null; |
| 18 | private scratch: HTMLCanvasElement | null = null; |
| 19 | private orbit = 0; |
| 20 | |
| 21 | protected params(): ParameterDef[] { |
| 22 | return [ |
| 23 | { key: "cRe", label: "c (real)", min: -1, max: 1, step: 0.001, defaultValue: -0.4 }, |
| 24 | { key: "cIm", label: "c (imaginary)", min: -1, max: 1, step: 0.001, defaultValue: 0.6 }, |
| 25 | { key: "maxIter", label: "Iteration cap", min: 32, max: 320, step: 32, defaultValue: 160 }, |
| 26 | { key: "animate", label: "Orbit speed", min: 0, max: 1, step: 0.05, defaultValue: 0.12 }, |
| 27 | ]; |
| 28 | } |
| 29 | |
| 30 | protected onReset(): void { |
| 31 | this.orbit = 0; |
| 32 | } |
| 33 | |
| 34 | protected onUpdate(dt: number): void { |
| 35 | const speed = this.num("animate"); |
| 36 | if (speed > 0) { |
| 37 | this.orbit = (this.orbit + speed * dt) % (2 * Math.PI); |
| 38 | // Orbit c on a circle of radius 0.7885 centered at origin: the classic |
| 39 | // movie path through connected and disconnected sets. |
| 40 | this.setInternalValue("cRe", 0.7885 * Math.cos(this.orbit)); |
| 41 | this.setInternalValue("cIm", 0.7885 * Math.sin(this.orbit)); |
| 42 | } |
| 43 | this.compute(); |
| 44 | } |
| 45 | |
| 46 | private compute(): void { |
| 47 | this.bufferW = BUFFER_WIDTH; |
| 48 | this.bufferH = Math.max(32, Math.round((BUFFER_WIDTH * this.height) / Math.max(1, this.width))); |
| 49 | if (this.mu.length !== this.bufferW * this.bufferH) { |
| 50 | this.mu = new Float32Array(this.bufferW * this.bufferH); |
| 51 | this.image = null; |
| 52 | this.scratch = null; |
| 53 | } |
| 54 | const maxIter = this.num("maxIter"); |
| 55 | const cre = this.num("cRe"); |
| 56 | const cim = this.num("cIm"); |
| 57 | const span = 3.2; |
| 58 | const aspect = this.bufferW / this.bufferH; |
| 59 | for (let py = 0; py < this.bufferH; py++) { |
| 60 | const y0 = ((py / this.bufferH) - 0.5) * span; |
| 61 | for (let px = 0; px < this.bufferW; px++) { |
| 62 | const x0 = ((px / this.bufferW) - 0.5) * span * aspect; |
| 63 | let zx = x0; |
| 64 | let zy = y0; |
| 65 | let it = 0; |
| 66 | let x2 = zx * zx; |
| 67 | let y2 = zy * zy; |
| 68 | while (x2 + y2 <= 16 && it < maxIter) { |
| 69 | zy = 2 * zx * zy + cim; |
| 70 | zx = x2 - y2 + cre; |
| 71 | x2 = zx * zx; |
| 72 | y2 = zy * zy; |
| 73 | it += 1; |
| 74 | } |
| 75 | this.mu[py * this.bufferW + px] = it; |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | resize(width: number, height: number): void { |
| 81 | this.width = width; |
| 82 | this.height = height; |
| 83 | } |
| 84 | |
| 85 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 86 | ctx.fillStyle = theme.bg; |
| 87 | ctx.fillRect(0, 0, this.width, this.height); |
| 88 | if (!this.image || this.image.width !== this.bufferW || this.image.height !== this.bufferH) { |
| 89 | this.image = new ImageData(this.bufferW, this.bufferH); |
| 90 | this.scratch = null; |
| 91 | } |
| 92 | const maxIter = this.num("maxIter"); |
| 93 | const data = this.image.data; |
| 94 | for (let i = 0; i < this.mu.length; i++) { |
| 95 | const t = this.mu[i] >= maxIter ? 0 : (this.mu[i] / maxIter) ** 0.55; |
| 96 | sampleInto(MAGMA, t, data, i * 4); |
| 97 | } |
| 98 | if (!this.scratch) { |
| 99 | this.scratch = document.createElement("canvas"); |
| 100 | this.scratch.width = this.bufferW; |
| 101 | this.scratch.height = this.bufferH; |
| 102 | } |
| 103 | this.scratch.getContext("2d")!.putImageData(this.image, 0, 0); |
| 104 | ctx.imageSmoothingEnabled = true; |
| 105 | ctx.drawImage(this.scratch, 0, 0, this.width, this.height); |
| 106 | ctx.font = "11px ui-monospace, monospace"; |
| 107 | ctx.fillStyle = theme.fgSecondary; |
| 108 | ctx.fillText( |
| 109 | `c = ${this.num("cRe").toFixed(3)} ${this.num("cIm") >= 0 ? "+" : "-"} ${Math.abs(this.num("cIm")).toFixed(3)}i`, |
| 110 | 10, |
| 111 | 16, |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | getMetrics() { |
| 116 | return { |
| 117 | c: `${this.num("cRe").toFixed(3)} ${this.num("cIm") >= 0 ? "+" : "-"} ${Math.abs(this.num("cIm")).toFixed(3)}i`, |
| 118 | maxIter: this.num("maxIter"), |
| 119 | magnitude: Math.hypot(this.num("cRe"), this.num("cIm")).toFixed(3), |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | describe(): string { |
| 124 | return `The Julia set of z squared plus c with c = ${this.num("cRe").toFixed(3)} ${this.num("cIm") >= 0 ? "+" : "-"} ${Math.abs(this.num("cIm")).toFixed(3)}i.`; |
| 125 | } |
| 126 | |
| 127 | entities(): number { |
| 128 | return this.bufferW * this.bufferH; |
| 129 | } |
| 130 | } |
| 131 | |
Performance
- Time per tick
- O(buffer * maxIter) per frame while animating
- Space
- One ImageData buffer
- Notes
- Push |c| past 2 and the set collapses to dust - every orbit escapes immediately.
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.