Jacob’s Tech Tavern

Jacob’s Tech Tavern

Maths, Metal, Mandelbrot, and SwiftUI

Heavy metal mathematics on the GPU (in 10 easy steps)

Jacob Bartlett's avatar
Jacob Bartlett
Oct 07, 2026
∙ Paid

I love the chance to play around with Metal shaders.

You can go from an incredibly simple rule, sometimes even just a Tweet-sized line of code, and get remarkable graphical complexity. Xor, the shader God, is a generational talent when it comes to this, with code-golfed tweet-sized shader worlds.

But, mathematically, there is only one GOAT: Benoit Mandelbrot, who coined the term “fractal” and pioneered their exploration with computers. His eponymous Mandelbrot Set is the coolest bit of pure mathematics ever imagined. It creates a shape with infinitely recursive fine structure, from one simple (or, perhaps complex) rule:

\(z_{n+1} = z_n^2 + c\)

I’ll get into the mathematics later: this post takes you through building your own fully-functional Mandelbrot set explorer app using SwiftUI and Metal:

  • 10 mostly-simple steps.

  • 51 lines of SwiftUI code.

  • 39 lines of Metal code.

That’s it. Really. And I promise not to ‘the rest of the owl’ you at any point.

Sponsored Link

Is your mobile app under attack right now?

Discover why real-time threat monitoring is crucial to your cybersecurity posture through a simulated attack demo.

Join GuardSquare on Oct 20th to uncover why threat monitoring is essential to the complete, end-to-end protection of your mobile app.

Sign up here.


10 Simple Steps

  1. Setting up the Metal project

  2. Utilising position in our shader

  3. Applying the physical bounds

  4. Our first Mandelbrot iteration

  5. Running a few iterations

  6. More and more (and more) iterations

  7. Colouring the escaping pixels

  8. Giving SwiftUI a camera

  9. Dragging around the screen

  10. The Mandelbrot Zoom™


1. Setting up the Metal project

Metal is incredibly well suited to drawing fractals, because the GPU can crunch thousands of pixels in parallel. Each pixel is associated with a different value on the x and y axes of our number line, and can be computed independently.

You know the drill.

In Xcode, add a new Metal file named Mandelbrot.metal.

#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;

[[ stitchable ]]
half4 mandelbrot(float2 position) {
    return half4(0.0h, 0.0h, 1.0h, 1.0h);
}

Metal is a platform-exclusive language based on C++.

A few important things to note:

  • [[stitchable]] exposes our shader to SwiftUI.

  • SwiftUI supplies float2 position, a vector (list) of 2 floats, the x and y position of a pixel.

  • We return half4, a vector of 4 half-width floats, representing RGBA values.

We can apply our shader to a SwiftUI view using ShaderLibrary.mandelbrot().

import SwiftUI

@main
struct MetalMandelbrotApp: App {
    var body: some Scene {
        WindowGroup {
            MandelbrotView()
        }
    }
}

struct MandelbrotView: View {
    var body: some View {
        Rectangle()
            .fill(ShaderLibrary.mandelbrot())
            .ignoresSafeArea()
    }
}

Hey Presto, our shader works!

It almost makes you want to cry happy tears. Back in the day, you would have to muck around quite a bit with build settings to get the shader library to behave.


2. Utilising position in our shader

Let’s spin our wheels for a couple of steps and make sure we can manipulate our shader in a few simple ways. Let’s apply the x-value of position to the blue value.

[[ stitchable ]]
half4 mandelbrot(float2 position) {
    float horizontal = position.x / 500.0;
    return half4(0.0h, 0.0h, half(horizontal), 1.0h);
}

Now the B channel is modulated by horizontal position.

This is neat, but it randomly cuts off into “pure blue” quite early.


3. Applying the physical bounds

SwiftUI allows us to pass the bounds of our view or shape into a shader as an argument. Like so:

struct MandelbrotView: View {
    var body: some View {
        Rectangle()
            .fill(ShaderLibrary.mandelbrot(.boundingRect))
            .ignoresSafeArea()
    }
}

This boundingRect supplies 4 numbers: (x position, y position, width, height). You know by now this argument will be a float4.


[[ stitchable ]]
half4 mandelbrot(float2 position, float4 bounds) {
    float horizontal = (position.x - bounds.x) / bounds.z;
    return half4(0.0h, 0.0h, half(horizontal), 1.0h);
}

bounds.x is the left edge of our bounding rectangle, and bounds.z is the width. Instead of guessing, now our blue value is proportional to the pixel’s horizontal position along the whole size of the bounds.

Now we very smoothly apply the blueness across our view’s width.

Finally, we can also work in 2 dimensions:

[[ stitchable ]]
half4 mandelbrot(float2 position, float4 bounds) {
    float2 normalised = (position - bounds.xy) / bounds.zw;
    return half4(half(normalised.x), half(normalised.y), 0.0h, 1.0h);
}

“Normalising” means tuning values to between 0 and 1, so we can get a valid RGB value from each screen pixel position.

Metal is full of syntactic shortcuts: here, we create a float2 for horizontal and vertical values simultaneously by applying float2 in each step of the equation: bounds.xy is a float2 of its x (horizontal origin) and y (vertical origin), whereas bounds.zw is a float2 of the z (width) and w (height) values.


