All articles
9 min read

Anatomy of a Shader World: How SDF Raymarching Creates 3D Scenes in 30 Lines of GLSL

shadersGLSLraymarchingcreative codingwebgl

Some of the most visually striking micro-worlds on Lumitree are built entirely in a fragment shader. No geometry. No meshes. No vertices. Just a single rectangle covering the screen, with every pixel calculated mathematically. The entire 3D scene — shapes, lighting, shadows, reflections — lives in ~30 lines of GLSL.

This article breaks down exactly how that works. If you've ever been curious about shader art but found the resources too academic or too hand-wavy, this is the practical version.

The setup: one triangle, infinite worlds

A shader world starts with the simplest possible WebGL setup: a full-screen quad (two triangles), a vertex shader that passes through coordinates, and a fragment shader that does all the work. The fragment shader runs once per pixel, every frame. For a 1920×1080 display, that's ~2 million function calls per frame, 60 times per second. GPUs are built for this.

The minimal JavaScript setup looks like this:

const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
// ... compile shaders, create full-screen quad ...
function render(time) {
  gl.uniform1f(uTime, time * 0.001);
  gl.uniform2f(uRes, canvas.width, canvas.height);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

That's it for JavaScript. Everything visual happens in the fragment shader.

Signed Distance Functions: geometry as math

The core idea behind SDF raymarching is representing shapes as mathematical functions. A signed distance function takes a point in 3D space and returns the distance to the nearest surface. Negative values mean you're inside the shape. Zero means you're on the surface.

Here are the building blocks — each one is a single line of GLSL:

// Sphere: center at origin, radius r
float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

// Box: half-dimensions b
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);
}

// Torus: inner radius t.y, outer radius t.x
float sdTorus(vec3 p, vec2 t) {
  vec2 q = vec2(length(p.xz) - t.x, p.y);
  return length(q) - t.y;
}

// Infinite plane at y=0
float sdPlane(vec3 p) {
  return p.y;
}

These primitives combine through simple operations:

// Union: take the closest surface
float opUnion(float d1, float d2) {
  return min(d1, d2);
}

// Smooth union: blend shapes together
float opSmoothUnion(float d1, float d2, float k) {
  float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
  return mix(d2, d1, h) - k * h * (1.0 - h);
}

// Subtraction: carve one shape from another
float opSubtract(float d1, float d2) {
  return max(-d1, d2);
}

That smooth union function is where the magic happens. It melts shapes into each other like liquid mercury — and it's three lines of math.

The raymarching algorithm

Raymarching is how we "render" the SDF scene. For each pixel, we cast a ray from the camera into the scene. Instead of calculating ray-triangle intersections (like traditional 3D rendering), we step along the ray, asking the SDF "how far is the nearest surface?" at each step. We then advance by that distance — guaranteed to be safe because the SDF tells us the minimum distance to anything.

float raymarch(vec3 ro, vec3 rd) {
  float t = 0.0;
  for (int i = 0; i < 80; i++) {
    vec3 p = ro + rd * t;
    float d = scene(p);
    if (d < 0.001) break;  // hit surface
    t += d;
    if (t > 50.0) break;   // too far, give up
  }
  return t;
}

This is the entire rendering algorithm. 80 steps is enough for most scenes. The 0.001 threshold determines surface precision. The 50.0 cutoff prevents infinite marching into empty space.

Normals and lighting

Once we've hit a surface, we need the normal vector for lighting. With SDFs, normals come free — they're the gradient of the distance function, estimated with a simple finite difference:

vec3 calcNormal(vec3 p) {
  vec2 e = vec2(0.001, 0.0);
  return normalize(vec3(
    scene(p + e.xyy) - scene(p - e.xyy),
    scene(p + e.yxy) - scene(p - e.yxy),
    scene(p + e.yyx) - scene(p - e.yyx)
  ));
}

Six extra SDF evaluations, and we get a perfect surface normal. Lighting is then standard Phong or Blinn-Phong:

vec3 light = normalize(vec3(1.0, 2.0, -1.0));
float diff = max(dot(normal, light), 0.0);
float spec = pow(max(dot(reflect(-light, normal), -rd), 0.0), 32.0);
vec3 col = baseColor * (0.15 + 0.85 * diff) + vec3(1.0) * spec * 0.3;

Soft shadows in two lines

Hard shadows are easy: raymarch from the surface toward the light. If you hit something, it's in shadow. Soft shadows are almost as easy — track how close the ray gets to surfaces along the way:

float softShadow(vec3 ro, vec3 rd, float tmax) {
  float res = 1.0;
  float t = 0.02;
  for (int i = 0; i < 32; i++) {
    float d = scene(ro + rd * t);
    res = min(res, 8.0 * d / t);
    t += clamp(d, 0.02, 0.2);
    if (t > tmax) break;
  }
  return clamp(res, 0.0, 1.0);
}

The 8.0 factor controls shadow softness. Higher values mean sharper shadows. This technique, pioneered by Inigo Quilez, gives cinematic-quality shadows in a loop that runs in microseconds.

Putting it together: a complete scene

Here's a complete scene function that creates an animated landscape — floating spheres over an infinite plane, all melting into each other:

float scene(vec3 p) {
  float ground = p.y + 1.0;
  float s1 = sdSphere(p - vec3(sin(uTime) * 2.0, 0.5, 0.0), 0.8);
  float s2 = sdSphere(p - vec3(-1.0, sin(uTime * 0.7) + 1.0, 1.0), 0.6);
  float s3 = sdSphere(p - vec3(1.5, cos(uTime * 0.5) + 0.8, -0.5), 0.5);
  float shapes = opSmoothUnion(s1, opSmoothUnion(s2, s3, 0.5), 0.5);
  return opSmoothUnion(ground, shapes, 0.8);
}

Three spheres, an infinite plane, all smoothly blended together, animated with time. The smooth union with the ground makes the spheres look like they're emerging from a liquid surface. The entire scene definition is six lines.

Domain repetition: infinite worlds from finite code

One of the most powerful SDF tricks is domain repetition. By wrapping the input coordinates with mod(), you repeat the entire scene infinitely in any direction:

// Repeat space every 4 units on x and z
vec3 q = p;
q.xz = mod(p.xz + 2.0, 4.0) - 2.0;
float d = sdSphere(q, 0.5);

Three lines, and you've turned a single sphere into an infinite grid of spheres stretching to the horizon. Combine this with varying the sphere radius based on the original position, and you get organic-looking crystal fields, alien cities, or underwater coral formations.

Color without textures

Shader worlds don't use textures — everything is procedural. Color comes from the geometry itself: the surface position, the normal direction, the distance traveled, or combinations of all three:

// Color based on surface normal (iridescent effect)
vec3 col = 0.5 + 0.5 * cos(6.28 * (normal * 0.5 + 0.5) + vec3(0, 2, 4));

// Color based on height (gradient landscape)
vec3 col = mix(vec3(0.1, 0.3, 0.2), vec3(0.9, 0.95, 1.0), smoothstep(-1.0, 3.0, p.y));

// Color based on distance (atmospheric fog)
col = mix(col, vec3(0.05, 0.05, 0.1), 1.0 - exp(-0.04 * t));

That last line — atmospheric fog — is what makes shader worlds feel expansive. Objects fade into a background color with distance, creating depth without any camera tricks.

Why this fits in 50KB

A complete shader world on Lumitree — HTML boilerplate, WebGL setup, vertex shader, fragment shader with a multi-object animated scene, lighting, shadows, and procedural color — typically weighs between 3KB and 8KB. The rest of the 50KB budget is headroom that's rarely needed.

Compare that to importing Three.js (700KB+ minified) to render a few spheres. The SDF approach isn't just smaller — it's fundamentally different. There's no scene graph, no geometry buffers, no draw calls. Just math, running directly on the GPU.

Learning resources

If you want to go deeper into shader art:

  • Shadertoy — the definitive playground for fragment shader experiments. Thousands of examples with source code.
  • Inigo Quilez's articles — the person who popularized SDF raymarching has written the best technical references on distance functions, soft shadows, and procedural techniques.
  • The Book of Shaders — a gentle introduction to fragment shaders, starting from the absolute basics.

Or you can browse the shader worlds on Lumitree's explore page — each one is a live example of these techniques in action, running in your browser right now.