Creative Coding

Raymarching Tutorial: Getting Started With Signed Distance Fields in GLSL

Build a 3D scene in a fragment shader with no geometry, no vertices and no mesh — just a function that answers "how far am I from the nearest surface?"

Once you have written a fragment shader that paints a plane, the obvious question is how to get a solid, lit, three-dimensional object out of the same twenty lines — without loading a model, building a vertex buffer or touching geometry at all.

The answer is raymarching over a signed distance field, and the idea underneath it is small enough to state in one sentence: if you can write a function that tells you how far any point in space is from the nearest surface, you can render that surface.

The one concept

A signed distance function (SDF) takes a point and returns the distance to the surface of a shape. The signed part: negative inside, zero exactly on the surface, positive outside.

A sphere is the easiest thing in the world:

float sdSphere(vec3 p, float r) {
    return length(p) - r;
}

Distance from the origin, minus the radius. Stand two units from the centre of a one-unit sphere and you are one unit from its skin. That is the entire definition of a sphere — no triangles, no normals, no UVs.

Raymarching then renders it. From the camera, shoot a ray through each pixel. Ask the SDF how far the nearest surface is. Step forward by exactly that much — which is guaranteed safe, because nothing can be closer. Ask again. Repeat until the distance is almost zero (you have hit something) or you have gone too far (you have not).

Stepping by the distance to the nearest surface is why this is “marching” rather than a fixed-step walk, and it is why it is fast: in empty space the steps are huge, and they only get small near a surface, which is where you need the precision.

A complete shader

This is a GLSL ES fragment shader. It runs as-is in p5.js via createShader(), in glslViewer, or in any WebGL setup that hands you u_resolution and u_time. (On Shadertoy the structure is identical but the entry point is mainImage(out vec4 fragColor, in vec2 fragCoord) and you get iResolution/iTime instead.)

1. Setup and the shapes

#ifdef GL_ES
precision highp float;
#endif

uniform vec2  u_resolution;
uniform float u_time;

const int   MAX_STEPS = 100;
const float MAX_DIST  = 50.0;
const float SURF_DIST = 0.001;

float sdSphere(vec3 p, float r) {
    return length(p) - r;
}

float sdBox(vec3 p, vec3 b) {
    vec3 q = abs(p) - b;
    return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

sdBox is worth reading twice because it is the pattern most SDFs follow. abs(p) - b folds the point into one octant and measures past the box’s half-extents b. Outside, length(max(q, 0.0)) is the real distance. Inside, all components of q are negative, the first term is zero, and the second term returns the (negative) distance to the nearest face. One expression, correct on both sides of the surface.

2. The scene

float map(vec3 p) {
    float sphere = sdSphere(p - vec3(1.2 * sin(u_time), 0.0, 0.0), 1.0);
    float box    = sdBox(p - vec3(-1.3, 0.0, 0.0), vec3(0.7));
    return min(sphere, box);
}

Two things here are the whole reason people like SDFs.

Moving a shape means subtracting from the point, not adding to the shape. p - vec3(1.2, 0.0, 0.0) evaluates the sphere as if the point were shifted the other way, which puts the sphere at x = 1.2. It reads backwards until it doesn’t.

Combining shapes is min. The distance to “either of these two objects” is the distance to whichever is nearer. That is a union. And the rest of constructive solid geometry is just as cheap:

// union
min(a, b)
// intersection
max(a, b)
// subtract b from a
max(a, -b)

Carving a hole in a box is one max and one minus sign. Try doing that to a mesh.

3. Marching

float raymarch(vec3 ro, vec3 rd) {
    float t = 0.0;
    for (int i = 0; i < MAX_STEPS; i++) {
        vec3  p = ro + rd * t;
        float d = map(p);
        if (d < SURF_DIST) return t;   // hit a surface
        t += d;
        if (t > MAX_DIST) break;       // escaped the scene
    }
    return -1.0;                       // missed
}

ro is the ray origin (the camera), rd the normalised ray direction. Returning -1.0 for a miss rather than a large t keeps the caller unambiguous — there is no distance that means “nothing here”.

4. Normals, from the field itself

Lighting needs a surface normal, and there is no mesh to read one from. But the gradient of a distance field points directly away from the surface, so you can sample it:

vec3 calcNormal(vec3 p) {
    vec2 e = vec2(0.0005, 0.0);
    return normalize(vec3(
        map(p + e.xyy) - map(p - e.xyy),
        map(p + e.yxy) - map(p - e.yxy),
        map(p + e.yyx) - map(p - e.yyx)
    ));
}

Six extra evaluations of map per pixel, central differences on each axis. e.xyy is vec3(0.0005, 0.0, 0.0) — the swizzle is a compact idiom you will see everywhere in shader code.

5. Putting it together

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;

    vec3 ro = vec3(0.0, 0.0, -4.0);
    vec3 rd = normalize(vec3(uv, 1.0));

    vec3  col = vec3(0.04);
    float t   = raymarch(ro, rd);

    if (t > 0.0) {
        vec3 p = ro + rd * t;
        vec3 n = calcNormal(p);
        vec3 l = normalize(vec3(0.8, 0.9, -0.6));

        float diff = max(dot(n, l), 0.0);
        col = vec3(0.25, 0.55, 0.95) * (0.15 + 0.85 * diff);
    }

    gl_FragColor = vec4(col, 1.0);
}

Dividing by u_resolution.y on both axes — rather than by u_resolution — is what keeps the result from stretching on a non-square canvas. The 0.15 + is ambient light, so the unlit side is dark rather than black.

That is a complete lit 3D scene in about sixty lines, with no geometry anywhere in it.

The smooth blend, which is the party trick

Replace min with a smooth minimum and the two shapes merge like clay:

float smin(float a, float b, float k) {
    float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
    return mix(b, a, h) - k * h * (1.0 - h);
}

Then in map, return smin(sphere, box, 0.6);. The k parameter is the blend radius in world units; 0.0 degenerates to a hard min. This is the effect that made SDFs popular, and it is three lines. There is no mesh operation that does it.

The three constants, and what breaks

Most raymarching problems are one of these, so tune them deliberately:

SURF_DIST too large — surfaces look inflated and edges go soft and wrong. Too small, and you waste steps creeping toward the surface and get speckled holes where the loop runs out. 0.001 is a reasonable default at a scene scale of a few units.

MAX_STEPS too low — objects dissolve at grazing angles and far from the camera, because that is where rays travel nearly parallel to a surface and need many tiny steps. If your shapes have ragged, feathered silhouettes, this is why.

MAX_DIST too high — you pay for rays that will never hit anything. Too low and distant objects get clipped away.

One more failure mode that is not a constant: the field must not lie. Everything above depends on map returning a distance that is never greater than the true distance to the surface. Scaling a shape by multiplying the result, or combining fields in ways that overestimate, makes rays step straight through surfaces — you get holes and shapes that vanish from certain angles. If something is mysteriously see-through, suspect the arithmetic in map before anything else.

Where to go next

Add a ground plane (p.y + 1.0 is a perfectly good SDF). Add shadows by marching a second ray from the hit point toward the light. Add repetition with mod to tile an infinite grid of objects for free — one of the genuinely magical properties of this approach, since infinite copies cost nothing extra. Inigo Quilez’s distance-function reference is the standard list of primitives and operators, and it is where almost every shader you admire got its sdBox.

Sources