The Silent Llama of Doom

Thoughts from a quiet Llama.



Fractals: the Mandelbrot Set

Sat 01 February 2020 by A Silent Llama


We’re going to temporarily set aside L-systems to look at a different type of fractal. As we’ve seen creating fractals involves repeating a process over and over again. We’ve seen this with simple drawings of triangles and squares and moved up to more complex patterns involving branching and randomness. We’re now going to take a detour in to the world of mathematical functions. I’m going to try and explain everything from the ground up - all I’m assuming is that you have some basic high school math.

Math Functions?

Repeating shapes makes intuitive sense but what can we achieve by repeating a math function? The example I’ve seen in several textbooks is one you’ve probably already played with. When you first get a calculator with a square root function a lot of people instinctively start pressing the button over and over again to see what happens. You’ll notice that eventually, no matter how big the starting number, you hit zero. This is an example of an iterated function. This should sound familiar - it’s exactly the same idea we’ve been using for our previously fractals - iterating the same process on itself.

Let’s start a new Rust project making sure we have the image library available:

$ cargo new mandelbrot

:::rust
// Cargo.toml
[dependencies]
image = "*"

Alright so which math functions are we going to try? I’m not going to jump straight into the fractal, I’m going to try and sneak up on it. We’ll start with the 1 dimensional case.

Escapism

For the heck of it let’s start with the function f(x) = x2. Everyone who’s done high school math will be familiar with this one. We’re not going to draw the graph though - we’re going to focus on what happens when we iterate it.

Let’s pick a random starting number, say 4:

f(4) = 42 = 16

Now let’s iterate:

f(4) = 16; f(16) = 256; f(256) = 65536; f(65536) = 4294967296

Whoa, that’s going to get really big really fast. Let’s pick a different number, say, 0.4:

f(0.4) = 0.16; f(0.16) = 0.0256; f(0.0256) = 0.00065546; f(0.00065546) = a seriously small number

This one is the opposite - it just gets smaller and smaller.

One last one (literally):

f(1) = 1; f(1) = 1; f(1) = 1

This never goes anywhere.

We can see that there are three cases:

We can simplify this down to two cases: either our iterations escape or they don’t. I’ve seen some texts describe the numbers that don’t escape as prisoner values which is a nicely evocative term.

One way of visualising the process is to imagine the multiplication stretching or squeezing our initial number. In general multiplying scales the number being multiplied.

Stretch 5 x 5 5 25
Squeeze ¼ x ¼ ¹⁄₁₆ ¼

You can imagine the numbers that escape are literally running away on the number line while the numbers that don’t are dragged back to the origin. You can practically hear the fingernails scraping on the number line floor.

Now we’re going to attempt to draw this so we can analyze the whole number line visually.

// main.rs
use image::{ImageBuffer, Rgb};
use std::path::Path;

fn main() {
    let width: u32 = 800;
    let height: u32 = 50;

    let mut imgbuf = ImageBuffer::from_pixel(width, height, Rgb([255, 255, 255]));

    for x in 0..width {
        // Need to iterate here:
        // -- Todo --
        imgbuf.put_pixel(x, 25, image::Rgb([0, 0, 0]));
    }

    imgbuf
        .save(&Path::new("rendered.png"))
        .expect("Unable to save image");
}

Okay - we’ve set up an image and we’re going to walk across the width of the image. Iterating our function only produces a single value so we fix our y coordinate to 25 for now. We’re going to analyze the iterations in the following way:

We’re hoping that whatever visual turns up it’ll help us understand our function a little more. So, how do we tell whether a point escapes or not? We’re going to use a rule of thumb (we’re computer people not mathematicians!). We can see from the examples above that points bigger than 1 escape so we’ll just say that if, after iterating, the output is bigger than 1 then we know the value will escape and if it’s smaller or equal to 1 then it won’t. We’ll call this number our escape threshold.

Here we go:

// main.rs
use image::{ImageBuffer, Rgb};
use std::path::Path;

