Raymarching and Signed Distance Functions: Procedural 3D From Pure Mathematics
Render infinite procedural 3D scenes from pure mathematics: signed distance functions, sphere tracing, normals, and lighting inside one fragment shader.
Conventional 3D rendering starts with geometry files: meshes of triangles, exported from modeling tools, loaded and transformed per frame. There is an entirely different tradition—no assets, no vertices—where every shape exists only as a formula, and the image emerges from millions of parallel questions asked against those formulas each frame. That technique, raymarching over signed distance functions, produces the smooth, dreamlike, infinitely detailed visuals behind much of modern generative art—and, closer to home, the ambient low-poly serenity that tools like Timelapse use to keep focus environments calm rather than busy.
If you can write JavaScript, you can write this. The whole discipline fits in one fragment shader, and by the end of this article you will have a complete, runnable scene: two smoothly blended shapes, real lighting, shadows implied by occlusion, atmospheric depth—all mathematics, zero models.
Beyond polygons: geometry as a function
Triangle meshes describe surfaces explicitly: here are the exact triangles, take them or leave them. A signed distance function (SDF) instead describes space implicitly. For any point p in 3D space, it answers one number: how far is p from the nearest surface? Negative when p sits inside the shape, positive outside, exactly zero at the boundary. The surface itself is never stored—it is the implicit set of all points where the function evaluates to zero.
Two properties make this representation powerful. First, SDFs compose with trivial algebra. The union of two shapes is min(d1, d2)—whichever surface is nearer wins everywhere. Subtraction flips a sign: max(d1, −d2) carves the second shape out of the first. And the crown jewel, the smooth minimum, blends two distances so their shapes melt organically into each other—that single trick is responsible for the gooey, seamless look characteristic of the genre.
Second, primitives scale analytically. A sphere’s SDF is just length(p) − radius; boxes, capsules, toruses, cylinders each reduce to a line or three of vector math. Infinite planes cost one component. Because nothing depends on tessellation density, curves stay perfectly smooth at any zoom—an impossibility for any fixed mesh.
The catch: given an SDF description of a world, nobody hands you a picture. You must find where rays from the camera touch these invisible boundaries. That search has an elegant solution.
Sphere tracing: walking rays safely
For each pixel, cast a ray from the camera through the scene and ask: where does it first meet a surface? Brute-force sampling would miss thin shapes or step wastefully. Sphere tracing exploits the SDF’s meaning instead: standing at point p, the function value tells you the radius of the largest empty sphere around p. So step forward by exactly that much—guaranteed not to pass through anything, because no surface exists within that radius.
Repeat: evaluate the field at the new position, step its distance, again. Far from geometry, strides are huge and the ray crosses space in a handful of hops. Near a surface, distances shrink and the march decelerates into careful small steps until the value drops under a small threshold—contact—or the step count runs out, meaning the ray escaped to the sky.
Two honest caveats belong next to that loop. Step limits are load-bearing: cap iterations around 64–128 and accept a rare grazing ray that terminates early, visible as slight haloing against backgrounds. And the guarantee holds only while each step trusts the global minimum distance—if your scene math ever cheats (stretched spaces, cheap approximations), oversized steps will tunnel through thin walls. Keep functions honest and the loop stays simple.
Normals, light, and the illusion of materiality
Lighting needs surface normals—the direction a surface faces at each hit point—but an implicit surface publishes none. The standard extraction borrows calculus: the SDF’s gradient points perpendicular to its zero-level set. Numerically, sample the function at four slightly offset positions around the hit and combine the differences—a handful of extra evaluations buying correct-looking shading everywhere, sharp edges included.
From there, shading is conventional. A Lambert term—dot(normal, lightDirection) clamped to zero—gives matte diffuse response; a Blinn half-vector highlight adds specular sheen scaled by a shininess exponent. One genre-specific trick deserves adoption immediately: cheap ambient occlusion. March a few short samples outward along the normal and measure how cramped the surrounding field is; crevices read darker, exposed areas brighter. It costs a few evaluations and single-handedly sells solidity, grounding objects visually without any shadow-ray infrastructure.
Depth cueing completes the illusion: mix pixel color toward a fog color based on distance traveled, exp(-k·t²)-style, and even stark primitive scenes acquire atmosphere and scale.
Code walkthrough: a complete minimal scene
The shader below is complete and WebGL1-compatible—paste it into any GLSL editor (on Shadertoy, rename the uniforms to iResolution/iTime) with a fullscreen quad behind it:
// Minimal raymarched scene — pure math, zero assets.
precision highp float;
uniform vec2 uResolution;
uniform float uTime;
float sdSphere(vec3 p, float r) { return length(p) - r; }
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);
}
// Smooth union: shapes melt together instead of hard-merging.
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);
}
float map(vec3 p) {
float ground = p.y + 0.5; // infinite plane y=-0.5
float breathe = 0.5 + 0.5 * sin(uTime * 0.6); // slow morph driver
float d = smin(sdSphere(p - vec3(0.0, 0.2, 0.0), 0.55),
sdBox(p - vec3(0.0, 0.35, 0.0), vec3(0.45)),
0.05 + 0.25 * breathe);
return min(d, ground);
}
vec3 calcNormal(vec3 p) {
const vec2 e = vec2(0.002, 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 * 2.0 - uResolution) / uResolution.y;
// Simple orbit-free camera looking at the origin.
vec3 ro = vec3(0.0, 0.8, -3.0);
vec3 fw = normalize(-ro);
vec3 rt = normalize(cross(fw, vec3(0.0, 1.0, 0.0)));
vec3 up = cross(rt, fw);
vec3 rd = normalize(uv.x * rt + uv.y * up + 1.5 * fw);
// Sphere tracing: step sizes come from the field itself.
float t = 0.0;
bool hit = false;
for (int i = 0; i < 96; i++) {
float d = map(ro + rd * t);
if (d < 0.001) { hit = true; break; }
t += d;
if (t > 20.0) break;
}
vec3 col = mix(vec3(0.96, 0.97, 0.98),
vec3(0.85, 0.92, 0.95), uv.y); // sky gradient
if (hit) {
vec3 p = ro + rd * t;
vec3 n = calcNormal(p);
vec3 l = normalize(vec3(0.7, 0.9, -0.4));
float dif = max(dot(n, l), 0.0); // Lambert diffuse
float spe = pow(max(dot(normalize(l - rd), n), 0.0), 32.0);
// Cheap ambient occlusion: sample the field along the normal.
float occ = 0.0;
for (int j = 1; j <= 4; j++) occ += map(p + n * 0.12 * float(j));
occ = clamp(occ, 0.0, 1.0);
col = vec3(0.88, 0.90, 0.93) * (0.45 + 0.55 * dif) * occ + spe * 0.5;
col = mix(col, vec3(0.93, 0.96, 0.98),
1.0 - exp(-0.02 * t * t)); // depth fog
}
gl_FragColor = vec4(pow(col, vec3(0.4545)), 1.0);
}
Read main() top to bottom and it mirrors the article: build a camera ray, march it through map() until contact or escape, shade with gradient-derived normals, diffuse plus specular plus occlusion, then fog toward the sky. Everything else—sdSphere, sdBox, smin, calcNormal—is support machinery you will reuse in every scene you ever write. Swap the two primitives for tori and capsules, animate the light direction, and the same hundred lines carry surprisingly far.
Performance and honest limits
The economics of raymarching differ fundamentally from rasterization. Every pixel runs its own iterative loop, each iteration re-evaluating every primitive in map(). Doubling scene complexity roughly doubles per-step cost; doubling render resolution quadruples total work. Modern GPUs absorb this gracefully at moderate resolutions—which is precisely why the technique thrives in ambient contexts: slow-moving backgrounds, hero canvases, meditative focus environments rendered at modest internal resolution and upscaled.
Where it does not belong: sprawling game worlds with thousands of dynamic objects, battery-constrained mobile scenarios running full-resolution loops, or anything demanding guaranteed frame times on heterogeneous hardware. Practical mitigations, in order of value: render internally at reduced resolution; early-exit loops aggressively; simplify map() during motion; and pause rendering entirely when the canvas leaves the viewport—ambient does not mean always-on.
For culture and education, the Shadertoy community remains the deep archive—thousands of fully commented scenes demonstrating every trick in this article pushed far beyond them.
Questions people often ask
How does raymarching differ from rasterization?
Rasterization projects known triangles to the screen—fast, hardware-native, ideal for explicit geometry. Raymarching solves visibility per-pixel against implicit math—slower per pixel but capable of perfect curvature, organic blending, and effects like true volumetric fog that raster pipelines approximate painfully. They answer different questions, which is why hybrid engines exist.
How do I get anti-aliasing?
Cheapest credible option: supersample—render internally at 1.5–2× display size and downscale. Shader-side, average four jittered rays per pixel (temporal accumulation works beautifully too since scenes are deterministic functions of time). Built-in MSAA helps nothing here; the geometry is evaluated, not rasterized.
Can SDF scenes use textures?
Yes—materials become functions too. Sample a texture using hit position or UVs derived from it, or go fully procedural: stripes, marble, wood grain from noise fields evaluated at the surface point, no images shipped at all. Procedural texturing pairs naturally with the zero-asset ethos.
Where should I learn more?
Inigo Quilez’s articles catalog the complete SDF primitive and operation library referenced industry-wide. The Art of Code YouTube channel teaches these shaders line by line. Shadertoy hosts the frontier—read popular scenes’ source directly; most authors comment generously.
The takeaway
Raymarching replaces the 3D asset pipeline with a calculator: define space with distance functions, walk rays safely through it, extract normals by asking nearby questions, and shade conventionally on top. One shader, no dependencies, infinite resolution—and a direct line into the calm generative aesthetics defining modern ambient interfaces. Start with the scene above; the field rewards curiosity disproportionately.
Share this article
Link, preview card, or your favorite app
Instagram has no web share link — save the card, copy the caption, post them together.