4. Our first Mandelbrot iteration

Let’s get serious now and introduce complex numbers. If you get through this step, the rest is easy. Stick with it 🫡.

Imaginary numbers are made by square rooting a negative. If this is your first time learning about these, woah, crazy, right?

\(i = \sqrt{-1} \qquad i^2 = -1\)

A complex number z is made by combining an “imaginary” number with a “real” number:

\(c = a + bi\)

To make a Mandelbrot set, just run this function on all values of c, recursively:

\(z_{n+1} = z_n^2 + c\)

To draw the set, we apply this function to every point (or, in our case, every pixel position) on the complex plane. This is c.

The result of step n is zₙ. The result of the next step is zₙ₊₁.

For every step, we square the result of the previous step, then add c.

\(\begin{aligned} z_0 &= 0,\qquad c = 1+i \\ z_1 &= z_0^2+c = 1+i \\ z_2 &= (1+i)^2+(1+i) = 1+3i \\ z_3 &= (1+3i)^2+(1+i) = -7+7i \\ z_4 &= (-7+7i)^2+(1+i) = 1-97i \end{aligned}\)

Who ever said maths wasn’t fun?

Some numbers, like 0.1 + 0.1i, will stay small through iterations and never escape. These are in the Mandelbrot set.

Other values of c, like in the above example, will keep growing bigger and bigger as you square them, infinitely. These are not in the Mandelbrot set.

OK. Back to the shaders: our first iteration:

[[ stitchable ]]
half4 mandelbrot(float2 position, float4 bounds) {
    float2 c = (position - bounds.xy - bounds.zw * 0.5) / bounds.w * 4.0;
    float2 z = float2(0.0, 0.0);

    z = float2(
        z.x * z.x - z.y * z.y,
        2.0 * z.x * z.y
    ) + c;

    if (length(z) > 2.0) {
        return half4(1.0h, 1.0h, 1.0h, 1.0h);
    }

    return half4(0.0h, 0.0h, 0.0h, 1.0h);
}

First, we set up the values of c and z, with float2 used to represent complex numbers:

  • z just starts as 0 (or, more specifically, 0 + 0i).

  • c maps pixel positions to each complex number, scaled to the bounds:

    • We set up the real and imaginary components in a float2, using normalised coordinates along the rectangle’s bounds.

    • We take the pixel position, take away the bounds origin to get a relative offset, and take away half the bounds’ size to center our coordinates around “zero”.

    • Then we divide by the bounds’ height to scale from -0.5 to 0.5 vertically, then multiply by 4 to re-scale to -2 to +2.

Next, we perform our iteration: we compute z² + c. A simple mathematical relationship allows us to, functionally, ignore the imaginary-ness of the complex number:

\((a + bi)^2 = a^2 + 2abi + b^2i^2 = (a^2 - b^2) + (2ab)i\)

This relationship allows us to compute the real component and the imaginary component of z as another simple float2.

Finally, we can apply another simple mathematical rule here: any complex number with length (magnitude) over 2 will escape to infinity, and so isn’t in the set, and we return white. Any other complex number might be in the set and we return black.

But we might need a few more iterations to find out the truth.


5. Running a few iterations

Okay, I feel like I lost like 75% of you back there. Originally, the previous step was about six steps, but it made the article very, very boring. To keep momentum I just had to rip the band-aid off.

I promise it gets easier now. We’ll do some SwiftUI next. Scout’s honour.

So we’ve got a big black circle. Let’s see what happens to it when we do a few more iterations. Like I promised, this bit is really easy: We just take our existing calculation for z, and we loop over it six times.

[[ stitchable ]]
half4 mandelbrot(float2 position, float4 bounds) {
    float2 c = (position - bounds.xy - bounds.zw * 0.5) / bounds.w * 4.0;
    float2 z = float2(0.0, 0.0);

    for (int iteration = 0; iteration < 6; iteration++) {
        z = float2(
            z.x * z.x - z.y * z.y,
            2.0 * z.x * z.y
        ) + c;

        if (length(z) > 2.0) {
            return half4(1.0h, 1.0h, 1.0h, 1.0h);
        }
    }

    return half4(0.0h, 0.0h, 0.0h, 1.0h);
}

The one clever nuance here is thus: we can save quite a bit of computation per pixel by allowing early escapes: As soon as the length of z exceeds 2, we can stop iterating and return white, meaning “definitely not in the set”.

I can start to see something familiar take fractal… I mean, take shape. I’m pumped.

This article has been in the works for years, I’m sure you can wait another few weeks to read the whole thing. If not, you can also read it for free:

Get 14 day free trial

Paid subscribers get tons of goodies:

⚓️ Access my full library of 50+ paywalled articles
🚀
Read free articles a month before anyone else
🧵 Master concurrency with my full course and advanced training
🧑‍🚀 Get a free copy of my new eBook, “Land your iOS Tech Job”
❤️‍🩹 Support independent, sometimes funny, tech writing

User's avatar

Continue reading this post for free, courtesy of Jacob Bartlett.

Or purchase a paid subscription.
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture