A Complete Tutorial on 3D Fractal Rendering
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.
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.
Node3D as rootextends Node3D
func _ready():
# We'll add our code here
pass
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.
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)
Ray marching is the technique we'll use to render the Mandelbulb. Here's how it works:
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.
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;
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;
}
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);
}
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;
}
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()
power = 2.0 - Creates a spherepower = 4.0 - Simpler, rounded structurepower = 9.0 - More spiky detailsmax_iterations = 20 - More detail (slower)color1 and color2 for different looksmax_iterations for better performancemax_steps in the ray marching loopOnce you understand this code, try implementing other 3D fractals: