Home Games Shader Sandbox

Game Dev Mechanics: Ray Marching & Signed Distance Fields — How It Works

Ray Marching & SDFs — Live GPU Demo
Drag to rotate  •  Scroll to zoom

Imagine being able to describe any 3D shape — a sphere, a twisted torus, a blob of melting wax — using nothing but a single mathematical function. No meshes, no vertices, no triangles. Signed Distance Fields (SDFs) provide exactly this, and when combined with the ray marching algorithm they become a powerful approach for rendering and collision detection in modern games.

From the psychedelic procedural worlds seen on Shadertoy to Unity's GPU-accelerated text rendering pipeline, and from the soft-shadow calculations in AAA titles to the physics simulation in Media Molecule's Dreams, SDFs appear everywhere once you know what to look for. In this article we'll explain both techniques: the math, the algorithm, and practical GLSL implementations you can use right now.

What Is a Signed Distance Field?

A Signed Distance Field is a scalar function $f(\mathbf{p})$ defined over 3D space. For any point $\mathbf{p}$, it returns the shortest distance from that point to the nearest surface of an object, with a sign convention:

  • Positive values: the point is outside the object
  • Negative values: the point is inside the object
  • Zero: the point is exactly on the surface

For the simplest example, a sphere of radius $r$ centered at the origin has the SDF:

$$f(\mathbf{p}) = |\mathbf{p}| - r$$

If you are 2 units from the center of a sphere with radius 1, the SDF returns $+1$. If you are at the center it returns $-1$. The surface is wherever the function returns 0. This simple idea (encoding geometry as a distance function rather than a triangle soup) captures what makes everything else possible.

Building an SDF Primitive Library

The real power emerges from a vocabulary of primitive shape functions. Here are the foundational ones in GLSL:

// Sphere: radius r centered at origin
float sdSphere(vec3 p, float r) {
    return length(p) - r;
}

// Axis-aligned Box: half-extents b
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);
}

// Torus: major radius t.x, minor radius t.y
float sdTorus(vec3 p, vec2 t) {
    vec2 q = vec2(length(p.xz) - t.x, p.y);
    return length(q) - t.y;
}

// Infinite ground plane at y = -h
float sdPlane(vec3 p, float h) {
    return p.y + h;
}

// Capsule: endpoints a and b, radius r
float sdCapsule(vec3 p, vec3 a, vec3 b, float r) {
    vec3 pa = p - a, ba = b - a;
    float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
    return length(pa - ba * h) - r;
}

The box SDF deserves a closer look. The expression abs(p) - b gives a vector whose components represent the signed overshoot past each face. max(q, 0.0) handles the outside case by projecting onto the nearest surface point. The min(..., 0.0) term returns the largest negative component when the point is inside, giving the correct (negative) interior distance. Together they produce an exact Euclidean distance field, not just an approximation, for any axis-aligned box.

The Ray Marching Algorithm

Sphere tracing (the specific variant of ray marching we use with SDFs, published by John C. Hart in 1994) is an iterative algorithm for finding the intersection of a ray with a surface described by an SDF. Unlike analytical ray-sphere intersection tests, it works for any SDF, no matter how complex or procedurally generated.

The core insight is beautifully simple: if the SDF at our current position tells us we are distance $d$ from the nearest surface, then we can safely advance along the ray by exactly $d$ units without hitting anything. This means we take large steps in open space and tiny, careful steps near geometry:

float rayMarch(vec3 ro, vec3 rd) {
    float t = 0.0; // total distance traveled along ray

    for (int i = 0; i < MAX_STEPS; i++) {
        vec3 p = ro + rd * t;   // current point on ray
        float d = sceneSDF(p);   // distance to nearest surface

        t += d;                  // safe to advance by d

        if (d < SURF_DIST) break; // hit: close enough to surface
        if (t > MAX_DIST)  break; // miss: ray escaped the scene
    }

    return t;
}

Typical constants are MAX_STEPS = 80–100, SURF_DIST = 0.001, and MAX_DIST = 50.0–100.0. The algorithm runs entirely inside a fragment shader, every pixel on the screen casts its own ray simultaneously, making this a natural fit for the massively parallel GPU architecture.

To set up the camera, we compute a ray origin $\mathbf{r_o}$ (the camera position) and a ray direction $\mathbf{r_d}$ for each pixel based on the screen UV coordinates and the camera's orientation matrix:

