GLSL Mandelbrot
Oct 30, 2021

Intro

While working on magicpixel, I wanted to try rendering the Mandelbrot Set. I guess as a $$(sideproject)^2$$. Magicpixel was already using OpenGL for rendering, so it was pretty simple to reuse all the boilerplate code. I pretty much just had to chop out a bunch of unnecessary code and change the fragment shader (more on that later). Here’s the Github repo for the full project source code.

The Mandelbrot Set is a fractal defined as the following:

For every point in complex form $$c = x + yi$$, $$z$$ does not diverge.

$$\forall n \ge 0$$ $$z_0 = 0$$ $$z_{n+1} = z_n^2 + c$$

Mandelbrot Set Approximation

To get an approximation, we could say $$z$$ does not diverge to a high value of $$n$$, instead of “z does not diverge at all”. The higher maximum value of $$n$$, the closer the approximation is to the exact set. However, using a higher number means it will be more computationally expensive. I learned about this technique in a Computational Physics course, but the version I wrote back then was rendering on a single CPU thread, so was too slow to interact with.

Here’s a snipped to decide, approximately, if the point $$x,y$$ is in the Mandelbrot set, written in GLSL.

bool in_set(float x, float y) {
int max_iterations = 5000;

vec2 c = vec2(x, y);
vec2 z = vec2(0.0, 0.0);

for (int i = 0; i < max_iterations; i++) {
if (isinf(magnitude(z))) {
return false;
}

z = product(z, z) + c;
}

return true;
}


But if we were to render this, we would only have a black and white photo. To make the image more interesting, let’s think about how to color the image.

Coloring

The coloring technique I used is to keep track of how many iterations ran per pixel before it realized the pixel isn’t in the set (return false of the above code). By comparing iteration to the max iterations, we have a percentage of how close to the set we were.

So instead of returning a boolean, we could return vec3(0.0, 0.0, 0.0) (black) if a pixel is in the set, or use a formula if it is not in the set:

float percent = float(iterations) / float(max_iterations);
return percent * vec3(1.0, 0.0, 0.0);


This formula will paint pixels shades of red depending on how far away they are from being in the Mandelbrot set.

But something I noticed was that it wasn’t spread nicely. The coloring was very focused near the edges of the rendering, but didn’t transition nicely towards the edges. So to make it a bit nicer, I added an exponential decay:

float v0 = float(iterations) / float(max_iterations);
// Decay so the color distribution isn't so close to the edge
float v = pow(1.0 - v0, 30.0);


To make it more interesting, we could also cycle through a color palette. I chose red->green->blue but it could be anything:

vec3 palette[4] = {
vec3(0.0, 0.0, 0.0),
vec3(1.0, 0.0, 0.0),
vec3(0.0, 1.0, 0.0),
vec3(0.0, 0.0, 1.0)
};
// -1 since the first color is just for transition from black (in-set) to red
int palette_size = 3;


To pick a color from the palette, I multiplied the magnitude v by the palette size. To get smooth transitions, I ran linear interpolation between the “upper” and “lower” palette values:

float v0 = float(i) / float(max_iterations);
// Palette decay so the color distribution isn't so close to the edge
float v = pow(1.0 - v0, 30.0);

float pal_coord = v * palette_size;

vec3 floor_col = palette[int(pal_coord)];
vec3 ceil_col  = palette[int(pal_coord) + 1];
float dist = pal_coord - floor(pal_coord);

// Linear interpolate between upper and lower palette values
return mix(floor_col, ceil_col, dist);


One limitation with this approach is floating point precision. At a certain point of zooming ($$1.54e-4$$ in the above image), the quality of the rendering goes down significantly.