Raymarching a first SDF scene
Learning objectives
- 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
Capstone — one whole task that evidences the objectives
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.
Prerequisite modules
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
Supporting — enrichment, not gating
Part of curricula
- Live Visualist — zero to performing live-coded & generative visuals — Reactive & procedural — make it listen, and go to the GPU recommended
- Shader Artist — real-time GPU craft to a demoscene-grade visual — Raymarching and sculpting SDF worlds required
Unlocks — modules that require this one