Skip to content
Fundamental · Distance fieldsIntermediate

3D signed distance fields

Sphere, box, torus and capsule as distance functions of a 3D point, and the surface normal from the field's gradient.

How it works

Everything from 2D distance fields carries over to 3D: a function takes a point, now a vec3, and returns the distance to the nearest surface, negative inside. The booleans, smooth minimum, repetition and rounding work unchanged. What 3D adds is a way to see the field, which is raymarching (stepping along a ray from the camera by the distance the field reports), and the need for a surface normal to light it. This fundamental covers the shapes and the normal; raymarching itself is a rendering concept later in the order.

The basic shapes

Most of them extend their 2D versions directly:

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

// b: half-size on each axis
float sdBox(vec3 p, vec3 b) {
    vec3 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}

// a segment from a to b, thickened by r
float sdCapsule(vec3 p, vec3 a, vec3 b, float r) {
    vec3 pa = p - a;
    vec3 ba = b - a;
    float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
    return length(pa - ba * h) - r;
}

// n: unit normal; the plane sits at distance h along it
float sdPlane(vec3 p, vec3 n, float h) {
    return dot(p, n) - h;
}

The sphere, box and capsule are the circle, box and segment with one more component. The plane is a dot product: the signed distance from a point to a plane through the origin is the length of its projection onto the unit normal.

Building shapes from 2D ones

Some 3D shapes are a 2D distance applied in a plane. A torus is a circle of radius big in the xz plane, swept with a tube of radius small. In the half-plane that contains the y axis and the point, the point is at (length(p.xz) - big, p.y) from the tube’s center line, and the torus is a 2D circle there:

float sdTorus(vec3 p, float big, float small) {
    vec2 q = vec2(length(p.xz) - big, p.y);
    return length(q) - small;
}

The same move, vec2(length(p.xz), p.y) passed to any 2D distance function, revolves a 2D shape around the y axis: a 2D box gives a cylinder, a 2D rounded box a rounded cylinder. Extrusion goes the other way: evaluate a 2D distance on p.xy, then intersect with a slab of half-thickness t along z, which turns a flat shape into a coin or a letter with depth.

The normal from the gradient

To light a surface you need its normal. A distance field gives it for free: the field grows fastest straight away from the surface, so its gradient points along the normal. With an exact distance the gradient’s length is 1; for any field, normalizing it gives the direction.

The gradient can be computed by evaluating the field at nearby points, one pair per axis:

float scene(vec3 p) {
    return min(sdSphere(p - vec3(-0.6, 0.0, 0.0), 0.5),
               sdTorus(p - vec3(0.6, 0.0, 0.0), 0.4, 0.12));
}

vec3 calcNormal(vec3 p) {
    const vec2 e = vec2(0.001, 0.0); // a small step, in scene units
    return normalize(vec3(
        scene(p + e.xyy) - scene(p - e.xyy),
        scene(p + e.yxy) - scene(p - e.yxy),
        scene(p + e.yyx) - scene(p - e.yyx)));
}

The swizzles e.xyy, e.yxy and e.yyx are small steps along x, y and z. Each line is a central difference: how much the distance changes across a tiny step on that axis. The step size is a trade-off: too large and small details blur, too small and floating-point error makes the normal noisy. 0.001 suits scenes about one unit across; scale it with the scene, or with the distance from the camera.

That costs six evaluations of the whole scene per pixel. A common optimization samples four points at the corners of a tetrahedron instead, which gives the same direction for two fewer evaluations; the normals article in the sources derives it.

Looking at a 3D field without raymarching

Before writing a raymarcher you can inspect a 3D field by slicing it: evaluate it on a plane and draw the 2D result. With a 2D pixel coordinate p:

float d = scene(vec3(p, sin(time) * 0.5)); // a moving slice through z
vec3 color = d < 0.0 ? vec3(0.9, 0.5, 0.2) : vec3(0.2, 0.5, 0.9);
color *= 0.8 + 0.2 * cos(60.0 * d);         // contour lines every ~0.1 units
// the surface itself
color = mix(color, vec3(1.0), 1.0 - smoothstep(0.0, 0.01, abs(d)));

Evenly spaced contour lines mean the field is a true distance. Where they spread apart, the field underestimates the distance, and a raymarcher will need more steps there; where they bunch up, it overestimates, and a raymarcher will step through the surface.

Further reading