Computer Graphics

Ray Marching & Signed Distance Fields

Render a whole world from one math function — no triangles required

Ray marching renders implicit surfaces by stepping along each ray a distance read from a signed distance field, so the marcher never overshoots the surface and converges in a handful of evaluations instead of triangulating geometry.

  • Steps per hit (typical)10–30
  • Grazing-ray worst case100+
  • Union / Intersect / Subtractmin / max / max(a,−b)
  • Geometry storeda distance function
  • Normalsgradient of the field

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

How ray marching finds a surface

Classical ray tracing solves an equation: given a ray and a triangle, it computes exactly where they cross. That works because a triangle has a closed-form intersection test. But many of the most interesting shapes — smoothly blended blobs, fractals, infinite repetitions, fog with soft edges — have no triangle representation at all. They are defined implicitly, as the zero level set of a function. Ray marching is how you render those.

The key object is a signed distance field (SDF): a function f(p) that, for any point p in space, returns the distance from p to the nearest surface. The value is negative inside the object, positive outside, and exactly zero on the surface. For a sphere of radius r centered at the origin, the whole shape is one line: length(p) − r.

To render a pixel, shoot a ray from the camera through it. You don't know where the surface is, so you march:

  1. Start at the ray origin. Evaluate the SDF to get d = f(p).
  2. d is the radius of an empty ball around p — nothing is closer than d. So you can safely jump forward by exactly d without passing through any surface.
  3. Step: p += d · rayDir. Re-evaluate.
  4. When d drops below a small epsilon, you've hit the surface. When total distance exceeds a far plane, the ray missed.

This is why ray marching is also called sphere tracing (John Hart, 1996): each step is the largest sphere you can take without overshooting. The genius is that the same step size that is safe is also as large as possible — the marcher takes giant strides through empty space and only slows down as it nears geometry.

Normals come free from the gradient

Once a ray hits the surface you need a normal to shade it. With an SDF you never stored any normals — but you can recover them, because the gradient of a distance field points away from the surface. Estimate it with central differences on a tiny offset h (often 0.0005–0.001):

vec3 normal(vec3 p) {
  vec2 e = vec2(1.0, -1.0) * 0.0005;
  return normalize(
    e.xyy * f(p + e.xyy) +
    e.yyx * f(p + e.yyx) +
    e.yxy * f(p + e.yxy) +
    e.xxx * f(p + e.xxx)
  );
}

That is the "tetrahedron" technique — four SDF evaluations instead of the naive six, a common micro-optimization since normals are computed for every visible pixel. Soft shadows, ambient occlusion, and sub-surface glow all fall out of the same field with a few extra marches, which is why a 30-line shader can produce images that would take thousands of triangles and a separate lighting pass.

When ray marching is the right tool

  • Procedural and analytic shapes. Spheres, boxes, tori, capsules, planes — anything with a closed-form distance. Their unions and blends stay cheap.
  • Smooth organic blends. A smin between fields gives metaball-like joins for free; no mesh booleans, no remeshing.
  • Infinite or repeated worlds. Tiling space with mod() renders an endless field of objects from a single primitive at zero extra storage.
  • Volumetrics and fractals. Clouds, the Mandelbulb, and other sets that have no surface mesh at all.
  • Demoscene and shader art. A whole scene fits in one fragment shader and a few kilobytes.

It is the wrong tool for dense static meshes (a game character, a scanned statue), where rasterization or BVH-accelerated ray tracing is far faster, and for scenes that are mostly empty grazing angles, where the marcher's step count explodes.

Ray marching vs other rendering approaches

Ray marching (SDF)RasterizationBVH ray tracingVoxel ray casting
Geometry storedA distance functionTriangle mesh + buffersTriangles + BVH3D grid of occupancy
Intersection cost10–100+ SDF evals/rayO(1) per fragmentO(log n) BVH traversalDDA grid walk
Smooth blends / CSGTrivial (min/max/smin)Needs remeshingHardHard
Arbitrary meshesPoor (needs baked SDF)ExcellentExcellentMemory-heavy
Infinite repetitionFree (mod())Instancing onlyInstancing onlyNo
MemoryTiny (just code)Vertex/index buffersMesh + accel structureCubic in resolution
Typical useShader art, fractals, fogReal-time gamesFilm, RTX reflectionsMedical, scientific volumes

