Cogs and Levers A blog full of technical stuff

Raymarching and Mandelbulbs

Introduction

Fractals are some of the most mesmerizing visuals in computer graphics, but rendering them in 3D space requires special techniques beyond standard polygonal rendering. This article will take you into the world of ray marching, where we’ll use distance fields, lighting, and soft shadows to render a Mandelbulb fractal — one of the most famous 3D fractals.

By the end of this post, you’ll understand:

  • The basics of ray marching and signed distance functions (SDFs).
  • How to render 3D objects without polygons.
  • How to implement Phong shading for realistic lighting.
  • How to compute soft shadows for better depth.
  • How to animate a rotating Mandelbulb fractal.

This article will build on the knowledge that we established in the Basics of Shader Programming article that we put together earlier. If you haven’t read through that one, it’ll be worth taking a look at.

What is Ray Marching?

Ray marching is a distance-based rendering technique that is closely related to ray tracing. However, instead of tracing rays until they hit exact geometry (like in traditional ray tracing), ray marching steps along the ray incrementally using distance fields.

Each pixel on the screen sends out a ray into 3D space. We then march forward along the ray, using a signed distance function (SDF) to tell us how far we are from the nearest object. This lets us render smooth implicit surfaces like fractals and organic shapes.

Our first SDF

The simplest 3D object we can render using ray marching is a sphere. We define its shape using a signed distance function (SDF):

// Sphere Signed Distance Function (SDF)
float sdfSphere(vec3 p, float r) {
    return length(p) - r;
}

The sdfSphere() function returns the shortest distance from any point in space to the sphere’s surface.

We can now step along that ray until we reach the sphere. We do this by integrating our sfpSphere() function into our mainImage() function:

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // Camera setup
    vec3 rayOrigin = vec3(0, 0, -3);  
    vec3 rayDir = normalize(vec3(uv, 1));

    float totalDistance = 0.0;
    const int maxSteps = 100;
    const float minDist = 0.001;
    const float maxDist = 20.0;
   
    for (int i = 0; i < maxSteps; i++) {
        vec3 pos = rayOrigin + rayDir * totalDistance;
        float dist = sdfSphere(pos, 1.0);

        if (dist < minDist) break;
        if (totalDistance > maxDist) break;

        totalDistance += dist;
    }

    vec3 col = (totalDistance < maxDist) ? vec3(1.0) : vec3(0.2, 0.3, 0.4);
    fragColor = vec4(col, 1.0);
}

First of all here, we convert the co-ordinate that we’re processing into screen co-ordinates:

vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

This uv value is now used to in the calculations to form our ray equation:

vec3 rayOrigin = vec3(0, 0, -3);  
vec3 rayDir = normalize(vec3(uv, 1));

We now iterate (march) down the ray to a maximum of maxSteps (currently set to 100) to determine if the ray intersects with our sphere (via sdfSphere).

Finally, we render the colour of our sphere if the distance is within tolerance; otherwise we consider this part of the background:

vec3 col = (totalDistance < maxDist) ? vec3(1.0) : vec3(0.2, 0.3, 0.4);
fragColor = vec4(col, 1.0);

You should see something similar to this:

Adding Lights

In order to make this sphere look a little more 3D, we can light it. In order to light any object, we need to be able to compute surface normals. We do that via a function like this:

vec3 getNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);  // Small offset for numerical differentiation
    return normalize(vec3(
        sdfSphere(p + e.xyy, 1.0) - sdfSphere(p - e.xyy, 1.0),
        sdfSphere(p + e.yxy, 1.0) - sdfSphere(p - e.yxy, 1.0),
        sdfSphere(p + e.yyx, 1.0) - sdfSphere(p - e.yyx, 1.0)
    ));
}

We make decisions about the actual colour via a lighting function. This lighting function is informed by the surface normals that it computes:

vec3 lighting(vec3 p) {
    vec3 lightPos = vec3(2.0, 2.0, -2.0);  // Light source position
    vec3 normal = getNormal(p);  // Compute the normal at point 'p'
    vec3 lightDir = normalize(lightPos - p);  // Direction to light
    float diff = max(dot(normal, lightDir), 0.0);  // Diffuse lighting
    return vec3(diff);  // Return grayscale lighting effect
}

We can now integrate this back into our scene in the mainImage function. Rather than just making a static colour return when we establish a hit point, we start to execute the lighting function towards the end of the function:

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // Camera setup
    vec3 rayOrigin = vec3(0, 0, -3);  // Camera positioned at (0,0,-3)
    vec3 rayDir = normalize(vec3(uv, 1));  // Forward-facing ray

    // Ray marching parameters
    float totalDistance = 0.0;
    const int maxSteps = 100;
    const float minDist = 0.001;
    const float maxDist = 20.0;
    vec3 hitPoint;
   
    // Ray marching loop
    for (int i = 0; i < maxSteps; i++) {
        hitPoint = rayOrigin + rayDir * totalDistance;
        float dist = sdfSphere(hitPoint, 1.0);  // Distance to the sphere

        if (dist < minDist) break;  // If we are close enough to the surface, stop
        if (totalDistance > maxDist) break;  // If we exceed max distance, stop

        totalDistance += dist;
    }

    // If we hit something, apply shading; otherwise, keep background color
    vec3 col = (totalDistance < maxDist) ? lighting(hitPoint) : vec3(0.2, 0.3, 0.4);
   
    fragColor = vec4(col, 1.0);
}

You should see something similar to this:

Mandelbulbs

We can now upgrade our rendering to use something a little more complex then our sphere.

SDF

The Mandelbulb is a 3D fractal inspired by the 2D Mandelbrot Set. Instead of working in 2D complex numbers, it uses spherical coordinates in 3D space.

The core formula: \(z→zn+c\) is extended to 3D using spherical math.

Instead of a sphere SDF, we’ll use an iterative function to compute distances to the fractal surface.

float mandelbulbSDF(vec3 pos) {
    vec3 z = pos;
    float dr = 1.0;
    float r;
    const int iterations = 8;
    const float power = 8.0;

    for (int i = 0; i < iterations; i++) {
        r = length(z);
        if (r > 2.0) break;

        float theta = acos(z.z / r);
        float phi = atan(z.y, z.x);
        float zr = pow(r, power - 1.0);
        dr = zr * power * dr + 1.0;
        zr *= r;
        theta *= power;
        phi *= power;

        z = zr * vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)) + pos;
    }

    return 0.5 * log(r) * r / dr;
}

This function iterates over complex numbers in 3D space to compute the Mandelbulb structure.

Raymarching the Mandelbulb

Now, we can take a look at what this produces. We use our newly created SDF to get our hit point. We’ll use this distance value as well to establish different colours.

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // Camera setup
    vec3 rayOrigin = vec3(0, 0, -4);
    vec3 rayDir = normalize(vec3(uv, 1));

    // Ray marching parameters
    float totalDistance = 0.0;
    const int maxSteps = 100;
    const float minDist = 0.001;
    const float maxDist = 10.0;
    vec3 hitPoint;
   
    // Ray marching loop
    for (int i = 0; i < maxSteps; i++) {
        hitPoint = rayOrigin + rayDir * totalDistance;
        float dist = mandelbulbSDF(hitPoint);  // Fractal distance function

        if (dist < minDist) break;
        if (totalDistance > maxDist) break;

        totalDistance += dist;
    }

    // Color based on distance (simple shading)
    vec3 col = (totalDistance < maxDist) ? vec3(1.0 - totalDistance * 0.1) : vec3(0.1, 0.1, 0.2);

    fragColor = vec4(col, 1.0);
}

You should see something similar to this:

Rotation

