🌀 Creating a Mandelbulb in Godot

A Complete Tutorial on 3D Fractal Rendering

1Understanding the Mandelbulb

The Mandelbulb is a three-dimensional fractal, analogous to the famous 2D Mandelbrot set. It was discovered relatively recently (2009) and creates stunning, organic-looking 3D structures.

The Mathematics

Basic iteration formula:

z → z^n + c

Where z and c are 3D points, and n is the power (typically 8)

In 3D, we convert to spherical coordinates, apply the power, then convert back to Cartesian coordinates.

2Setting Up the Scene

Create the Basic Structure

  1. Download the entire project here: mandelbulb.zip
  2. Open Godot and create a new 3D project
  3. Create a new 3D Scene with a Node3D as root
  4. Attach a new script to the root node

Initial Script Setup

extends Node3D

func _ready():
    # We'll add our code here
    pass

3Creating the Camera

First, we need a camera to view our fractal. Add this to your _ready() function:

func _ready():
    # Create and position the camera
    var camera = Camera3D.new()
    camera.position = Vector3(0, 0, 3)
    add_child(camera)

This creates a camera positioned 3 units back from the origin, looking at the center where our Mandelbulb will be.

4Creating the Display Surface

We need a surface to apply our shader to. We'll use a simple quad (rectangle) that fills the screen:

    # Create a mesh to display the shader
    var mesh_instance = MeshInstance3D.new()
    var quad_mesh = QuadMesh.new()
    quad_mesh.size = Vector2(2, 2)
    mesh_instance.mesh = quad_mesh
    add_child(mesh_instance)
Note: The quad acts as a "canvas" for our shader. The actual 3D rendering happens in the shader code through ray marching.

5Understanding Ray Marching

Ray marching is the technique we'll use to render the Mandelbulb. Here's how it works:

  1. Cast a ray from the camera through each pixel
  2. March along the ray in small steps
  3. At each step, check the distance to the nearest surface
  4. When close enough, we've hit the surface
  5. Calculate lighting and return the color
Why ray marching? Traditional polygon rendering doesn't work well for complex fractals. Ray marching allows us to render mathematically-defined surfaces directly.

6Creating the Shader Material

Now we create the shader that will do all the heavy lifting:

    # Create shader material
    var shader_material = ShaderMaterial.new()
    var shader = Shader.new()
    shader.code = """
shader_type spatial;
render_mode unshaded;

// We'll add shader code here
"""
    shader_material.shader = shader
    mesh_instance.material_override = shader_material

We use render_mode unshaded because we'll calculate our own lighting.

7Shader Uniforms (Parameters)

Add these uniform variables at the top of your shader code. These allow you to tweak the fractal in real-time:

uniform float power : hint_range(1.0, 16.0) = 8.0;
uniform int max_iterations : hint_range(1, 30) = 15;
uniform float bailout : hint_range(1.0, 100.0) = 2.0;
uniform vec3 color1 : source_color = vec3(0.1, 0.2, 0.5);
uniform vec3 color2 : source_color = vec3(1.0, 0.5, 0.2);
uniform float rotation_speed = 0.1;

8The Distance Estimator Function

This is the heart of the Mandelbulb. It calculates how far a point is from the fractal surface:

float mandelbulb_de(vec3 pos) {
    vec3 z = pos;
    float dr = 1.0;  // Derivative for distance estimation
    float r = 0.0;
    
    for (int i = 0; i < max_iterations; i++) {
        r = length(z);
        if (r > bailout) break;
        
        // Convert to spherical coordinates
        float theta = acos(z.z / r);
        float phi = atan(z.y, z.x);
        dr = pow(r, power - 1.0) * power * dr + 1.0;
        
        // Apply power in spherical coordinates
        float zr = pow(r, power);
        theta = theta * power;
        phi = phi * power;
        
        // Convert back to Cartesian
        z = zr * vec3(
            sin(theta) * cos(phi),
            sin(phi) * sin(theta),
            cos(theta)
        );
        z += pos;
    }
    
    return 0.5 * log(r) * r / dr;
}