fn main() {
    let width: u32 = 800;
    let height: u32 = 50;

    let mut imgbuf = ImageBuffer::from_pixel(width, height, Rgb([255, 255, 255]));
    let max_iterations = 10;
    let escape_threshold = 1.0;

    for x in 0..width {
        let mut iteration = 0;
        let mut iterated_value = x;

        // We check for both the escape_threshold and max_iterations so we can
        // bail out early the moment we exceed the threshold. This isn't important
        // in this 1 dimensional case but it helps when we move to 2D.
        while iterated_value <= escape_threshold && iteration < max_iterations {
           iterated_value = iterated_value * iterated_value;
           iteration += 1;
        }

        // We can only hit our max_iterations value if we *don't* escape:
        if iteration == max_iterations {
           // The entire image is white by default so we
           // only need to worry about colouring in the
           // case where we don't escape:
           imgbuf.put_pixel(x, 25, image::Rgb([0, 0, 0]));
        }
    }

    imgbuf
        .save(&Path::new("rendered.png"))
        .expect("Unable to save image");
}

If you try and run this we’ll get an error! What’s the problem? Well - we’re scanning over the width of our image but that means our x is only ever going to hit the sequence 1, 2, 3 up until our width. We’re missing all the real numbers in between and we’re going far enough that we’re multiplying numbers big enough to overflow our iterated_value.

What we need to do is find some way to map our width onto the number line that we’re analysing. For now let’s say we only really want to analyse what happens to our function for the numbers from -2.0 to 2.0. We’ll call these our start and end values:

// main.rs
// -- snip --
let max_iterations = 10;
let start: f64 = -2.0;
let end: f64 = 2.0;
// -- snip --

From this we can work out that the width on the number line by calculating the absolute value of the difference between end and start:

let number_line_width = (end - start).abs();

If we divide our number_line_width with the width of our image we’ll essentially find out how big a step we need to take on the number line for each step in the image. Think of the image like a window into the number world - we’re just working out what portion of the number line we’re looking at. If we liked we can zoom in by making our start and end closer together or zoom out by making our start and end further apart.

// This fits the number_line_width into our image width:
let step: f64 = number_line_width / width as f64;

We’re working with small numbers here so we need to tell Rust to use floating point numbers. Now when we iterate we use our start value as the first value to iterate. We know that each pixel on the image represents a size step on the number line so we need to multiply our step by our current x value to find out how far we are on the number line.

// main.rs
for x in 0..width {
    let mut iteration = 0;
    // Convert our x on the image to a point on the number line:
    let mut iterated_value = start + (x as f64 * step);

    while iteration < max_iterations {
       iterated_value = iterated_value * iterated_value;
       iteration += 1;
    }

    if iterated_value < escape_threshold {
       imgbuf.put_pixel(x, 25, image::Rgb([0, 0, 0]));
    }
}

This produces the following exciting image:


Alright, maybe not so exciting. We’ve managed to draw a line without needing the Bresenham algorithm though. Good for us! We can see something interesting though - there’s only a small section of this function that doesn’t escape. This makes sense - any number bigger than 1 will keep getting bigger so the only section that can’t escape are the numbers between -1 and 1.

Escaping in 2D

Lines are boring. What happens if we move into 2D space? We want to do the same thing f(x) = x2 but how? If we try the equation: f(x,y) = x2 + y2 we notice that it’s output is a single number but its input is two numbers. How are we supposed to iterate it?

What we’re going to need is something 2 dimensional that when multiplied with itself produces another 2 dimensional thing. Then we can write our equation as f(something) = something2. We just need to define this elusive something. What we’re really looking for is a 2 dimensional number.

2 Dimensional Numbers

Let’s look back to our very first equation: f(x) = x2. Mathematicians have spent years looking at equations like that trying to work out their solutions. For example if f(x) = 4 then we know:

f(x) = 4

x2 = 4

x = √4

x = 2 or x = -2

What about the following?

f(x) = -1

x2 = -1

x = √-1

Whoops! We can’t get a square root of a negative number. A negative times a negative is always positive, there’s no way we can find a number multiplied by itself and still get a negative.

This is annoying for mathematicians and for good reason. You might notice that when we can solve this equation that there are 2 valid solutions. It’s irksome that a particular set of numbers provides no solution at all. Mathematicians fixed this problem by introducing a new number: i.

