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 fieldsA function that returns how far a point is from the edge of a shape, negative inside and positive outside. Zero is the outline itself. Signed distance field (SDF) A function that returns how far a point is from the edge of a shape, negative inside and positive outside. Zero is the outline itself.circle(p) = |p| − rvec3,
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 productMultiplies two vectors component by component and adds the results. For unit vectors it is the cosine of the angle between them, from 1 (same direction) to -1 (opposite). Dot product Multiplies two vectors component by component and adds the results. For unit vectors it is the cosine of the angle between them, from 1 (same direction) to -1 (opposite).dot(a, b) = |a| |b| cos θ
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 normalA unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions. Normal A unit vector perpendicular to a surface, pointing the way it faces. Lighting and Fresnel effects compare it with other directions.
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 swizzlesReading or writing a vector's components by name, in any order, as in v.xy, color.bgr or p.yx. Repeats are allowed when reading. Swizzle Reading or writing a vector's components by name, in any order, as in v.xy, color.bgr or p.yx. Repeats are allowed when reading.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.