Ray Marching Basics: Building a 3D Scene from Signed Distance Functions

227 views 0 replies
Live Shader
Loading versions...

Ray marching is one of the most elegant techniques in computer graphics. Unlike traditional polygon-based rendering, ray marching constructs entire 3D scenes using nothing but mathematical functions. There are no meshes, no vertices, no triangles — just pure math describing the shape of space itself. In this tutorial, we will build a complete ray-marched scene from scratch, covering signed distance functions (SDFs), the marching loop, normal estimation, Phong lighting, soft shadows, and ambient occlusion.

The Complete Shader

Below is the full, standalone fragment shader. It renders a procedural scene featuring a smoothly blended sphere and torus hovering above an infinite plane, with a secondary orbiting sphere. The scene includes Blinn-Phong lighting, soft shadows, ambient occlusion, fog, and gentle animation.

precision mediump float;

uniform vec2 iResolution;
uniform float iTime;

#define MAX_STEPS 128
#define MAX_DIST 80.0
#define SURF_DIST 0.001
#define PI 3.14159265

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

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

float sdPlane(vec3 p) { return p.y; }

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);
}

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);
}

vec2 opUnion(vec2 a, vec2 b) { return (a.x < b.x) ? a : b; }

vec2 sceneSDF(vec3 p) {
    float t = iTime;
    float plane = sdPlane(p);
    vec2 ground = vec2(plane, 1.0);

    vec3 spherePos = vec3(0.0, 1.2 + 0.3 * sin(t * 0.8), 0.0);
    float sphere = sdSphere(p - spherePos, 1.0);

    vec3 tp = p - vec3(0.0, 1.2 + 0.3 * sin(t * 0.8), 0.0);
    float tiltAngle = t * 0.3;
    vec3 tpRotX = vec3(tp.x, tp.y * cos(tiltAngle) - tp.z * sin(tiltAngle), tp.y * sin(tiltAngle) + tp.z * cos(tiltAngle));
    float torus = sdTorus(tpRotX, vec2(1.5, 0.25));

    float blended = smin(sphere, torus, 0.5);
    vec2 mainObject = vec2(blended, 2.0);

    float orbitRadius = 3.0;
    vec3 orbitPos = vec3(orbitRadius * cos(t * 0.7), 0.6 + 0.4 * sin(t * 1.1), orbitRadius * sin(t * 0.7));
    float orbitSphere = sdSphere(p - orbitPos, 0.5);
    vec2 orbiter = vec2(orbitSphere, 3.0);

    vec3 boxPos = vec3(-2.5, 0.6, -1.5);
    vec3 bp = p - boxPos;
    float boxAngle = t * 1.2;
    vec3 bpRot = vec3(bp.x * cos(boxAngle) - bp.z * sin(boxAngle), bp.y, bp.x * sin(boxAngle) + bp.z * cos(boxAngle));
    float box = sdBox(bpRot, vec3(0.5, 0.5, 0.5)) - 0.05;
    vec2 boxObj = vec2(box, 4.0);

    vec2 scene = opUnion(ground, mainObject);
    scene = opUnion(scene, orbiter);
    scene = opUnion(scene, boxObj);
    return scene;
}

vec2 rayMarch(vec3 ro, vec3 rd) {
    float dist = 0.0;
    float matID = 0.0;
    for (int i = 0; i < MAX_STEPS; i++) {
        vec3 p = ro + rd * dist;
        vec2 hit = sceneSDF(p);
        float stepDist = hit.x;
        matID = hit.y;
        dist += stepDist;
        if (stepDist < SURF_DIST) break;
        if (dist > MAX_DIST) break;
    }
    return vec2(dist, matID);
}

vec3 estimateNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    float d = sceneSDF(p).x;
    return normalize(vec3(sceneSDF(p + e.xyy).x - d, sceneSDF(p + e.yxy).x - d, sceneSDF(p + e.yyx).x - d));
}

float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
    float result = 1.0;
    float t = mint;
    for (int i = 0; i < 48; i++) {
        if (t > maxt) break;
        float h = sceneSDF(ro + rd * t).x;
        if (h < SURF_DIST) return 0.0;
        result = min(result, k * h / t);
        t += clamp(h, 0.02, 0.5);
    }
    return clamp(result, 0.0, 1.0);
}

float ambientOcclusion(vec3 p, vec3 n) {
    float ao = 0.0;
    float weight = 1.0;
    for (int i = 1; i <= 5; i++) {
        float dist = 0.05 * float(i);
        float sdfVal = sceneSDF(p + n * dist).x;
        ao += weight * (dist - sdfVal);
        weight *= 0.5;
    }
    return 1.0 - clamp(ao * 3.0, 0.0, 1.0);
}

vec3 getMaterialColor(float matID, vec3 p) {
    if (matID < 1.5) {
        float checker = mod(floor(p.x) + floor(p.z), 2.0);
        return mix(vec3(0.15, 0.15, 0.17), vec3(0.4, 0.4, 0.42), checker);
    } else if (matID < 2.5) {
        return vec3(0.9, 0.45, 0.15);
    } else if (matID < 3.5) {
        return vec3(0.2, 0.5, 0.9);
    } else {
        return vec3(0.25, 0.85, 0.4);
    }
}

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

    float camAngle = iTime * 0.2;
    float camHeight = 3.5 + 0.5 * sin(iTime * 0.15);
    float camDist = 7.0;
    vec3 ro = vec3(camDist * cos(camAngle), camHeight, camDist * sin(camAngle));
    vec3 target = vec3(0.0, 1.0, 0.0);
    vec3 forward = normalize(target - ro);
    vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0)));
    vec3 up = cross(right, forward);
    vec3 rd = normalize(forward + uv.x * right + uv.y * up);

    vec2 result = rayMarch(ro, rd);
    float dist = result.x;
    float matID = result.y;

    vec3 skyColor = mix(vec3(0.6, 0.75, 0.95), vec3(0.15, 0.2, 0.4), uv.y + 0.5);
    vec3 color = skyColor;

    if (dist < MAX_DIST) {
        vec3 p = ro + rd * dist;
        vec3 n = estimateNormal(p);
        vec3 matColor = getMaterialColor(matID, p);

        vec3 lightPos1 = vec3(4.0, 8.0, -3.0);
        vec3 lightCol1 = vec3(1.0, 0.95, 0.85);
        vec3 l1 = normalize(lightPos1 - p);
        vec3 v = normalize(ro - p);
        vec3 h1 = normalize(l1 + v);
        float diff1 = max(dot(n, l1), 0.0);
        float spec1 = pow(max(dot(n, h1), 0.0), 64.0);
        float shadow1 = softShadow(p + n * 0.01, l1, 0.02, 25.0, 16.0);

        vec3 lightPos2 = vec3(-5.0, 5.0, 5.0);
        vec3 lightCol2 = vec3(0.3, 0.35, 0.6);
        vec3 l2 = normalize(lightPos2 - p);
        vec3 h2 = normalize(l2 + v);
        float diff2 = max(dot(n, l2), 0.0);
        float spec2 = pow(max(dot(n, h2), 0.0), 32.0);
        float shadow2 = softShadow(p + n * 0.01, l2, 0.02, 25.0, 8.0);

        float ao = ambientOcclusion(p, n);
        vec3 ambient = vec3(0.08, 0.09, 0.12) * matColor * ao;
        vec3 light = ambient;
        light += lightCol1 * matColor * diff1 * shadow1;
        light += lightCol1 * spec1 * shadow1 * 0.5;
        light += lightCol2 * matColor * diff2 * shadow2 * 0.4;
        light += lightCol2 * spec2 * shadow2 * 0.15;
        float fresnel = pow(1.0 - max(dot(n, v), 0.0), 4.0);
        light += fresnel * vec3(0.2, 0.25, 0.35) * ao * 0.5;
        color = light;

        float fogAmount = 1.0 - exp(-dist * 0.04);
        color = mix(color, skyColor, fogAmount);
    }

    color = color / (color + vec3(1.0));
    color = pow(color, vec3(1.0 / 2.2));
    gl_FragColor = vec4(color, 1.0);
}

What is Ray Marching?

Ray marching is a rendering technique where, for each pixel on screen, we cast a ray from the camera into the scene and march along it in discrete steps until we hit a surface. Unlike ray tracing with analytical intersections, ray marching uses Signed Distance Functions (SDFs) to determine how far away the nearest surface is at any point in space.

Signed Distance Functions (SDFs)

An SDF is a function that takes a 3D point and returns the shortest distance from that point to the nearest surface of an object. The sign tells us which side of the surface we are on: positive means outside, negative means inside, and zero means exactly on the surface.

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

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

float sdPlane(vec3 p) {
    return p.y;
}

You can combine SDFs using simple operations. Union (minimum), intersection (maximum), subtraction, and the smin function for smooth blends between shapes.

The Ray Marching Loop

The core algorithm is surprisingly simple. Start at the camera, step along the ray direction. At each step, evaluate the scene SDF to find the distance to the closest surface. Advance by exactly that distance. Repeat until you hit a surface or march too far.

vec2 rayMarch(vec3 ro, vec3 rd) {
    float dist = 0.0;
    for (int i = 0; i < MAX_STEPS; i++) {
        vec3 p = ro + rd * dist;
        vec2 hit = sceneSDF(p);
        dist += hit.x;
        if (hit.x < SURF_DIST) break;
        if (dist > MAX_DIST) break;
    }
    return vec2(dist, matID);
}

Estimating Surface Normals

We compute the normal by sampling the SDF at tiny offsets around the hit point. The gradient of the distance field points away from the surface — that is our normal.

vec3 estimateNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    float d = sceneSDF(p).x;
    return normalize(vec3(
        sceneSDF(p + e.xyy).x - d,
        sceneSDF(p + e.yxy).x - d,
        sceneSDF(p + e.yyx).x - d
    ));
}

Soft Shadows and Ambient Occlusion

One of the great advantages of ray marching is that soft shadows come almost for free. We march a secondary ray from the surface toward the light source and track how close it passes to other surfaces for a soft penumbra. Ambient occlusion samples the SDF at increasing distances along the normal to approximate how much ambient light reaches a surface point.

Where to Go from Here

This shader covers the foundational building blocks. Explore more SDF primitives, domain repetition using mod() for infinite worlds, fractal geometry, and volumetric effects. Ray marching rewards experimentation — every modification teaches you something new about how mathematical functions can describe the world.

Moonjump
Forum Search Shader Sandbox
Sign In Register