We define i as follows: i * i = -1 and therefore i = √-1. You might think this is crazy - how can we just go ahead and declare the existence of an entirely new number, especially one that seems to defy the previous laws of math? Well - we’ve done it before. Think of the sum 3 - 4. That’s technically impossible too. There’s no way we can subtract more than we have. But mathematicians solved that by introducing a new type of number: -1. We know what it means: it means I owe you one.

In order for this to work mathematicians declared that from now on we have a new mixed type of number. It mixes our ordinary numbers with these new imaginary numbers (which is what i stands for). They call these numbers complex numbers, not because they’re difficult but because they’re no longer simple. A simple thing has only one part - these numbers have two parts: a Real part and an Imaginary part. Now we can get an entire Imaginary number line by simply adding up i. We can start with i and then have 2i, 3i, -i etc…

If the Real number line is the one we’ve always used where is the Imaginary one? Well - it’s directly opposed to the Real line and hence it’s perpendicular to it:

1 i Real Imaginary

We now have our 2 dimensional number. It’s important to note that the numbers don’t mix - if we add 1 to i we get the number 1 + i. Similarly if we subtract i we get 1 - i. The imaginary part lives in a seperate world from the real part but the combination allows us to find a single complex number for any point in the 2D plane. We can find the point like this: walk our x real units on the real number line and then walk i units on the imaginary number line.

1 i -i 1 + i 1 - i

In the above diagram we can see the imaginary components in green and the real components in blue.

How does multiplication work for Complex Numbers?

So we already saw for our everyday numbers that we can use this idea of stretching or squeezing to define multiplication. But what does multiplication for imaginary numbers do?

Let’s multiply 1 by i. If we have one of i then we have i so 1 * i = i. Multiplying again gives i * i which is -1 (by definition). Multiplying again gives: -1 * i which is -i. One last multiplication brings us back to 1.

Visually this looks like:

1 i -1 -i i * 1 = i i * i = -1 -1 * i = -i -i * i = 1 90° 180° 270° 360°

If we multiply by i we rotate 90°. In general the imaginary components of a complex number rotate by some angle when we multiply. We can write our complex numbers in a different way - if we think of a complex number as being connected to the origin by a line we can create a triangle with some hyptoneuse and angle. Multiplying will then stretch one number by the hyptoneuse of the other and will then add the angles:

Aside: Some of you might want to know what the difference is between a Complex number and a 2D Vector. This is the biggest difference - Vector multiplication is defined either with a dot or cross product which gives very different results versus the rotation by i that complex numbers have.

Back to our code. Lucky we don’t need to implement complex numbers (this post is long enough already) - Rust has a library ready for us:

// Cargo.toml
[dependencies]
num_complex = "0.2"

Now we can use them in our program:

// main.rs
// -- snip --
use num_complex::Complex64;

let width: u32 = 800;
let height: u32 = 800;
let aspect_ratio: f64 = width as f64 / height as f64;

// The further in we zoom the more iterations are required - we make this
// an unsigned 64 bit number to handle just about anything you feel like
// throwing at it.
let max_iterations: u64 = 30;
let escape_threshold = 1.0;

// Our starting point - this is the top left corner of
// the 'window' into the fractal world.
let start_x: f64 = -2.0;
let start_y: f64 = -2.0;

// delta_x is the width of our window.
let delta_x = 4.0;
let delta_y = delta_x / aspect_ratio;

let step_x: f64 = delta_x / width as f64;
let step_y: f64 = delta_y / height as f64;

for x in 0..width {
    for y in 0..height {
        let mut iteration = 0;

        let mut c = Complex64 {
           re: start + (x as f64 * step_x),
           im: start + (y as f64 * step_y)
        };

        while c.norm_sqr().sqrt() < escape_threshold && iteration < max_iterations {
           c = c*c;
           iteration += 1;
        }

        if iteration == max_iterations {
           imgbuf.put_pixel(x, y, image::Rgb([0, 0, 0]));
        }
    }
}

The difference is we’re now going to scan over our entire image instead of just a line so we need to add a second for loop over the image height. Just about everyone renders these images as squares but I’ve added in the calculations for making rectangular images (separating out the x step from the y step and making sure we maintain the same aspect ratio so the image doesn’t become distorted).

