home/ modules/ raymarching-first-sdf-scene

Raymarching a first SDF scene

  • learner can define signed distance functions and combine primitives with min-based scene composition and boolean operations
  • learner can march a ray through an SDF field using sphere tracing to find surface hits
  • learner can composite 2D reference SDF primitives — positioned with an offset and given visible thickness — as a screen-space overlay on the raymarched image

Raymarch a small 3D SDF scene that unions and subtracts several primitives and displays the surface hits, then composite a 2D SDF overlay on top of the image in screen space: a thickness-offset segment and an offset-positioned reference-library shape, thresholded with step/smoothstep.

This module is where your visuals stop being flat gradients and become geometry. In a live set, raymarched SDF scenes are the workhorse of projector visuals: an entire evolving 3D world lives in one fragment shader, hot-reloaded on save, with no meshes, no asset pipeline — just distance formulas you can mutate mid-performance. The whole task is a first working raymarcher: several primitives unioned and carved into a small 3D scene whose surface hits show up on screen, finished with a flat 2D SDF overlay composited over the image.

Start supported. First get one shape on canvas by understanding what a signed distance function actually returns — positive outside, negative inside, zero at the boundary — and model the camera ray as a parametric function so “where does this ray hit?” becomes “solve for t”. Then wire the sphere-tracing loop: step along the ray by exactly the SDF’s value, terminate on a hit threshold or a far miss. From there the scene grows by repetition of two micro-moves you should drill to automaticity inside the build: inserting a min() to union in a new object, and max(distance, -cutter) to subtract one. The final layer is deliberately 2D: the same distance-field math evaluated in screen space and thresholded with step()/smoothstep(), drawn over the raymarched result. Don’t derive these shapes — the segment SDF and the offset-adaptation workflow for reference-library shapes are your just-in-time how-tos, remembering that thin 1D primitives need a thickness offset subtracted to be visible at all.

Every required atom gates the capstone: without composition, subtraction, or the march loop the 3D scene cannot render, and without the segment SDF, the thickness offset, and the library drop-in workflow the 2D overlay cannot appear. The supporting atoms enrich rather than gate — why primitive-based SDFs dominate demoscene and Shadertoy practice, material IDs for per-object shading later, and the checkpoint-stage workflow that keeps a live build recoverable.

Walkthrough

An entire 3D world in one fragment shader, no meshes — just distance formulas. Paste each into The Book of Shaders editor. Each is a complete shader. (GLSL needs functions declared before use, so helpers sit above main.)

1 — what an SDF returns. A signed distance function gives the distance to a surface: positive outside, negative inside, zero on the boundary. Visualise a 2D circle’s field — magnitude as brightness, sign as colour ([[sdf-signed-distance-function]]).

precision mediump float;
uniform vec2 u_resolution;
void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;
  float d = length(uv) - 0.5;                 // SDF of a circle radius 0.5
  vec3 col = vec3(abs(d)) * (d < 0.0 ? vec3(1.0, 0.6, 0.3) : vec3(0.3, 0.5, 1.0));
  col = mix(col, vec3(1.0), 1.0 - smoothstep(0.0, 0.01, abs(d)));  // white boundary
  gl_FragColor = vec4(col, 1.0);
}

2 — a ray, and sphere tracing. Model each pixel as a ray from a camera; march along it by exactly the SDF’s value each step (the largest safe stride) until you hit the surface or run far. Show the silhouette ([[ray-as-parametric-function]], [[raymarching-sphere-tracing]]).

precision mediump float;
uniform vec2 u_resolution;
float sdSphere(vec3 p, float r) { return length(p) - r; }
void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;
  vec3 ro = vec3(0.0, 0.0, 3.0);              // camera / ray origin
  vec3 rd = normalize(vec3(uv, -1.5));        // ray direction, one per pixel
  float t = 0.0, hit = 0.0;
  for (int i = 0; i < 64; i++) {
    float d = sdSphere(ro + rd * t, 1.0);
    if (d < 0.001) { hit = 1.0; break; }       // surface
    t += d;                                    // step by the distance
    if (t > 10.0) break;                       // miss
  }
  gl_FragColor = vec4(vec3(hit) * vec3(0.4, 0.7, 1.0), 1.0);
}

3 — normals and light (it becomes solid). The surface normal is the gradient of the SDF (four extra map() samples). Dot it with a light direction for diffuse shading — and the flat silhouette turns into a lit sphere ([[sdf-surface-normal]] if present).

precision mediump float;
uniform vec2 u_resolution;
float map(vec3 p) { return length(p) - 1.0; }
vec3 calcNormal(vec3 p) {
  vec2 e = vec2(0.001, 0.0);
  return normalize(vec3(map(p + e.xyy) - map(p - e.xyy),
                        map(p + e.yxy) - map(p - e.yxy),
                        map(p + e.yyx) - map(p - e.yyx)));
}
void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;
  vec3 ro = vec3(0.0, 0.0, 3.0), rd = normalize(vec3(uv, -1.5));
  float t = 0.0; bool hit = false;
  for (int i = 0; i < 80; i++) { float d = map(ro + rd * t); if (d < 0.001) { hit = true; break; } t += d; if (t > 10.0) break; }
  vec3 col = vec3(0.05, 0.06, 0.1);
  if (hit) {
    vec3 n = calcNormal(ro + rd * t);
    float diff = max(dot(n, normalize(vec3(0.7, 0.8, 0.5))), 0.0);
    col = vec3(0.3, 0.6, 0.9) * diff + 0.08;
  }
  gl_FragColor = vec4(col, 1.0);
}