The dividing line is whether your geometry has a cheap distance function. When it does, ray marching wins on flexibility and code size; when it doesn't, the per-ray evaluation cost makes it the slowest option on the table.

What the numbers actually say

  • 10–30 steps for a clean hit. A ray approaching a surface roughly perpendicular halves its remaining distance each step in the worst geometric case, but for convex primitives it usually closes in under 30 SDF evaluations before crossing the epsilon threshold.
  • 100+ steps for grazing rays. A ray skimming nearly tangent to a surface sees the empty-sphere radius collapse toward zero while making little forward progress. This is the dominant cost in ray-marched scenes and is why iteration caps (commonly 128 or 256) exist as a hard safety valve.
  • Each step is a full SDF evaluation. A scene with 20 primitives blended together evaluates all 20 distance functions per step. A 1080p frame is about 2 million rays; at 40 steps and 20 primitives each, that's roughly 1.6 billion distance evaluations per frame — which is why SDF scenes are GPU-fragment-shader workloads, not CPU ones.
  • 4 evaluations per normal, plus shadows and AO. Shading a pixel typically costs 4 extra marches for the gradient normal, another short march for a hard shadow, and 5–8 short samples for ambient occlusion — often doubling the per-pixel SDF call count beyond the primary march.
  • Baking a mesh SDF is cubic. Storing an arbitrary mesh as a voxel distance grid at resolution N costs N³ floats: a 256³ grid is 64 MB at 32-bit, and trilinear sampling it loses the exactness that analytic SDFs enjoy.

JavaScript implementation

A CPU sphere-tracer that resolves one ray against a scene of two blended spheres minus a box. It returns the hit distance and the surface normal — the same loop a GPU runs in parallel across every pixel.

