Shader concept

Ray marching: drawing 3D scenes with a distance function

Ray marching draws a 3D scene without any triangles. For each pixel you send a ray into the scene and walk along it in steps, using a distance function to know how far it is safe to go, until you hit something.

Running live in your browser. The code below is exactly what draws it.

The loop

  1. Make a ray for the pixel: a start point (the camera) and a direction through that pixel.
  2. Ask the scene how far the nearest surface is from where the ray is now (the map function: a signed distance field of the whole scene).
  3. Step forward by exactly that distance. It is safe: nothing can be closer than the nearest surface, so you cannot skip over anything.
  4. Repeat until the distance is tiny (you hit something) or the ray has gone too far (you hit the sky).

Because the step size is the distance to the nearest surface, the ray takes big strides through empty space and tiny ones near surfaces. That is sometimes called sphere tracing.

Lighting it

Once you have the hit point, estimate the surface direction (the normal) by asking map for the distance at tiny offsets in x, y and z; the difference is the slope. Then standard lighting works: the dot product of the normal with the light direction gives diffuse shading, and the same loop pointed at the light gives shadows.

Common mistakes

  • Not enough steps, so rays give up early and the scene shows holes near edges seen at a glancing angle. Raise the loop count, or the step multiplier.
  • A distance function that overestimates (after stretching space). The ray then steps through surfaces. Scale the step down by a safety factor.
  • Normals estimated with a huge offset, which blurs edges; or a tiny offset, which adds noise. 0.001 to 0.005 usually works.

The code, in every shader language

The same example for each language family. Open one to see it.

HLSL Unity, Unreal
float map(float3 p)
{
    float sphere = length(p - float3(0.0, 0.1 * sin(time), 0.0)) - 0.8;
    float floorY = p.y + 0.9;
    return min(sphere, floorY);
}

float3 normalAt(float3 p)
{
    float e = 0.002;
    return normalize(float3(map(p + float3(e, 0.0, 0.0)) - map(p - float3(e, 0.0, 0.0)),
                            map(p + float3(0.0, e, 0.0)) - map(p - float3(0.0, e, 0.0)),
                            map(p + float3(0.0, 0.0, e)) - map(p - float3(0.0, 0.0, e))));
}

float4 frag(float2 uv)
{
    float2 p = (uv - 0.5) * float2(aspect, 1.0);
    float3 ro = float3(0.0, 0.2, -3.0);                    // where the camera is
    float3 rd = normalize(float3(p.x, p.y - 0.05, 1.4));   // the direction of this pixel's ray
    float t = 0.0;
    float hit = 0.0;
    for (int i = 0; i < 64; i++)
    {
        float d = map(ro + rd * t);
        if (d < 0.001) { hit = 1.0; break; }
        t += d;                                            // safe to step this far
        if (t > 12.0) { break; }
    }
    float3 sky = lerp(float3(0.06, 0.08, 0.2), float3(0.3, 0.45, 0.7), uv.y);
    float3 pos = ro + rd * t;
    float3 n = normalAt(pos);
    float light = saturate(dot(n, normalize(float3(0.6, 0.8, -0.5)))) * 0.85 + 0.15;
    float3 surface = float3(0.95, 0.6, 0.35) * light;
    return float4(lerp(sky, surface, hit), 1.0);
}
GLSL Shadertoy, three.js, OpenGL
float map(vec3 p)
{
    float sphere = length(p - vec3(0.0, 0.1 * sin(time), 0.0)) - 0.8;
    float floorY = p.y + 0.9;
    return min(sphere, floorY);
}

vec3 normalAt(vec3 p)
{
    float e = 0.002;
    return normalize(vec3(map(p + vec3(e, 0.0, 0.0)) - map(p - vec3(e, 0.0, 0.0)),
                            map(p + vec3(0.0, e, 0.0)) - map(p - vec3(0.0, e, 0.0)),
                            map(p + vec3(0.0, 0.0, e)) - map(p - vec3(0.0, 0.0, e))));
}

vec4 frag(vec2 uv)
{
    vec2 p = (uv - 0.5) * vec2(aspect, 1.0);
    vec3 ro = vec3(0.0, 0.2, -3.0);                    // where the camera is
    vec3 rd = normalize(vec3(p.x, p.y - 0.05, 1.4));   // the direction of this pixel's ray
    float t = 0.0;
    float hit = 0.0;
    for (int i = 0; i < 64; i++)
    {
        float d = map(ro + rd * t);
        if (d < 0.001) { hit = 1.0; break; }
        t += d;                                            // safe to step this far
        if (t > 12.0) { break; }
    }
    vec3 sky = mix(vec3(0.06, 0.08, 0.2), vec3(0.3, 0.45, 0.7), uv.y);
    vec3 pos = ro + rd * t;
    vec3 n = normalAt(pos);
    float light = clamp(dot(n, normalize(vec3(0.6, 0.8, -0.5))), 0.0, 1.0) * 0.85 + 0.15;
    vec3 surface = vec3(0.95, 0.6, 0.35) * light;
    return vec4(mix(sky, surface, hit), 1.0);
}
WGSL WebGPU, Bevy
fn map(p: vec3f) -> f32
{
    let sphere = length(p - vec3f(0.0, 0.1 * sin(time), 0.0)) - 0.8;
    let floorY = p.y + 0.9;
    return min(sphere, floorY);
}

fn normalAt(p: vec3f) -> vec3f
{
    let e = 0.002;
    return normalize(vec3f(map(p + vec3f(e, 0.0, 0.0)) - map(p - vec3f(e, 0.0, 0.0)), map(p + vec3f(0.0, e, 0.0)) - map(p - vec3f(0.0, e, 0.0)), map(p + vec3f(0.0, 0.0, e)) - map(p - vec3f(0.0, 0.0, e))));
}