One extra twist is for the escape threshold. Previously we could just check if the value we iterated was smaller than the threshold but we have a problem now: Complex numbers don’t have an order! It’s easy to order things on a line but how would you order points in space?

The solution is to re-examine what we were actually doing: when checked our escape threshold in the first program what we were actually doing was comparing the distance of that point from the origin. Since squaring the number always makes it positive we didn’t need to worry about the negative case. So - can we do the same, can we compare the distance of a complex number from the origin?

Pythagoras to the rescue! Of course we can - the distance of a complex point from the origin is simply the length of the hyptoneuse of the triangle you make by connecting the real point on the Real axis, the imaginary point on the y axis the origin to the point itself. E.g.:

P = 5 + 5i y = 5 x = 5 r² = x² + y²

The complex number library we are using has a function to work this out for us: norm_sqr(). We just need to then take the square root and we have our distance from the origin. We can use an escape threshold of 1.0 again - just as with the 1 dimensional case we saw that every number greater than 1 escapes.

We now get:


This should make sense and come as something of a relief - from high school we know that the equation for a circle is x2 + y2 = r2 and, intuitively, squaring our complex numbers feels like we should get something circular out of it. Complex multiplication stretches and squeezes in the same way as our normal Real multiplication did in our first 1 dimensional example. So - anything within the boundary of the circle should get squeezed down to our zero point and everything else should escape.

Now, I know what you’re thinking. This is a really long blog post and we haven’t even drawn a fractal yet. Well - we’re going to add one last wrinkle to our ‘escape analysis’. Instead of just iterating the square of each point we’re going to give each point a little bump. Let’s say our starting point is some complex number c. We’ll then iterate on the equation z2 + c where z is a complex number that starts from 0. This is almost the same as the equation that generated our circle so its worth trying to guess what will happen. After 1 iteration we’ll have c2 just like the first case. But now - we add c back on every iteration. This has the effect of moving our point every iteration. When I first saw it I wondered if it might make our circle smaller. If we keep adding in c it might make it easier for numbers to escape. I then wondered if it might add some bumps to the circle since c might move back into the no escape zone. Think about what you expect the result to be. We can also see that we need to use a larger escape threshold since iterating 2i gives us the sequence -2, 2, -2 etc - the escape threshold must be bigger than 2.

Before we render the whole image here’s a quick diagram illustrating things. This shows the paths particular points travel as we iterate them. You can see how the red and black points escape. The pink point is constant - travelling the same path over and over again. The green point spirals into itself.

We’re ready to do the escape analysis. Let’s draw it:

// main.rs
// -- snip --
let escape_threshold = 2.0;
// -- snip --
for x in 0..width {
    for y in 0..height {
        // c is the point in the complex plane that matches our current
        // position in the image:
        let c = Complex64 {
            re: start + (x as f64 * step_x),
            im: start + (y as f64 * step_y)
        };

        let z = Complex64 { re: 0.0, im: 0 };

        while z.norm_sqr().sqrt() < escape_threshold && iteration < max_iterations {
           z = z*z + c;
           iteration += 1;
        }

        // -- snip --
    }
}

And here’s the what we get:


Well - there are definitely a few bumps! This is one of the most famous fractals of all: the Mandelbrot Set. All the numbers that don’t escape, the prisoner numbers, are in the set and coloured black. The incredible thing is that this picture can be zoomed in indefinitely. Everywhere you look you’ll see a complex mathematical structure.

In fact let’s zoom in so we can see some of the structure - it’s amazing what we can find inside this fractal (remember you can zoom by setting the start_x and start_y and delta_x values. Remember though - the more you zoom the more iterations you’ll need to check if a point escapes or not):






It’s incredible that all this is because of a simple equation: z = z2 + c but it is. You might ask, though, why don’t these images look as colourful as the ones you usually see on the internet? Well - in the next post I’ll show you another type of fractal (the Julia Set) and we’ll work on colouring them so we won’t be too embarrased to show them our friends.

The code is on Gitlab as usual.

References

Wikipedia