const sub  = (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
const add  = (a, b) => [a[0]+b[0], a[1]+b[1], a[2]+b[2]];
const mul  = (a, s) => [a[0]*s, a[1]*s, a[2]*s];
const len  = a => Math.hypot(a[0], a[1], a[2]);
const norm = a => mul(a, 1 / (len(a) || 1));

// --- primitive SDFs ---
const sdSphere = (p, c, r) => len(sub(p, c)) - r;
const sdBox = (p, c, b) => {
  const q = sub(p, c).map((v, i) => Math.abs(v) - b[i]);
  const out = Math.hypot(Math.max(q[0],0), Math.max(q[1],0), Math.max(q[2],0));
  return out + Math.min(Math.max(q[0], Math.max(q[1], q[2])), 0);
};

// smooth minimum (Inigo Quilez polynomial smin)
function smin(a, b, k) {
  const h = Math.max(k - Math.abs(a - b), 0) / k;
  return Math.min(a, b) - h * h * k * 0.25;
}

// --- the scene as one distance function ---
function scene(p) {
  const s1 = sdSphere(p, [-0.8, 0, 0], 1.0);
  const s2 = sdSphere(p, [ 0.8, 0, 0], 1.0);
  const blob = smin(s1, s2, 0.6);          // union with a soft seam
  const cut  = sdBox(p, [0, 0, 0], [0.6, 0.6, 2.0]);
  return Math.max(blob, -cut);             // subtract the box from the blob
}

function gradient(p, h = 1e-3) {
  const dx = scene([p[0]+h, p[1], p[2]]) - scene([p[0]-h, p[1], p[2]]);
  const dy = scene([p[0], p[1]+h, p[2]]) - scene([p[0], p[1]-h, p[2]]);
  const dz = scene([p[0], p[1], p[2]+h]) - scene([p[0], p[1], p[2]-h]);
  return norm([dx, dy, dz]);
}

function rayMarch(origin, dir, { eps = 1e-3, far = 50, maxSteps = 128 } = {}) {
  let t = 0;
  for (let i = 0; i < maxSteps; i++) {
    const p = add(origin, mul(dir, t));
    const d = scene(p);
    if (d < eps) return { hit: true, t, steps: i, normal: gradient(p) };
    t += d;                                 // safe jump = signed distance
    if (t > far) break;
  }
  return { hit: false, t, steps: maxSteps };
}

const hit = rayMarch([-0.8, 0, -5], norm([0, 0, 1]));
console.log(hit.hit ? `hit at t=${hit.t.toFixed(3)} in ${hit.steps} steps`
                    : 'miss');

Two details matter for correctness. First, the step is exactly the returned distance, never larger — a step beyond d risks tunneling through a thin surface and producing holes. Second, the box SDF combines an exterior term (distance to the nearest face) with an interior term (how far inside you are), so the field is correct on both sides; truncating it to just the exterior term breaks the marcher inside the shape.

Python implementation

The same algorithm with NumPy, written to march all rays at once — the vectorized form that mirrors how a GPU dispatches one thread per pixel. This renders a small depth/step-count buffer for a grid of rays.

import numpy as np

def sd_sphere(p, c, r):
    return np.linalg.norm(p - c, axis=-1) - r

def sd_box(p, c, b):
    q = np.abs(p - c) - b
    out = np.linalg.norm(np.maximum(q, 0.0), axis=-1)
    ins = np.minimum(np.max(q, axis=-1), 0.0)
    return out + ins

def smin(a, b, k):
    h = np.clip(k - np.abs(a - b), 0.0, None) / k
    return np.minimum(a, b) - h * h * k * 0.25

def scene(p):
    s1 = sd_sphere(p, np.array([-0.8, 0, 0]), 1.0)
    s2 = sd_sphere(p, np.array([ 0.8, 0, 0]), 1.0)
    blob = smin(s1, s2, 0.6)
    cut  = sd_box(p, np.array([0, 0, 0]), np.array([0.6, 0.6, 2.0]))
    return np.maximum(blob, -cut)          # blob minus box

def ray_march(origin, dirs, eps=1e-3, far=50.0, max_steps=128):
    n = dirs.shape[0]
    t = np.zeros(n)
    alive = np.ones(n, dtype=bool)          # rays still marching
    steps = np.zeros(n, dtype=int)
    for _ in range(max_steps):
        p = origin + dirs * t[:, None]
        d = scene(p)
        hit  = alive & (d < eps)
        gone = alive & (t > far)
        alive &= ~(hit | gone)
        t[alive] += d[alive]                # only advance live rays
        steps[alive] += 1
        if not alive.any():
            break
    return t, steps

# 128x128 orthographic-ish ray bundle aimed down +z
g = np.linspace(-2, 2, 128)
xx, yy = np.meshgrid(g, g)
origins = np.stack([xx.ravel(), yy.ravel(), np.full(xx.size, -5.0)], axis=-1)
dirs = np.tile([0.0, 0.0, 1.0], (origins.shape[0], 1))
t, steps = ray_march(origins, dirs)
print("rays that hit:", int((t < 50).sum()), "/", t.size)

The alive mask is the vectorized equivalent of an early break: a ray that has hit or escaped stops advancing while its neighbors keep marching. On a GPU this is implicit — divergent threads simply idle — but in NumPy you must mask explicitly or you waste work re-marching finished rays and, worse, march escaped rays back into geometry.

Variants worth knowing

Enhanced sphere tracing. Vanilla sphere tracing stalls on grazing rays. Over-relaxation multiplies each step by a factor >1 and backtracks if it overshoots, and the 2014 "enhanced sphere tracing" of Keinert et al. cuts step counts by up to 40% on tangent-heavy scenes.

Cone marching. Treat the ray as a widening cone (one per pixel footprint). It enables cheap level-of-detail and anti-aliasing, and underpins fast voxel-cone-tracing global illumination.

Discrete / DDA marching. When the field is stored in a uniform voxel grid rather than analytically, you march cell-by-cell with a digital differential analyzer instead of jumping by distance. This is how sparse voxel octrees and many volume renderers traverse space.

Volumetric ray marching. Drop the "stop at the surface" rule and accumulate density and color at fixed-size steps through a participating medium — clouds, smoke, fire, subsurface scattering. The march integrates light along the ray rather than finding a single hit.

Domain operations. Because the SDF is just a function of p, you can transform p before evaluating: mod(p, c) − 0.5·c tiles space infinitely, rotation matrices bend it, and twist/bend warps animate it — all without touching the primitive.

Common bugs and edge cases

  • Non-Lipschitz fields. Sphere tracing is only safe if the SDF never over-estimates distance — formally, its gradient magnitude must stay ≤ 1 (1-Lipschitz). Scaling a shape by multiplying p without dividing the result back out inflates distances and tunnels rays through surfaces.
  • Over-stepping with relaxation. Multiplying the step by a relaxation factor speeds grazing rays but can skip thin features; you must detect the overshoot (next distance < 0 or step exceeded the sphere) and roll back, or you get holes.
  • Epsilon too small or too large. Too small and rays never converge within the step cap, leaving black holes at silhouettes; too large and surfaces look inflated and detail melts. A distance-scaled epsilon (eps = t · pixelSize) adapts with depth.
  • Wrong CSG sign. Subtraction is max(a, −b), not max(−a, b); flipping it subtracts the wrong operand and carves the scene inside out.
  • Treating CSG results as exact distances. min/max are exact on the surface but only bound the distance in the interior, so deeply nested booleans can underestimate distance and slow the march; rounding and blending keep the field better-behaved.
  • Forgetting the iteration cap. Without a hard maxSteps, a grazing or near-tangent ray loops effectively forever, freezing the GPU. Always cap, and treat a capped ray as a miss (or sky).

Frequently asked questions

Why is ray marching called sphere tracing?

Because each step advances by the signed distance value, which is the radius of the largest empty sphere centered at the current point. You jump to the edge of that sphere — guaranteed to hit nothing — then re-query. The term sphere tracing comes from John Hart's 1996 paper; ray marching is the broader name for stepping along a ray.

What is the difference between a signed distance field and a regular distance field?

An unsigned distance field returns the absolute distance to the boundary, always non-negative, so it grows on both sides of the surface and cannot tell inside from outside. A signed distance field returns negative values inside the surface and positive values outside, with the magnitude equal to the distance to the boundary. The sign is what lets boolean operations like subtraction and intersection work with simple min/max.

How many steps does ray marching take to hit a surface?

For a ray that hits a surface head-on, often 10 to 30 steps. Rays that graze the surface tangentially can take 100+ steps because each step shrinks as the empty sphere collapses near the boundary — this is the classic grazing-ray slowdown. Most marchers cap iterations at 64 to 256 and treat unreached rays as misses.

Can ray marching render arbitrary triangle meshes?

Not efficiently. SDFs shine for analytic primitives — spheres, boxes, tori — and their smooth blends, where the distance has a closed form. For an arbitrary mesh you must bake the distance into a 3D texture (a voxel grid) and trilinearly sample it, which costs memory and loses exactness. Rasterization or BVH ray tracing beats ray marching on dense static meshes.

Why do ray-marched scenes use min for union and max for intersection?

The union of two shapes is the set of points inside either one, so the distance to the union is the smaller of the two distances: min(a, b). The intersection requires being inside both, so it is max(a, b). Subtraction of B from A is max(a, -b). These constructive solid geometry operations are exact for the surface but only approximate the true distance in the interior, which is usually fine.

What is a smooth minimum and why does it matter for SDFs?

A smooth minimum (smin) blends two distance fields with a rounded seam instead of a hard crease, producing organic metaball-like joins. The polynomial smin from Inigo Quilez costs one extra mix and a clamp. It is what makes ray-marched scenes look like melting blobs rather than sharp boolean cuts, and it keeps the result a valid distance field as long as the blend radius stays small.