Author

Steve Trettel

Published

January 2026

1 Day 2: Fractals

1.1 Overview

A fractal is a shape with structure at every scale—zoom in and you find more detail, forever. Today we make two of them:

The first is the Mandelbrot set, born from iterating \(z \mapsto z^2 + c\) in the complex plane. The second is the Apollonian gasket, born from iterating circle inversions. Different mathematics, same principle: take a simple operation and repeat it.

Both fit naturally into shaders. Each pixel asks “what happens when I iterate from here?”—and since pixels don’t depend on each other, the GPU can answer millions of these questions in parallel.

The Mandelbrot set lives in the complex plane, so we start there.

1.2 Complex Numbers in GLSL

The complex numbers \(\mathbb{C}\) are the plane \(\mathbb{R}^2\) equipped with a multiplication. A point \((a, b)\) represents \(a + bi\), and we define \[(a + bi)(c + di) = (ac - bd) + (ad + bc)i.\]

This makes \(\mathbb{C}\) a field, with \(i^2 = -1\) encoding the rotation structure of complex multiplication.

Representation

A complex number \(z = a + bi\) is naturally a vec2:

vec2 z = vec2(a, b);  // represents a + bi

We use the convention that z.x is the real part and z.y is the imaginary part.

Arithmetic

Addition is componentwise, so GLSL’s built-in + already does the right thing. No helper function needed.

Multiplication requires care. The * operator on vectors is componentwise—vec2(a,b) * vec2(c,d) gives vec2(a*c, b*d)—which is not complex multiplication. We implement the correct formula:

vec2 cmul(vec2 z, vec2 w) {
    return vec2(
        z.x * w.x - z.y * w.y,
        z.x * w.y + z.y * w.x
    );
}

The minus sign in the real part comes from \(i^2 = -1\).

Magnitude

The magnitude \(|z| = \sqrt{a^2 + b^2}\) measures distance from the origin. GLSL provides a built-in length() function for this:

length(z)  // computes sqrt(z.x*z.x + z.y*z.y)

We’ll use this to test conditions like \(|z| > 2\).

1.3 The Mandelbrot Set

Fix a complex number \(c\). Starting from \(z_0 = 0\), iterate: \[z_{n+1} = z_n^2 + c\]

For some values of \(c\), this sequence stays bounded. For others, it escapes to infinity. The Mandelbrot set \(\mathcal{M}\) is the set of all \(c\) for which the sequence remains bounded: \[\mathcal{M} = \{c \in \mathbb{C} : \text{the orbit of } 0 \text{ under } z \mapsto z^2 + c \text{ is bounded}\}\]

The boundary has intricate structure, but we never describe this geometry explicitly. Every pixel runs the same computation, asks “does my orbit escape?”, and the structure emerges.

The Escape Radius

We can’t iterate forever, so we need a stopping criterion. Two facts make this practical:

Fact 1. If \(|c| > 2\), then \(c \notin \mathcal{M}\).

Fact 2. If \(|z_n| > 2\) for some \(n\), the orbit escapes to infinity, so \(c \notin \mathcal{M}\).

Together, these justify the escape-time algorithm: iterate until \(|z| > 2\) (the orbit is escaping) or until we hit a maximum iteration count (the orbit is probably bounded). The number 2 here is the escape radius.

Proving these facts requires some careful estimates with the triangle inequality; we leave this as Challenge H4.

Implementation

vec2 cmul(vec2 z, vec2 w) {
    return vec2(z.x * w.x - z.y * w.y, z.x * w.y + z.y * w.x);
}

vec2 normalize_coord(vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    uv = uv - vec2(0.5, 0.5);
    uv.x *= iResolution.x / iResolution.y;
    return uv * 2.5;
}

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 c = normalize_coord(fragCoord);
    c.x = c.x - 0.5;  // shift left to center the interesting part
    
    vec3 color = vec3(0.0, 0.0, 0.0);
    
    vec2 z = vec2(0.0, 0.0);
    
    for (int i = 0; i < 100; i++) {
        if (length(z) > 2.0) {
            color = vec3(1.0, 1.0, 1.0);
            break;
        }
        z = cmul(z, z) + c;
    }
    
    fragColor = vec4(color, 1.0);
}

1.4 Coloring Escape-Time Fractals

Black and white shows the set, but we’re throwing away information. The iteration count tells us how quickly a point escapes—points that escape after 5 iterations are different from points that escape after 50.

Grayscale

Map the iteration count to brightness. To use the iteration count after the loop, declare it beforehand:

vec3 color = vec3(0.0, 0.0, 0.0);

vec2 z = vec2(0.0, 0.0);
int i;
for (i = 0; i < 100; i++) {
    if (length(z) > 2.0) break;
    z = cmul(z, z) + c;
}

if (i < 100) {
    float t = float(i) / 100.0;
    float gray = 1.0 - t;
    color = vec3(gray, gray, gray);
}

Structure appears: tendrils, spirals, bulbs around the boundary. Points far from the Mandelbrot set escape quickly (white); points near the boundary take many iterations to escape (gray); points in the set never escape (black).

Color

Grayscale reveals structure, but color can reveal more. We use a cosine palette (see Appendix: Color) to map iteration counts to smooth color gradients:

vec3 palette(float t) {
    vec3 a = vec3(0.5, 0.5, 0.5);
    vec3 b = vec3(0.5, 0.5, 0.5);
    vec3 c = vec3(1.0, 1.0, 1.0);
    vec3 d = vec3(0.00, 0.33, 0.67);
    return a + b * cos(6.28318 * (c * t + d));
}

// in mainImage, after the loop:
if (i < 100) {
    float t = float(i) / 100.0;
    color = palette(t);
}

The color bands correspond to iteration counts. There’s another fractal hiding in the same iteration.

1.5 Julia Sets

The Mandelbrot set asks: for which \(c\) does the orbit of \(0\) stay bounded? We can ask a different question: for a fixed \(c\), which starting points \(z_0\) have bounded orbits?

Fix \(c \in \mathbb{C}\). The filled Julia set \(K_c\) is \[K_c = \{z_0 \in \mathbb{C} : \text{the orbit of } z_0 \text{ under } z \mapsto z^2 + c \text{ is bounded}\}\]

Same iteration, different question. For the Mandelbrot set, we vary \(c\) and always start at \(z_0 = 0\). For a Julia set, we fix \(c\) and vary the starting point.

The code change is minimal—just swap which variable comes from the pixel and which is fixed. The iteration and coloring logic stay the same:

// Mandelbrot: c comes from pixel position, z starts at 0
vec2 c = normalize_coord(fragCoord);
c.x = c.x - 0.5;
vec2 z = vec2(0.0, 0.0);

// Julia: c is fixed, z comes from pixel position
vec2 c = vec2(-0.7, 0.27015);  // a fixed parameter
vec2 z = normalize_coord(fragCoord);

The escape condition \(|z| > 2\) still works for most Julia sets; the precise escape radius depends on \(|c|\) (see Exercise H3).

Different values of \(c\) produce very different Julia sets. Some are connected (one piece), others are totally disconnected (Cantor dust). The relationship is governed by a precise theorem.

The Mandelbrot-Julia Correspondence

Each point \(c\) in the complex plane has an associated Julia set \(K_c\), and the Mandelbrot set tells you what kind:

Theorem (Douady-Hubbard). The filled Julia set \(K_c\) is connected if and only if \(c \in \mathcal{M}\).

If \(c\) lies inside the Mandelbrot set, the Julia set is a single connected piece—perhaps with a complicated boundary, but topologically one blob. If \(c\) lies outside the Mandelbrot set, the Julia set shatters into uncountably many pieces, a Cantor set of dust. The Mandelbrot set is precisely the set of parameters for which the Julia set holds together.

Points on the boundary of \(\mathcal{M}\) give the most intricate Julia sets—neither solid nor completely fractured, but balanced at the edge of connectivity.

Drag from inside the Mandelbrot set to outside and watch the Julia set transform. Connected structures with complicated boundaries give way to scattered dust as you cross into the exterior.

1.6 Circle Inversion

We’ve been iterating a polynomial. But polynomials aren’t the only thing we can iterate.

Circle inversion is to circles what reflection is to lines: it swaps inside and outside, preserves angles, and applying it twice returns you to where you started. We’ll build another fractal the same way we built the Mandelbrot set: iterate a map, check a condition on where the point ends up, and color accordingly. For Mandelbrot, we checked whether the orbit escaped past radius 2. For our next fractal, we’ll check which region the orbit lands in after bouncing between inversions.