mat3 setCamera(vec3 ro, vec3 target) {
    vec3 cw = normalize(target - ro);               // forward
    vec3 cu = normalize(cross(cw, vec3(0,1,0)));    // right
    vec3 cv = cross(cu, cw);                         // up
    return mat3(cu, cv, cw);
}

// In main():
vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
mat3 cam = setCamera(ro, vec3(0.0));
vec3 rd  = cam * normalize(vec3(uv, 1.8)); // 1.8 = focal length

Computing Surface Normals

Once the ray has hit a surface at position $\mathbf{p}$, we need the outward normal vector for lighting. Since the SDF's gradient always points in the direction of steepest distance increase, perpendicular to the isosurface, the surface normal is simply the normalized gradient:

$$\mathbf{n} = \nabla f(\mathbf{p}) = \left(\frac{\partial f}{\partial x},\; \frac{\partial f}{\partial y},\; \frac{\partial f}{\partial z}\right)$$

We estimate this numerically using the efficient tetrahedral finite-difference method, which requires only 4 SDF samples (versus 6 for naive central differences):

vec3 getNormal(vec3 p) {
    float e = 0.001;
    vec2 k = vec2(1.0, -1.0);
    return normalize(
        k.xyy * sceneSDF(p + k.xyy * e) +
        k.yyx * sceneSDF(p + k.yyx * e) +
        k.yxy * sceneSDF(p + k.yxy * e) +
        k.xxx * sceneSDF(p + k.xxx * e)
    );
}

The four sample offsets $(+,-,-),\; (-,-,+),\; (-,+,-),\; (+,+,+)$ form a tetrahedron around $\mathbf{p}$. Their weighted sum approximates the gradient direction with very low error even at larger epsilon values, making it robust for real-time use.

Boolean Operations and Shape Combination

Here is where SDFs shine. Combining two shapes requires nothing more than arithmetic on their distance values:

// Union: the surface closer to p wins
float opUnion(float d1, float d2) {
    return min(d1, d2);
}

// Intersection: only where both shapes overlap
float opIntersect(float d1, float d2) {
    return max(d1, d2);
}

// Subtraction: carve d2 out of d1
float opSubtract(float d1, float d2) {
    return max(d1, -d2);
}

// Smooth Union: organic blending with radius k
float opSmoothUnion(float d1, float d2, float k) {
    float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
    return mix(d2, d1, h) - k * h * (1.0 - h);
}

The smooth union operator, developed by Inigo Quilez, creates organic blending. It produces a blend zone of width $k$ where two surfaces meld together. The formula computes a blending weight $h \in [0,1]$, then applies a quadratic correction $-k \cdot h(1-h)$ that pushes the surface inward within the transition zone. This is the mechanism behind the iconic metaball effect, where liquid blobs merge and separate based on proximity.

Soft Shadows and Ambient Occlusion

SDFs enable two lighting effects that would otherwise be expensive: soft shadows and ambient occlusion.

For soft shadows, we march a shadow ray from the surface toward the light source, recording the minimum SDF value encountered along the way. A small minimum means the ray passed close to an occluder, which we use to produce a penumbra:

float softShadow(vec3 ro, vec3 rd, float mint, float maxt) {
    float res = 1.0;
    float t = mint;
    for (int i = 0; i < 24; i++) {
        float h = sceneSDF(ro + rd * t);
        if (h < 0.001) return 0.0;     // fully in shadow
        res = min(res, 8.0 * h / t);   // penumbra factor
        t += clamp(h, 0.02, 0.3);
        if (t > maxt) break;
    }
    return clamp(res, 0.0, 1.0);
}

The expression 8.0 * h / t relates the clearance h at distance t to the angular size of the occluder as seen from the surface point. A larger constant gives harder shadows; a smaller one gives softer penumbras.

Ambient occlusion is approximated by stepping short distances along the surface normal and comparing the actual SDF value to the step distance. Nearby geometry reduces this ratio, indicating occlusion:

float ambientOcclusion(vec3 pos, vec3 nor) {
    float occ = 0.0;
    float sca = 1.0;
    for (int i = 0; i < 5; i++) {
        float h = 0.01 + 0.15 * float(i) / 4.0;
        float d = sceneSDF(pos + nor * h);
        occ += (h - d) * sca;
        sca *= 0.9;
    }
    return clamp(1.0 - 3.5 * occ, 0.0, 1.0);
}

Transformations and Domain Operations

SDFs transform elegantly. Rather than transforming the geometry, you transform the query point in the opposite direction before evaluating the SDF:

