Introduction
Raymarching is a powerful rendering technique widely used in real-time graphics environments like Shadertoy. Unlike traditional rasterization, it does not require predefined 3D meshes. Instead, every object in the scene is defined mathematically and generated on the fly. This article breaks down the mechanics of the raymarching algorithm and demonstrates how to implement a basic renderer using fragment shaders.
Signed Distance Functions (SDF)
Before implementing the marching loop, we must understand distance calculation. The standard Euclidean distance between two points is derived from the Pythagorean theorem:
To find the distance from a point to a circle, calculate the Euclidean distance to the center and subtract the radius. If the point is inside the circle, the result is negative—hence the term "Signed Distance Function."
An SDF takes a point in space as input and returns the shortest distance to the surface of the object. While a circle's SDF is simple, shapes like rectangles require more complex derivations:
When combining multiple shapes, the scene's distance is determined by the minimum value of all individual SDFs at a specific point.
The Raymarching Loop
The core logic of raymarching follows these steps:
- Initialize the current position at the camera origin.
- Query the scene's SDF to find the shortest distance from the current position to any object.
- Advance the current position along the ray direction by that distance.
- Repeat steps 2 and 3 until the distance is negligible (hit) or a maximum iteration count is reached (miss).
This process is executed for every pixel on the screen. If the ray intersects a object, the pixel takes the object's color; otherwise, it displays the background.
Implementation in 3D
Transitioning from 2D to 3D involves fragment shaders. We map screen coordinates to a normalized UV space and emit rays from the camera through the view frustum.
The SDF for a 3D sphere is analogous to the 2D circle. Below is a GLSL implementation rendering the depth of a sphere:
#version 300 es
precision highp float;
in vec3 position;
uniform float aspect_ratio;
out vec4 frag_color;
const int MAX_STEPS = 100;
const float SURFACE_DIST = 0.001;
const float MAX_DIST = 1000.0;
float sdSphere(vec3 p, vec3 center, float r) {
return length(p - center) - r;
}
float rayMarch(vec3 ro, vec3 rd) {
float depth = 0.0;
for (int i = 0; i < MAX_STEPS; i++) {
vec3 currentPos = ro + rd * depth;
float distToSurf = sdSphere(currentPos, vec3(0.0, 0.0, 2.0), 0.5);
depth += distToSurf;
if (distToSurf < SURFACE_DIST || depth > MAX_DIST) break;
}
return depth;
}
void main() {
vec2 uv = vec2(position.x, position.y * aspect_ratio);
vec3 ro = vec3(0.0);
vec3 rd = normalize(vec3(uv, 1.0));
float depth = rayMarch(ro, rd);
depth /= 7.0;
frag_color = vec4(vec3(depth), 1.0);
}
We can expand the scene by adding boxes and planes. By returning an object ID alongside the distance, we can assign specific colors to different geometry. If the ray travels too far, we render a sky color:
vec2 sceneMarch(vec3 ro, vec3 rd) {
float depth = 0.0;
float objId = 0.0;
for (int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + rd * depth;
vec2 sceneInfo = mapScene(p); // Returns vec2(dist, id)
objId = sceneInfo.y;
float dist = sceneInfo.x;
depth += dist;
if (dist < SURFACE_DIST) break;
if (depth > MAX_DIST) {
objId = 0.0;
break;
}
}
return vec2(depth, objId);
}
vec3 getMaterial(int id) {
if (id == 1) return vec3(0.7); // Grey
if (id == 2) return vec3(1.0, 0.0, 0.0); // Red
if (id == 3) return vec3(0.0, 0.0, 1.0); // Blue
return vec3(0.48, 0.856, 1.0); // Sky Blue
}
To add realism, we calculate surface normals using the gradient of the SDF and apply diffuse lighting:
vec3 calcNormal(vec3 p) {
float d = mapScene(p).x;
vec2 e = vec2(0.001, 0.0);
return normalize(vec3(
mapScene(p + e.xyy).x - d,
mapScene(p + e.yxy).x - d,
mapScene(p + e.yyx).x - d
));
}
void main() {
// ... setup ray ...
vec2 result = sceneMarch(ro, rd);
float depth = result.x;
int id = int(result.y);
vec3 color = getMaterial(id);
if (id > 0) {
vec3 hitPos = ro + rd * depth;
vec3 norm = calcNormal(hitPos);
vec3 lightDir = normalize(vec3(0.5, 2.0, -1.0));
float diff = max(dot(norm, lightDir), 0.0);
// Simple shadow check
if (sceneMarch(hitPos + norm * 0.01, lightDir).x < length(lightDir - hitPos)) {
diff = 0.0;
}
color *= (vec3(0.4) + diff);
}
frag_color = vec4(color, 1.0);
}