fn frag(uv: vec2f) -> vec4f
{
    let p = (uv - 0.5) * vec2f(aspect, 1.0);
    let ro = vec3f(0.0, 0.2, -3.0);                    // where the camera is
    let rd = normalize(vec3f(p.x, p.y - 0.05, 1.4));   // the direction of this pixel's ray
    var t = 0.0;
    var hit = 0.0;
    for (var i = 0; i < 64; i++)
    {
        let d = map(ro + rd * t);
        if (d < 0.001) { hit = 1.0; break; }
        t += d;                                            // safe to step this far
        if (t > 12.0) { break; }
    }
    let sky = mix(vec3f(0.06, 0.08, 0.2), vec3f(0.3, 0.45, 0.7), uv.y);
    let pos = ro + rd * t;
    let n = normalAt(pos);
    let light = saturate(dot(n, normalize(vec3f(0.6, 0.8, -0.5)))) * 0.85 + 0.15;
    let surface = vec3f(0.95, 0.6, 0.35) * light;
    return vec4f(mix(sky, surface, hit), 1.0);
}
Godot Godot 4 shading language
shader_type canvas_item;

float map(vec3 p)
{
    float sphere = length(p - vec3(0.0, 0.1 * sin(TIME), 0.0)) - 0.8;
    float floorY = p.y + 0.9;
    return min(sphere, floorY);
}

vec3 normalAt(vec3 p)
{
    float e = 0.002;
    return normalize(vec3(map(p + vec3(e, 0.0, 0.0)) - map(p - vec3(e, 0.0, 0.0)),
                            map(p + vec3(0.0, e, 0.0)) - map(p - vec3(0.0, e, 0.0)),
                            map(p + vec3(0.0, 0.0, e)) - map(p - vec3(0.0, 0.0, e))));
}

vec4 frag(vec2 uv)
{
    vec2 p = (uv - 0.5) * vec2(aspect, 1.0);
    vec3 ro = vec3(0.0, 0.2, -3.0);                    // where the camera is
    vec3 rd = normalize(vec3(p.x, p.y - 0.05, 1.4));   // the direction of this pixel's ray
    float t = 0.0;
    float hit = 0.0;
    for (int i = 0; i < 64; i++)
    {
        float d = map(ro + rd * t);
        if (d < 0.001) { hit = 1.0; break; }
        t += d;                                            // safe to step this far
        if (t > 12.0) { break; }
    }
    vec3 sky = mix(vec3(0.06, 0.08, 0.2), vec3(0.3, 0.45, 0.7), uv.y);
    vec3 pos = ro + rd * t;
    vec3 n = normalAt(pos);
    float light = clamp(dot(n, normalize(vec3(0.6, 0.8, -0.5))), 0.0, 1.0) * 0.85 + 0.15;
    vec3 surface = vec3(0.95, 0.6, 0.35) * light;
    return vec4(mix(sky, surface, hit), 1.0);
}

void fragment()
{
    // Godot's UV starts at the top-left, the lessons use bottom-left
    COLOR = frag(vec2(UV.x, 1.0 - UV.y));
}
Metal iOS, macOS
float map(float3 p)
{
    float sphere = length(p - float3(0.0, 0.1 * sin(time), 0.0)) - 0.8;
    float floorY = p.y + 0.9;
    return min(sphere, floorY);
}

float3 normalAt(float3 p)
{
    float e = 0.002;
    return normalize(float3(map(p + float3(e, 0.0, 0.0)) - map(p - float3(e, 0.0, 0.0)),
                            map(p + float3(0.0, e, 0.0)) - map(p - float3(0.0, e, 0.0)),
                            map(p + float3(0.0, 0.0, e)) - map(p - float3(0.0, 0.0, e))));
}

float4 frag(float2 uv)
{
    float2 p = (uv - 0.5) * float2(aspect, 1.0);
    float3 ro = float3(0.0, 0.2, -3.0);                    // where the camera is
    float3 rd = normalize(float3(p.x, p.y - 0.05, 1.4));   // the direction of this pixel's ray
    float t = 0.0;
    float hit = 0.0;
    for (int i = 0; i < 64; i++)
    {
        float d = map(ro + rd * t);
        if (d < 0.001) { hit = 1.0; break; }
        t += d;                                            // safe to step this far
        if (t > 12.0) { break; }
    }
    float3 sky = mix(float3(0.06, 0.08, 0.2), float3(0.3, 0.45, 0.7), uv.y);
    float3 pos = ro + rd * t;
    float3 n = normalAt(pos);
    float light = saturate(dot(n, normalize(float3(0.6, 0.8, -0.5)))) * 0.85 + 0.15;
    float3 surface = float3(0.95, 0.6, 0.35) * light;
    return float4(mix(sky, surface, hit), 1.0);
}

Practise it in the lessons

Start with "3D, step 1: every pixel has a ray"

Questions

Is ray marching the same as ray tracing?

Both shoot rays. Ray tracing finds exact intersections with triangles. Ray marching walks along the ray using a distance function, which suits smooth, procedural shapes and needs no triangle meshes.

Is ray marching fast enough for games?

For full-screen scenes it is expensive, so it is mostly used for effects, volumes (clouds, smoke), and demos. For regular geometry games still use triangles.

Where can I learn it step by step?

The 3D module of the lessons builds the above one idea at a time: a ray for each pixel, testing a sphere, walking the ray, smart steps, normals and lighting.

Related concepts