4 — union with min(). Grow the scene by combining SDFs: min(a, b) is the union — the nearest surface wins, so two shapes become one field. Here a sphere and a box ([[sdf-scene-composition-min]]).

precision mediump float;
uniform vec2 u_resolution;
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); }
float map(vec3 p) {
  float s = length(p - vec3(-0.7, 0.0, 0.0)) - 0.8;
  float b = sdBox(p - vec3(0.7, 0.0, 0.0), vec3(0.55));
  return min(s, b);                            // UNION
}
vec3 calcNormal(vec3 p) { vec2 e = vec2(0.001, 0.0); return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx))); }
void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;
  vec3 ro = vec3(0.0, 0.0, 3.5), rd = normalize(vec3(uv, -1.6));
  float t = 0.0; bool hit = false;
  for (int i = 0; i < 90; i++) { float d = map(ro + rd * t); if (d < 0.001) { hit = true; break; } t += d; if (t > 12.0) break; }
  vec3 col = vec3(0.05, 0.06, 0.1);
  if (hit) { vec3 n = calcNormal(ro + rd * t); col = vec3(0.35, 0.6, 0.85) * max(dot(n, normalize(vec3(0.6, 0.8, 0.4))), 0.0) + 0.08; }
  gl_FragColor = vec4(col, 1.0);
}

5 — subtract with max(d, -cutter). Carving is the other micro-move: max(a, -b) removes shape b from a. Cut a spherical hole out of the box — the two moves (min to add, max(…,-…) to subtract) build any scene ([[sdf-boolean-subtraction]]).

precision mediump float;
uniform vec2 u_resolution;
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); }
float map(vec3 p) {
  float b = sdBox(p, vec3(0.8));
  float cutter = length(p - vec3(0.0, 0.0, 0.6)) - 0.6;
  return max(b, -cutter);                      // SUBTRACT the sphere from the box
}
vec3 calcNormal(vec3 p) { vec2 e = vec2(0.001, 0.0); return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx))); }
void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;
  vec3 ro = vec3(0.0, 0.0, 3.5), rd = normalize(vec3(uv, -1.6));
  float t = 0.0; bool hit = false;
  for (int i = 0; i < 90; i++) { float d = map(ro + rd * t); if (d < 0.001) { hit = true; break; } t += d; if (t > 12.0) break; }
  vec3 col = vec3(0.05, 0.06, 0.1);
  if (hit) { vec3 n = calcNormal(ro + rd * t); col = vec3(0.9, 0.6, 0.4) * max(dot(n, normalize(vec3(0.6, 0.8, 0.4))), 0.0) + 0.08; }
  gl_FragColor = vec4(col, 1.0);
}

6 — a scene, plus a 2D SDF overlay (the capstone). The full task: a box with a hole carved out, unioned with a moving ball (3D), then a flat 2D SDF overlay in screen space — a thick segment thresholded with smoothstep (thin 1D shapes need a thickness offset subtracted to show at all), composited over the render:

precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
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); }
float map(vec3 p) {
  float b = sdBox(p, vec3(0.8));
  float hole = length(p - vec3(0.0, 0.0, 0.7)) - 0.55;
  float scene = max(b, -hole);                 // box minus hole
  float ball = length(p - vec3(sin(u_time) * 0.9, 0.0, 0.0)) - 0.4;
  return min(scene, ball);                     // union the moving ball
}
vec3 calcNormal(vec3 p) { vec2 e = vec2(0.001, 0.0); return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx))); }
float sdSegment(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h); }
void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;
  vec3 ro = vec3(0.0, 0.0, 3.5), rd = normalize(vec3(uv, -1.6));
  float t = 0.0; bool hit = false;
  for (int i = 0; i < 90; i++) { float d = map(ro + rd * t); if (d < 0.001) { hit = true; break; } t += d; if (t > 12.0) break; }
  vec3 col = vec3(0.04, 0.05, 0.09);
  if (hit) { vec3 n = calcNormal(ro + rd * t); col = vec3(0.4, 0.65, 0.9) * max(dot(n, normalize(vec3(0.6, 0.8, 0.4))), 0.0) + 0.08; }
  float seg = sdSegment(uv, vec2(-0.8, -0.7), vec2(0.8, -0.55)) - 0.02;   // 2D overlay, thickness offset
  col = mix(col, vec3(1.0, 0.8, 0.3), 1.0 - smoothstep(0.0, 0.01, seg));
  gl_FragColor = vec4(col, 1.0);
}

What good looks like. A recognisable 3D form — you should read the lit box, the carved hole, and the moving ball as solid geometry, not a flat gradient — with the 2D line crisp on top. If the whole frame is background colour, your camera is pointed away or the objects are behind it (check ro/rd); if edges shimmer, raise the step count or lower the hit threshold; if the 2D shape is invisible, you forgot the thickness offset (a segment is 1D — subtract a radius). The two boolean moves (min to add, max(…,-…) to subtract) are the whole vocabulary of SDF modelling. (Skill map: live-visualist Domain A3 — raymarched SDF geometry.)

Now make it yours. Add a third primitive with another min(). Animate the camera by rotating ro with u_time. Smooth-union the ball into the box (smin) for a metaball melt. Add a second 2D overlay shape. Colour the surface by its normal (col = n * 0.5 + 0.5).

Runnable examples

Generated from the context/ instrument corpus by concept (redistributable idioms only). Do not edit — regenerate with gen-module-examples.mjs.

feedback-trail

osc(4).modulate(src(o0), 0.6).out(o0)

hydra-0022 · CC0-1.0

function draw(){ fill(0, 20); rect(0, 0, width, height); circle(mouseX, mouseY, 40) }

p5live-0003 · CC0-1.0

tiling-repeat

osc(10).repeat(3, 3).out()

hydra-0008 · CC0-1.0

tile [4,4] (circle 0 0.3) >> add

punctual-0020 · CC0-1.0

radial-symmetry

osc(10).kaleid(5).out()

hydra-0010 · CC0-1.0

// sandbox
osc(10, 0.05, 1.3).kaleid(8).out()
// sandbox

p5live-0037 · CC0-1.0

scale-pulse

uv *= 1.0 + 0.3 * sin(u_time * 2.0);

glsl-0030 · public-domain

updateAudio(); scale(1 + amp * 0.01)

p5live-0044 · CC0-1.0

sdf-shape

circle [0,0] 0.4 >> add

punctual-0018 · CC0-1.0

float d = length(uv) - r;

glsl-0003 · public-domain

mirror

uv = abs(uv);

glsl-0010 · public-domain

osc(10).kaleid(2).out()

hydra-0011 · CC0-1.0

vector-drawing

beginShape(); for(let p of pts) curveVertex(p.x, p.y); endShape()

p5live-0016 · CC0-1.0

raymarch-sdf

vec3 nrm(vec3 p){vec2 e=vec2(.001,0);return normalize(vec3(map(p+e.xyy)-map(p-e.xyy),map(p+e.yxy)-map(p-e.yxy),map(p+e.yyx)-map(p-e.yyx)));}

glsl-0033 · public-domain

boolean-sdf

float u = min(a, b); float s = max(a, -b);

glsl-0006 · public-domain

typography

let pts = font.textToPoints('P5', 0, 200, 200, {sampleFactor: 0.2})

p5live-0029 · CC0-1.0

Atoms in this module

Required — these gate the capstone

A signed distance function (SDF) returns positive distances outside a shape, negative inside, and zero at its boundary
Concept L2 First instrument G
A ray is modeled as the parametric function P(t) = A + t*b to enable intersection math
Concept L2 First instrument G
Raymarching finds ray–surface intersections by stepping along the ray using the SDF value as the safe step distance
Concept L2 First instrument G
The minimum of multiple SDFs combines them into a single scene that ray marchers can query
Concept L2 First instrument G
SDF union, intersection, and subtraction combine primitive shapes into complex ones
Concept L2 First instrument G
SDF boolean subtraction uses max(distance, -cutter) to carve one shape out of another
Concept L2 First instrument G
The SDF of a line segment is the distance from a point to the nearest point on the clamped segment
Concept L2 First instrument G
Line-segment and curve SDFs need a thickness offset subtracted to become visible
Procedure L2 First instrument G
A reference-list SDF is dropped into a shader by adding an offset parameter subtracted from p
Procedure L2 First instrument G

Supporting — enrichment, not gating

Primitive-based SDFs define scenes as mathematical formulae rather than volumetric grids, giving infinite precision and a tiny memory footprint
Concept L2 First instrument G
Returning a material ID alongside the SDF distance lets the raymarcher know which object was hit for shading
Concept L2 First instrument G
IQ's first SDF-raymarched image, Slisesix (2008), won a 4KB demoscene procedural-graphics competition
Fact L2 First instrument GO
Dividing a shader build into named checkpoint stages lets you resume from a stable state and avoid rabbit holes
Principle L3 Craft G
Reserve raymarched 3D for when depth adds meaning; a flat SDF composition is often stronger
Principle L2 First instrument LGH
SDFs and immediate-mode drawing are two distinct shape paradigms: field-based vs path-based
Concept L2 First instrument LGH
Geometric visuals are built by combining one SDF shape with boolean operations, then imposing symmetry, then composing the frame
Procedure L2 First instrument HG
Fractal visuals have two build routes: domain repetition with raymarching for 3D lattices, or feedback zoom for a cheap 2D self-similar tunnel
Concept L2 First instrument HG