Definition

Inversion in the unit circle sends a point \(\mathbf{p}\) to: \[\text{inv}(\mathbf{p}) = \frac{\mathbf{p}}{|\mathbf{p}|^2}\]

The inverted point lies on the same ray from the origin, but at reciprocal distance: if \(\mathbf{p}\) is at distance \(r\), its image is at distance \(1/r\). Points inside the circle map outside; points outside map inside; points on the circle stay fixed. (The origin maps to “infinity”—inversion extends naturally to the Riemann sphere.)

vec2 invert(vec2 p) {
    return p / dot(p, p);
}

Visualizing Inversion

To see what inversion does, let’s draw some shapes and watch them transform. The shader below toggles between original and inverted coordinates:

vec2 p_inv = invert(p);

// Toggle every second
vec2 q;
if (fract(iTime * 0.5) < 0.5) {
    q = p;
} else {
    q = p_inv;
}

// Draw shapes using q

Lines not through the origin become circles through the origin. Circles map to circles, or to lines if they pass through the center of inversion.

TipGLSL Shortcuts: mix and step

The toggle logic can be written more compactly:

  • step(edge, x) returns 0 if x < edge, otherwise 1
  • mix(a, b, t) linearly interpolates: returns a when t = 0, b when t = 1
float t = step(0.5, fract(iTime * 0.5));
vec2 q = mix(p, p_inv, t);

Invert a grid. The function mod(q, 0.5) gives the position of q within a repeating \(0.5 \times 0.5\) cell. When either component is near zero, we’re on a grid line:

vec2 grid = mod(q, 0.5);
if (grid.x < 0.02 || grid.y < 0.02) color = vec3(1.0, 1.0, 0.0);

The rectilinear grid becomes a web of circles, all passing through the origin.

General Circle Inversion

So far we’ve inverted through the unit circle at the origin. For a circle with center \(\mathbf{c}\) and radius \(R\): \[\text{inv}(\mathbf{p}) = \mathbf{c} + R^2 \frac{\mathbf{p} - \mathbf{c}}{|\mathbf{p} - \mathbf{c}|^2}\]

The point is reflected through the circle: same ray from the center, reciprocal distance (scaled by \(R^2\)).

1.7 Structs

To invert through an arbitrary circle, we need to pass both a center and a radius. We could write:

vec2 invert(vec2 p, vec2 center, float radius) { ... }

But when working with multiple circles, this gets unwieldy. GLSL lets us bundle related data into a struct:

struct Circle {
    vec2 center;
    float radius;
};

Now Circle is a type. We can create instances and access their fields:

Circle c = Circle(vec2(1.0, 0.5), 0.7);
// c.center is vec2(1.0, 0.5)
// c.radius is 0.7

Our inversion function becomes cleaner:

vec2 invert(vec2 p, Circle c) {
    vec2 d = p - c.center;
    return c.center + c.radius * c.radius * d / dot(d, d);
}

And we can write helper functions that take circles as arguments:

float distToCircle(vec2 p, Circle c) {
    return abs(length(p - c.center) - c.radius);
}

bool isInside(vec2 p, Circle c) {
    return length(p - c.center) < c.radius;
}

1.8 The Apollonian Gasket

The Apollonian gasket is a fractal circle packing, named for Apollonius of Perga who studied tangent circles in the 3rd century BCE. Start with four mutually tangent circles, three inside one, and fill each curved gap with a circle tangent to its three neighbors. Repeat.

Setup

Place three circles of radius \(r\) with centers at the vertices of an equilateral triangle, all tangent to each other and enclosed by an outer circle tangent to all three:

float r = 1.0;
float circumradius = 2.0 * r / sqrt(3.0);  // center-to-vertex distance

Circle c1 = Circle(vec2(0.0, circumradius), r);
Circle c2 = Circle(vec2(-circumradius * sqrt(3.0)/2.0, -circumradius * 0.5), r);
Circle c3 = Circle(vec2(circumradius * sqrt(3.0)/2.0, -circumradius * 0.5), r);
Circle outer = Circle(vec2(0.0, 0.0), circumradius + r);

The gaps between circles are curvilinear triangles.

Iteration

If a point is inside one of the inner circles, invert it through that circle, pushing it out. If it’s outside the outer circle, invert through the outer circle—pulling it in. Repeat until the point lands in a gap (inside the outer circle but outside all inner circles) or we hit a maximum iteration count.