How It Works:

  1. Start with the input position
  2. Convert to spherical coordinates (r, theta, phi)
  3. Raise to the power (this creates the fractal pattern)
  4. Convert back to Cartesian coordinates
  5. Add the original position (like adding 'c' in z^n + c)
  6. Track the derivative for accurate distance estimation
  7. Return the estimated distance to the surface

9The Ray Marching Function

This function marches a ray through space until it hits the fractal:

vec4 ray_march(vec3 ro, vec3 rd) {
    float t = 0.0;  // Distance traveled
    float max_dist = 20.0;
    int steps = 0;
    int max_steps = 100;
    
    for (int i = 0; i < max_steps; i++) {
        vec3 p = ro + rd * t;  // Current position
        float d = mandelbulb_de(p);  // Distance to surface
        
        if (d < 0.001) {
            // Hit! Calculate color
            float shade = float(steps) / float(max_steps);
            vec3 col = mix(color1, color2, shade);
            
            // Calculate normal for lighting
            vec3 normal = normalize(vec3(
                mandelbulb_de(p + vec3(0.001, 0, 0)) - 
                mandelbulb_de(p - vec3(0.001, 0, 0)),
                mandelbulb_de(p + vec3(0, 0.001, 0)) - 
                mandelbulb_de(p - vec3(0, 0.001, 0)),
                mandelbulb_de(p + vec3(0, 0, 0.001)) - 
                mandelbulb_de(p - vec3(0, 0, 0.001))
            ));
            
            // Simple diffuse lighting
            vec3 light_dir = normalize(vec3(1, 1, 1));
            float diff = max(dot(normal, light_dir), 0.0) * 0.5 + 0.5;
            col *= diff;
            
            return vec4(col, 1.0);
        }
        
        if (t > max_dist) break;
        
        t += d * 0.5;  // March forward
        steps++;
    }
    
    // Missed - return background color
    return vec4(0.05, 0.05, 0.1, 1.0);
}

Key Concepts:

10The Fragment Shader

Finally, the fragment shader that runs for each pixel:

void fragment() {
    // Convert screen coordinates to UV space (-1 to 1)
    vec2 uv = (SCREEN_UV - 0.5) * 2.0;
    uv.x *= VIEWPORT_SIZE.x / VIEWPORT_SIZE.y;  // Fix aspect ratio
    
    // Rotating camera
    float time = TIME * rotation_speed;
    vec3 ro = vec3(
        cos(time) * 2.5,
        sin(time * 0.5) * 0.5,
        sin(time) * 2.5
    );
    
    // Camera direction vectors
    vec3 target = vec3(0, 0, 0);
    vec3 forward = normalize(target - ro);
    vec3 right = normalize(cross(vec3(0, 1, 0), forward));
    vec3 up = cross(forward, right);
    
    // Ray direction
    vec3 rd = normalize(forward + uv.x * right + uv.y * up);
    
    // Ray march and set pixel color
    ALBEDO = ray_march(ro, rd).rgb;
}

What's Happening:

  1. Convert pixel position to normalized UV coordinates
  2. Correct for screen aspect ratio
  3. Calculate orbiting camera position using sine and cosine
  4. Build a camera coordinate system (forward, right, up)
  5. Calculate the ray direction for this pixel
  6. Ray march and return the color

11Adding Input Handling

Add this function to allow quitting with the Escape key:

func _input(event):
    if event is InputEventKey and event.pressed:
        if event.keycode == KEY_ESCAPE:
            get_tree().quit()

12Running and Tweaking

To Run:

  1. Save your scene
  2. Press F5 to run the project
  3. You should see a rotating Mandelbulb!

To Adjust Parameters:

  1. While the scene is running, click on the MeshInstance3D in the scene tree
  2. Look for the shader parameters in the inspector
  3. Try different values:
Recommended experiments:
  • power = 2.0 - Creates a sphere
  • power = 4.0 - Simpler, rounded structure
  • power = 9.0 - More spiky details
  • max_iterations = 20 - More detail (slower)
  • Change color1 and color2 for different looks

13Performance Tips

14Going Further

Enhancements You Can Try:

Alternative Fractals:

Once you understand this code, try implementing other 3D fractals: