Skip to content

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.

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

1import { BaseExperiment } from "../core/base-experiment";
2import { MAGMA, sampleInto } from "../render/colormaps";
3import type { ParameterDef, ThemeColors } from "../core/types";
4
5const 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 */
11export 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.