We can’t see much with how this object is oriented. By adding some basic animation, we can start to look at the complexities of how this object is put together. We use the global iTime variable here to establish movement:

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // Rotate camera around the fractal using iTime
    float angle = iTime * 0.5;  // Adjust speed of rotation
    vec3 rayOrigin = vec3(3.0 * cos(angle), 0.0, 3.0 * sin(angle)); // Circular path
    vec3 target = vec3(0.0, 0.0, 0.0); // Looking at the fractal
    vec3 forward = normalize(target - rayOrigin);
    vec3 right = normalize(cross(vec3(0, 1, 0), forward));
    vec3 up = cross(forward, right);
   
    vec3 rayDir = normalize(forward + uv.x * right + uv.y * up);

    // Ray marching parameters
    float totalDistance = 0.0;
    const int maxSteps = 100;
    const float minDist = 0.001;
    const float maxDist = 10.0;
    vec3 hitPoint;

    // Ray marching loop
    for (int i = 0; i < maxSteps; i++) {
        hitPoint = rayOrigin + rayDir * totalDistance;
        float dist = mandelbulbSDF(hitPoint);  // Fractal distance function

        if (dist < minDist) break;
        if (totalDistance > maxDist) break;

        totalDistance += dist;
    }

    // Color based on distance (simple shading)
    vec3 col = (totalDistance < maxDist) ? vec3(1.0 - totalDistance * 0.1) : vec3(0.1, 0.1, 0.2);

    fragColor = vec4(col, 1.0);
}

You should see something similar to this:

Lights

In order to make our fractal look 3D, we need to be able to compute our surface normals. We’ll be using the mandelbulbSDF function above to accomplish this:

vec3 getNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    return normalize(vec3(
        mandelbulbSDF(p + e.xyy) - mandelbulbSDF(p - e.xyy),
        mandelbulbSDF(p + e.yxy) - mandelbulbSDF(p - e.yxy),
        mandelbulbSDF(p + e.yyx) - mandelbulbSDF(p - e.yyx)
    ));
}

We now use this function to light our object using phong shading:

// Basic Phong shading
vec3 phongLighting(vec3 p, vec3 viewDir) {
    vec3 normal = getNormal(p);

    // Light settings
    vec3 lightPos = vec3(2.0, 2.0, -2.0);
    vec3 lightDir = normalize(lightPos - p);
    vec3 ambient = vec3(0.1); // Ambient light

    // Diffuse lighting
    float diff = max(dot(normal, lightDir), 0.0);
   
    // Specular highlight
    vec3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), 16.0); // Shininess factor
   
    return ambient + diff * vec3(1.0, 0.8, 0.6) + spec * vec3(1.0); // Final color
}

Soft Shadows

To make the fractal look more realistic, we’ll implement soft shadows. This will really enhance how this object looks.

// Soft Shadows (traces a secondary ray to detect occlusion)
float softShadow(vec3 ro, vec3 rd) {
    float res = 1.0;
    float t = 0.02; // Small starting step
    for (int i = 0; i < 24; i++) {
        float d = mandelbulbSDF(ro + rd * t);
        if (d < 0.001) return 0.0; // Fully in shadow
        res = min(res, 10.0 * d / t); // Soft transition
        t += d;
    }
    return res;
}

Pulling it all together

We can now pull all of these enhancements together with our main image function:

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // Rotating Camera
    float angle = iTime * 0.5;
    vec3 rayOrigin = vec3(3.0 * cos(angle), 0.0, 3.0 * sin(angle));
    vec3 target = vec3(0.0);
    vec3 forward = normalize(target - rayOrigin);
    vec3 right = normalize(cross(vec3(0, 1, 0), forward));
    vec3 up = cross(forward, right);
    vec3 rayDir = normalize(forward + uv.x * right + uv.y * up);

    // Ray marching
    float totalDistance = 0.0;
    const int maxSteps = 100;
    const float minDist = 0.001;
    const float maxDist = 10.0;
    vec3 hitPoint;

    for (int i = 0; i < maxSteps; i++) {
        hitPoint = rayOrigin + rayDir * totalDistance;
        float dist = mandelbulbSDF(hitPoint);

        if (dist < minDist) break;
        if (totalDistance > maxDist) break;

        totalDistance += dist;
    }

    // Compute lighting only if we hit the fractal
    vec3 color;
    if (totalDistance < maxDist) {
        vec3 viewDir = normalize(rayOrigin - hitPoint);
        vec3 baseLight = phongLighting(hitPoint, viewDir);
        float shadow = softShadow(hitPoint, normalize(vec3(2.0, 2.0, -2.0)));
        color = baseLight * shadow; // Apply shadows
    } else {
        color = vec3(0.1, 0.1, 0.2); // Background color
    }

    fragColor = vec4(color, 1.0);
}

Finally, you should see something similar to this:

Pretty cool!