// Translate: move the SDF's origin
float sdSphereAt(vec3 p, vec3 center, float r) {
    return sdSphere(p - center, r);
}

// Rotate: apply inverse rotation to the query point
float sdBoxRotated(vec3 p, mat3 invRot, vec3 b) {
    return sdBox(invRot * p, b);
}

// Infinite repetition: fold space with period c
vec3 opRepeat(vec3 p, vec3 c) {
    return mod(p + 0.5 * c, c) - 0.5 * c;
}

// Twist deformation along Y axis
vec3 opTwist(vec3 p, float k) {
    float c = cos(k * p.y), s = sin(k * p.y);
    return vec3(c*p.x - s*p.z, p.y, s*p.x + c*p.z);
}

The infinite repetition operator uses a clever trick: a single mod operation folds 3D space into a tiled cell, allowing one SDF evaluation to describe an infinite lattice of identical objects at no additional cost. Combine this with a box SDF for an infinite city grid, or a sphere for a star field.

Real-World Applications

Unity's SDF Text Rendering

Unity's TextMesh Pro and the newer UI Toolkit store pre-computed distance information for font glyphs in textures. At runtime the shader reconstructs sharp edges by thresholding the distance value, producing sharp, resolution-independent text that can be freely scaled, outlined, and drop-shadowed without re-rasterization. The glyph SDF is generated at high resolution (e.g. 4096×4096) and stored at a fraction of that size, achieving significant compression while retaining quality at any scale.

Dreams (Media Molecule)

Dreams uses SDFs for both rendering and physics of its sculptable objects. Every user-created asset is an SDF volume; collision detection becomes a simple distance lookup rather than a mesh intersection test. This is why characters can collide with and deform complex freeform shapes in real time on PS4 hardware.

Procedural Metaballs

The metaball effect (organic liquid blobs that merge as they approach each other) is a direct application of opSmoothUnion. Each blob is an SDF sphere, and the smooth union creates the characteristic neck that forms between them. This appears in games from Gish (2004) to modern physics-based puzzle games.

Volumetric Effects

Ray marching through SDF-described fog volumes and procedural clouds is used for volumetric lighting in titles like Horizon Zero Dawn and many Unreal Engine 5 titles. The per-pixel parallelism of the fragment shader aligns well with volume integration along view rays.

Performance Considerations

Ray marching is GPU-friendly but requires care to keep frame times in budget:

  • Reduce step count: 64 steps is sufficient for most scenes; use relaxed sphere tracing (a small overstep factor) for faster convergence in open areas
  • Half-resolution rendering: march at half resolution and upscale — the depth-discontinuity edges are the only region requiring full resolution
  • Bounding volumes: compute screen-space bounding boxes for SDF objects and only ray-march within those tiles (the GPU tile rasterizer maps perfectly to this)
  • Hybrid pipelines: combine traditional rasterization for large static meshes with ray marching for SDF-specific effects — Unity's VFX Graph uses exactly this approach for smoke and fluid effects
  • Brick maps / SVOs: for large worlds, precompute a Sparse Voxel Octree (SVO) of the SDF so the shader can skip empty space in $O(\log n)$ instead of linear march steps

With 80 steps at 1080p, a well-written ray marching shader runs at 60+ FPS on any mid-range GPU. The interactive demo above runs entirely in a fragment shader on your GPU right now, every pixel independently sphere-tracing a scene with smooth unions, soft shadows, and ambient occlusion.

Getting Started

The main resource for SDFs is Inigo Quilez's website (iquilezles.org), which contains an extensive library of primitives, distance operators, and lighting techniques. Shadertoy is a great sandbox: open any browser tab, write a fragment shader, and start experimenting. For game engines, Unity's Shader Graph and Unreal's Custom node both accept raw GLSL/HLSL, letting you drop these exact functions into production pipelines.

The article's demo above visualizes the complete pipeline: three orbiting spheres and a rotating torus blended via opSmoothUnion, lit with a single directional light, soft shadows, ambient occlusion, and a rim light, all computed analytically from pure math functions with not a single triangle in sight.

Comments

Like this article? Consider supporting us

Your support helps us keep creating free game dev content, tutorials, and tools.

Free

$0 /month

Newsletter and public posts

  • Newsletter access
  • Public posts & updates
  • Community access

Studio Backer

$25 /month

Direct impact on development with your name in the credits

  • Everything in Supporter
  • Your name in game credits
  • Priority feature requests
  • Direct developer access
  • Monthly asset downloads