int i;
for (i = 0; i < 50; i++) {
    if (isInside(p, c1)) {
        p = invert(p, c1);
    } else if (isInside(p, c2)) {
        p = invert(p, c2);
    } else if (isInside(p, c3)) {
        p = invert(p, c3);
    } else if (!isInside(p, outer)) {
        p = invert(p, outer);
    } else {
        break;  // in a gap—done
    }
}

Color by iteration count, just like escape-time fractals:

float t = float(i) / 50.0;
vec3 color = palette(t);

Each inversion maps the configuration into a smaller copy of itself, creating self-similar structure at every scale.

Full Implementation

struct Circle {
    vec2 center;
    float radius;
};

vec2 invert(vec2 p, Circle c) {
    vec2 d = p - c.center;
    return c.center + c.radius * c.radius * d / dot(d, d);
}

bool isInside(vec2 p, Circle c) {
    return length(p - c.center) < c.radius;
}

vec3 palette(float t) {
    vec3 a = vec3(0.5, 0.5, 0.5);
    vec3 b = vec3(0.5, 0.5, 0.5);
    vec3 c = vec3(1.0, 1.0, 1.0);
    vec3 d = vec3(0.00, 0.33, 0.67);
    return a + b * cos(6.28318 * (c * t + d));
}

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 uv = fragCoord / iResolution.xy;
    uv = uv - vec2(0.5, 0.5);
    uv.x *= iResolution.x / iResolution.y;
    vec2 p = uv * 6.0;
    
    // Setup circles
    float r = 1.0;
    float circumradius = 2.0 * r / sqrt(3.0);
    
    Circle c1 = Circle(vec2(0.0, circumradius), r);
    Circle c2 = Circle(vec2(-circumradius * sqrt(3.0)/2.0, -circumradius * 0.5), r);
    Circle c3 = Circle(vec2(circumradius * sqrt(3.0)/2.0, -circumradius * 0.5), r);
    Circle outer = Circle(vec2(0.0, 0.0), circumradius + r);
    
    // Iterate inversions
    int i;
    for (i = 0; i < 50; i++) {
        if (isInside(p, c1)) {
            p = invert(p, c1);
        } else if (isInside(p, c2)) {
            p = invert(p, c2);
        } else if (isInside(p, c3)) {
            p = invert(p, c3);
        } else if (!isInside(p, outer)) {
            p = invert(p, outer);
        } else {
            break;
        }
    }
    
    // Color by iteration count
    float t = float(i) / 50.0;
    vec3 color = palette(t);
    
    fragColor = vec4(color, 1.0);
}

The Limit Set

Points that land in a gap quickly are dark. Points near the fractal boundary, the limit set, take many iterations to settle and appear bright. We can emphasize the limit set with nonlinear coloring:

float t = float(i) / 100.0;
float t2 = pow(t, 2.0);
vec3 color = 30.0 * vec3(t2, t2, t2);

The squaring suppresses low iteration counts while the factor of 30 boosts high ones:

All of this from iterating four circle inversions.

1.9 Summary

Today we built fractals from two different iterations: complex quadratic polynomials (Mandelbrot and Julia sets) and circle inversions (Apollonian gasket). Despite their different origins, both share the same algorithmic structure:

  1. Iterate a transformation
  2. Test a stopping condition (escape, or landing in a fundamental region)
  3. Color based on iteration count

This pattern—per-pixel iteration with no communication between pixels—is ideal for GPUs.

GLSL Skills

  • Complex arithmetic: Representing \(\mathbb{C}\) as vec2, implementing cmul
  • Structs: Bundling related data (Circle with center and radius)
  • Control flow: for loops with early break, nested if/else
  • Built-in functions: dot, length, mod, step, mix
  • Cosine palettes: Mapping scalar values to smooth color gradients

Key Concepts

  • The escape radius lets us stop iterating early—once \(|z|\) exceeds the threshold, we know the orbit escapes
  • The Mandelbrot set indexes Julia sets: \(c \in \mathcal{M}\) if and only if \(K_c\) is connected
  • Circle inversion generalizes reflection and preserves angles (it’s a conformal map)
  • Iteration count encodes geometric information—how close a point is to the fractal boundary