Learn Creative Coding (#38) - Raymarching: 3D Worlds from Math

in StemSocial26 days ago

Learn Creative Coding (#38) - Raymarching: 3D Worlds from Math

cc-banner

Everything we've done in shaders so far has been flat. Beautiful, sure -- noise landscapes, feedback spirals, color palettes, signed distance shapes. But flat. Two dimensions. The fragment shader takes a 2D pixel coordinate and outputs a color. That's the contract.

Today we break that contract. We're building 3D scenes inside a fragment shader. No vertices, no triangles, no mesh data, no 3D engine. Just math. Pure math. For every pixel on screen, we cast a ray from a virtual camera into a virtual world, and we figure out what it hits using the same distance functions we learned in episode 33. Except now they're in three dimensions.

The technique is called raymarching, and when it clicks -- when you realize that a sphere, a floor, shadows, lighting, an entire 3D scene is coming from maybe 40 lines of GLSL -- it's one of those moments where your brain rearranges itself a little. At least mine did :-)

The idea: marching along a ray

Traditional 3D rendering works by projecting triangles onto the screen. You have mesh data -- thousands of vertices defining the surface of objects -- and the GPU figures out which pixels each triangle covers. It's fast, it's how games work, it's what WebGL's vertex pipeline is built for.

Raymarching works backwards. Instead of projecting geometry onto pixels, we start at the pixel and ask: "if I shoot a ray from the camera through this pixel, what does it hit?"

Here's the procedure for each pixel:

  1. Compute a ray direction based on the pixel's position
  2. Start at the camera position
  3. Ask the scene: "how far is the closest surface from here?"
  4. Step forward by that distance
  5. Ask again: "how far now?"
  6. Step forward again
  7. Repeat until either we're very close to a surface (we hit something) or we've gone too far (nothing there)

That "how far is the closest surface" question? That's a signed distance function. The same SDF concept from episode 33 -- sdCircle, sdBox, the boolean operations, the smooth minimum. Except now in 3D. sdSphere, sdBox3D, and so on.

And the reason we step by the distance to the nearest surface rather than by fixed increments is the clever bit. If we're far from any surface, the distance is large, so we take a big step -- covering ground fast. If we're close to a surface, the distance is small, so we take tiny steps -- homing in precisely on the intersection point. This is called sphere tracing, and it's what makes raymarching practical. You converge on surfaces quickly without ever overshooting.

Setting up the camera

Before we can march rays, we need to generate them. For every pixel we need two things: where the ray starts (the camera position) and which direction it goes.

precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;

void main() {
  // normalize pixel coordinates to -1..1 range, aspect-corrected
  vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;

  // camera
  vec3 ro = vec3(0.0, 1.0, -3.0);  // ray origin (camera position)
  vec3 rd = normalize(vec3(uv, 1.0));  // ray direction

  gl_FragColor = vec4(rd * 0.5 + 0.5, 1.0);
}

ro is the ray origin -- where the camera sits in 3D space. Here it's at (0, 1, -3), so slightly above the ground plane and pulled back on the Z axis.

rd is the ray direction. We take the 2D pixel coordinate uv and extend it into 3D by adding a Z component of 1.0. The normalize makes it a unit vector. This is the simplest possible perspective camera -- the Z component acts as the focal length. Larger Z values make a narrower field of view (more telephoto), smaller values make a wider field of view (more fisheye). Try changing that 1.0 to 0.5 or 2.0 and you'll see.

If you run this, you'll see a smooth gradient -- the ray directions visualized as colors. Center of screen is (0, 0, 1) which maps to (0.5, 0.5, 1.0) after the * 0.5 + 0.5 -- a nice blue. The corners bend toward the edges of the direction space. Not exciting yet, but this is the foundation everything else builds on.

The sphere SDF in 3D

In episode 33 we wrote sdCircle(vec2 p, float r) which returned length(p) - r. A sphere in 3D is the exact same thing with vec3:

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

That's it. The distance from any point in 3D space to the surface of a sphere centered at the origin. Positive means outside, negative means inside, zero means exactly on the surface. The concept transfers directly from 2D -- we just added a dimension.

To place the sphere somewhere other than the origin, subtract the center position before measuring:

float d = sdSphere(p - vec3(0.0, 1.0, 0.0), 0.5);

A sphere of radius 0.5 centered at (0, 1, 0). Same pattern as 2D -- translate by moving the point, not the shape.

The ground plane

A flat ground plane is the simplest possible SDF. The distance from any point to a horizontal plane at height h is just:

float sdPlane(vec3 p, float h) {
  return p.y - h;
}

If the point is above the plane, p.y - h is positive (outside). Below the plane, negative (inside). On the plane, zero. One subtraction. Can't get simpler than that.

The scene function

We combine our primitives into a single scene function that returns the distance to the nearest surface. Just like episode 33's 2D boolean operations -- min() for union:

float scene(vec3 p) {
  float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
  float ground = sdPlane(p, 0.0);
  return min(sphere, ground);
}

A sphere floating above a ground plane. min() means the raymarcher will stop at whichever surface is closest -- the sphere's surface or the ground's surface. Union. Same principle as 2D, just in 3D now.

The raymarching loop

Here's the core algorithm. For each pixel, march along the ray direction, sampling the scene distance at each step:

float raymarch(vec3 ro, vec3 rd) {
  float t = 0.0;

  for (int i = 0; i < 80; i++) {
    vec3 p = ro + rd * t;         // current position along the ray
    float d = scene(p);           // distance to nearest surface

    if (d < 0.001) break;         // close enough -- we hit something
    if (t > 100.0) break;         // too far -- nothing there

    t += d;                       // step forward by the distance
  }

  return t;
}

t accumulates the total distance traveled along the ray. Each iteration, we compute our current position (ro + rd * t), ask the scene how far away the nearest surface is, and step forward by that amount. If the distance drops below 0.001 (our precision threshold), we've effectively hit a surface. If t exceeds 100.0, we've marched into empty space and give up.

The 80 iteration limit is a practical safeguard. Most rays converge in 20-40 steps. Some edge-grazing rays where the ray barely skims a surface can take more. 80 is generous enough for most scenes. You can crank it to 200 for complex geometry, but 80 is a good starting point.

Why 0.001 and not 0.0? Floating point precision. On the GPU, you'll never get exactly zero -- there's always some rounding error. 0.001 is close enough that you can't see the difference visually, but forgiving enough that the loop actually terminates. Some people use 0.0001 for higher precision, or scale the threshold with distance (0.001 * t) so far-away surfaces don't need sub-pixel accuracy.

Putting it together: our first 3D render

Let's combine the camera, the scene, and the raymarcher. No lighting yet -- just distance-based shading so we can see the shapes:

precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdPlane(vec3 p, float h) {
  return p.y - h;
}

float scene(vec3 p) {
  float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
  float ground = sdPlane(p, 0.0);
  return min(sphere, ground);
}

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;
    if (t > 100.0) break;
    t += d;
  }
  return t;
}

void main() {
  vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;

  vec3 ro = vec3(0.0, 2.0, -4.0);
  vec3 rd = normalize(vec3(uv, 1.0));

  float t = raymarch(ro, rd);

  // simple depth-based shading
  vec3 color = vec3(0.0);
  if (t < 100.0) {
    color = vec3(1.0 - t * 0.1);
  }

  gl_FragColor = vec4(color, 1.0);
}

Run this and you should see a white sphere sitting on a ground plane, both fading to dark with distance. No lighting, no shadows, no color -- just "did the ray hit something, and how far away was it?" But it's 3D. An actual 3D scene rendered from pure math in a fragment shader. No mesh data anywhere. No vertex buffer. No triangle. Just distance functions.

If you see the sphere and the ground, congratulations -- you just rendered your first raymarched scene. Take a moment with that. We went from "here are some 2D circles" in episode 33 to "here's a 3D world" in one episode. The SDF concept didn't change at all -- it just gained a dimension.

Computing surface normals

Depth shading is fine for debugging but it looks flat. For real lighting we need surface normals -- the direction each surface point faces. In traditional 3D, normals come from the mesh data (each vertex stores its normal). In raymarching, we compute them from the distance field itself.

The insight: the gradient of a distance field points away from the surface. And the gradient is just "which direction does the distance increase fastest." We can approximate it by sampling the SDF at six points around our hit position -- a tiny nudge in each axis direction:

vec3 getNormal(vec3 p) {
  float e = 0.001;
  return normalize(vec3(
    scene(p + vec3(e, 0.0, 0.0)) - scene(p - vec3(e, 0.0, 0.0)),
    scene(p + vec3(0.0, e, 0.0)) - scene(p - vec3(0.0, e, 0.0)),
    scene(p + vec3(0.0, 0.0, e)) - scene(p - vec3(0.0, 0.0, e))
  ));
}

For each axis (x, y, z), we sample the distance slightly in both directions and take the difference. That gives us the partial derivative in each axis. Together, those three partial derivatives form the gradient vector, which IS the normal. normalize makes it unit length.

The e value (epsilon) controls the sampling distance. Too large and you get inaccurate normals (rounded corners, blurry details). Too small and floating point noise dominates. 0.001 is the standard choice -- matches our raymarching precision threshold.

This is six extra SDF evaluations per hit pixel. Sounds expensive, but remember -- the miss pixels (background) don't need normals. And for the hit pixels, six extra evaluations out of the 20-40 already done in the raymarching loop is a modest overhead.

Lambertian diffuse lighting

With normals in hand, the simplest realistic lighting model is Lambertian diffuse. The brightness of a surface depends on the angle between the surface normal and the light direction. Facing the light = bright. Facing away = dark. The math is a single dot product:

float diffuse = max(dot(normal, lightDir), 0.0);

dot(normal, lightDir) gives the cosine of the angle between them. When they point the same way (surface faces the light), the dot product is 1.0 -- full brightness. When perpendicular, 0.0 -- no light. When facing away, negative -- the max(..., 0.0) clamps it so surfaces facing away from the light don't go negative.

This is Lambert's cosine law from 1760. Two hundred sixty years old and still the foundation of every lighting model in computer graphics. It works because a surface tilted away from the light intercepts less light per unit area -- the cosine of the tilt angle captures exactly that relationship.

The complete minimal raymarcher

Let's put everything together. Camera, scene, raymarching, normals, diffuse lighting with ambient:

precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdPlane(vec3 p, float h) {
  return p.y - h;
}

float scene(vec3 p) {
  float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0);
  float ground = sdPlane(p, 0.0);
  return min(sphere, ground);
}

vec3 getNormal(vec3 p) {
  float e = 0.001;
  return normalize(vec3(
    scene(p + vec3(e, 0, 0)) - scene(p - vec3(e, 0, 0)),
    scene(p + vec3(0, e, 0)) - scene(p - vec3(0, e, 0)),
    scene(p + vec3(0, 0, e)) - scene(p - vec3(0, 0, e))
  ));
}

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;
    if (t > 100.0) break;
    t += d;
  }
  return t;
}

void main() {
  vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;

  vec3 ro = vec3(0.0, 2.0, -4.0);
  vec3 rd = normalize(vec3(uv, 1.0));

  float t = raymarch(ro, rd);

  vec3 color = vec3(0.05, 0.05, 0.1);  // background

  if (t < 100.0) {
    vec3 p = ro + rd * t;
    vec3 n = getNormal(p);

    // directional light from upper-right
    vec3 lightDir = normalize(vec3(1.0, 1.0, -0.5));
    float diff = max(dot(n, lightDir), 0.0);

    // ambient so shadows aren't pure black
    float ambient = 0.15;

    color = vec3(0.8, 0.7, 0.6) * (diff + ambient);
  }

  gl_FragColor = vec4(color, 1.0);
}

There it is. A lit 3D scene. A sphere sitting on a ground plane, lit from the upper right, with a subtle dark blue background where rays miss everything. The sphere has visible shading -- bright on the side facing the light, dark on the opposite side. The ground plane catches light at an angle, so it's dimmer than the top of the sphere but still visible.

This entire 3D renderer is about 50 lines of GLSL. No external libraries. No mesh data. No texture uploads. Just math running on the GPU. The same GPU architecture that handles the noise and feedback effects we've been building -- but now it's rendering a 3D world.

The ambient term (0.15) prevents pure black shadows. In the real world, light bounces off surfaces and fills in shadows indirectly. We're faking that with a constant. It's crude but effective -- without it, the back half of the sphere would be completely invisible, which looks wrong. Real ambient occlusion is much more sophisticated (we'll get there eventually), but a constant ambient term is what 90% of Shadertoy shaders use and it works fine.

Adding color and multiple objects

A white sphere on a white floor is fine for proving the concept, but let's make it interesting. Multiple spheres, each with its own color. To do this, we need the scene function to tell us not just the distance, but WHICH object we hit. We can encode this as a material ID:

precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdPlane(vec3 p, float h) {
  return p.y - h;
}

vec2 scene(vec3 p) {
  // returns vec2(distance, materialID)
  vec2 ground = vec2(sdPlane(p, 0.0), 0.0);

  vec2 sphere1 = vec2(
    sdSphere(p - vec3(-1.5, 0.8, 2.0), 0.8),
    1.0
  );

  vec2 sphere2 = vec2(
    sdSphere(p - vec3(0.5, 0.6, 1.0), 0.6),
    2.0
  );

  vec2 sphere3 = vec2(
    sdSphere(p - vec3(2.0, 1.0, 3.0), 1.0),
    3.0
  );

  // union: pick closest (smallest distance)
  vec2 res = ground;
  if (sphere1.x < res.x) res = sphere1;
  if (sphere2.x < res.x) res = sphere2;
  if (sphere3.x < res.x) res = sphere3;

  return res;
}

vec3 getNormal(vec3 p) {
  float e = 0.001;
  return normalize(vec3(
    scene(p + vec3(e, 0, 0)).x - scene(p - vec3(e, 0, 0)).x,
    scene(p + vec3(0, e, 0)).x - scene(p - vec3(0, e, 0)).x,
    scene(p + vec3(0, 0, e)).x - scene(p - vec3(0, 0, e)).x
  ));
}

vec2 raymarch(vec3 ro, vec3 rd) {
  float t = 0.0;
  float matID = -1.0;
  for (int i = 0; i < 80; i++) {
    vec3 p = ro + rd * t;
    vec2 res = scene(p);
    if (res.x < 0.001) {
      matID = res.y;
      break;
    }
    if (t > 100.0) break;
    t += res.x;
  }
  return vec2(t, matID);
}

vec3 getMaterial(float id) {
  if (id < 0.5) return vec3(0.4, 0.4, 0.35);   // ground: grey
  if (id < 1.5) return vec3(0.9, 0.2, 0.2);     // sphere 1: red
  if (id < 2.5) return vec3(0.2, 0.7, 0.3);     // sphere 2: green
  return vec3(0.3, 0.4, 0.9);                     // sphere 3: blue
}

void main() {
  vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;

  vec3 ro = vec3(0.0, 2.5, -5.0);
  vec3 rd = normalize(vec3(uv, 1.2));

  vec2 res = raymarch(ro, rd);
  float t = res.x;
  float matID = res.y;

  vec3 color = vec3(0.05, 0.05, 0.12);  // background

  if (t < 100.0) {
    vec3 p = ro + rd * t;
    vec3 n = getNormal(p);

    vec3 lightDir = normalize(vec3(1.0, 1.2, -0.8));
    float diff = max(dot(n, lightDir), 0.0);
    float ambient = 0.12;

    vec3 mat = getMaterial(matID);
    color = mat * (diff + ambient);
  }

  gl_FragColor = vec4(color, 1.0);
}

Three colored spheres on a grey ground plane. Red, green, blue -- each at a different position and size. The scene function now returns a vec2 instead of a float: the x component is the distance (same as before), the y component is a material ID that tells us which object was closest. When the raymarcher finds a hit, it records the material ID. Then in the shading step, getMaterial maps each ID to an RGB color.

The if/else chain for material selection uses < 0.5, < 1.5, < 2.5 instead of exact equality because floating point. Comparing id == 1.0 might fail due to rounding. Comparing id < 1.5 is robust. Standard trick for integer-ish values in GLSL.

This is a pattern you'll use in every raymarched scene. The scene function defines geometry AND material assignment. The shading step reads the material ID and applies the right color. Clean separation between "what shape is it" and "what does it look like."

Animating the scene

Static scenes are for screenshots. Let's make stuff move. The camera can orbit, spheres can bounce, everything can breathe:

precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdPlane(vec3 p, float h) {
  return p.y - h;
}

float scene(vec3 p) {
  float ground = sdPlane(p, 0.0);

  // bouncing sphere
  float bounce = abs(sin(u_time * 2.0)) * 1.5;
  float sphere = sdSphere(p - vec3(0.0, 0.8 + bounce, 0.0), 0.8);

  return min(sphere, ground);
}

vec3 getNormal(vec3 p) {
  float e = 0.001;
  return normalize(vec3(
    scene(p + vec3(e, 0, 0)) - scene(p - vec3(e, 0, 0)),
    scene(p + vec3(0, e, 0)) - scene(p - vec3(0, e, 0)),
    scene(p + vec3(0, 0, e)) - scene(p - vec3(0, 0, e))
  ));
}

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;
    if (t > 100.0) break;
    t += d;
  }
  return t;
}

void main() {
  vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;

  // orbiting camera
  float angle = u_time * 0.3;
  vec3 ro = vec3(sin(angle) * 5.0, 3.0, cos(angle) * 5.0);

  // look-at: point camera toward origin
  vec3 target = vec3(0.0, 1.0, 0.0);
  vec3 forward = normalize(target - ro);
  vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward));
  vec3 up = cross(forward, right);
  vec3 rd = normalize(uv.x * right + uv.y * up + 1.2 * forward);

  float t = raymarch(ro, rd);

  vec3 color = vec3(0.05, 0.05, 0.1);

  if (t < 100.0) {
    vec3 p = ro + rd * t;
    vec3 n = getNormal(p);

    vec3 lightDir = normalize(vec3(1.0, 1.0, -0.5));
    float diff = max(dot(n, lightDir), 0.0);

    // ground vs sphere coloring
    vec3 mat = p.y < 0.01 ?
      vec3(0.3 + 0.2 * mod(floor(p.x) + floor(p.z), 2.0)) :
      vec3(0.8, 0.3, 0.25);

    color = mat * (diff + 0.15);
  }

  gl_FragColor = vec4(color, 1.0);
}

Two new things here. First, the sphere bounces using abs(sin(u_time * 2.0)) -- the absolute value of sine gives a repeating bounce pattern, always positive, like a ball on a trampoline. Second, the camera orbits around the origin using sin and cos on the angle, which increases with time.

The camera setup is more involved now. Instead of the simple normalize(vec3(uv, 1.0)) we used earlier, we build a proper look-at camera. forward points from the camera toward the target. right is perpendicular to both forward and world-up (computed via cross product). up is perpendicular to both forward and right. Then the ray direction combines the pixel's UV offset (right and up components) with the forward direction. This is the standard look-at camera from every 3D graphics textbook -- it lets the camera orbit freely without distorting the view.

The ground has a checkerboard pattern. mod(floor(p.x) + floor(p.z), 2.0) alternates between 0 and 1 at integer positions, creating a checkered grid. Classic floor pattern. We know which surface we hit by checking p.y < 0.01 -- if the hit point is near y=0, it's the ground. Otherwise it's the sphere. This is a quick hack that works for simple scenes. For complex scenes, use the material ID approach from the previous example.

Raymarching vs rasterization: what just happened

It's worth stepping back and appreciating how weird this is from a traditional 3D graphics perspective.

In normal 3D rendering (OpenGL, DirectX, game engines), you describe geometry as meshes. A sphere is hundreds of triangles arranged in a ball shape. A ground plane is two triangles forming a quad. The GPU's vertex shader transforms each triangle's corners into screen space, and the fragment shader colors the interior of each triangle. More triangles = smoother surfaces, but also more data to process.

We just rendered a perfect sphere. Not an approximation made of 500 triangles -- a mathematically perfect sphere. Smooth from any angle, any distance. Zero mesh data. The sphere exists because the function length(p) - r exists. The ground plane exists because p.y exists. The entire scene is defined by two arithmetic expressions.

This also means we can do things that meshes make difficult. Want a sphere that morphs into a cube? Write a function that interpolates between the sphere SDF and the cube SDF. Want a surface that ripples based on noise? Add noise to the distance value. Want infinate repetition of a shape? mod the position before evaluting the SDF. Things that would require generating new mesh data on the fly -- potentially thousands of triangles per frame -- are just parameter tweaks in a raymarcher.

The tradeoff is performance. Raymarching evaluates the scene distance function potentially hundreds of times per pixel. For complex scenes with many objects and detailed distance functions, this gets expensive. Rasterization scales well with screen resolution (it only processes visible triangles). Raymarching scales with scene complexity (every pixel marches through the whole scene). That's why games use rasterization -- they have millions of triangles and need 60fps at 4K. But for creative coding, shader art, and mathematical exploration, raymarching is unbeatable.

Creative exercise: your first raymarched scene

Build a scene with three spheres at different positions. Give each one a different color using material IDs. Add a directional light and ambient term. Optional challenge: make one sphere orbit around another using sin(u_time) and cos(u_time).

Here's a starting template that combines everthing from this episode:

precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdPlane(vec3 p, float h) {
  return p.y - h;
}

vec2 scene(vec3 p) {
  vec2 ground = vec2(sdPlane(p, 0.0), 0.0);

  // your spheres here -- try different positions, sizes
  float t = u_time * 0.8;
  vec2 s1 = vec2(
    sdSphere(p - vec3(0.0, 1.0, 0.0), 1.0),
    1.0
  );

  // orbiting sphere
  vec2 s2 = vec2(
    sdSphere(p - vec3(sin(t) * 2.5, 0.5, cos(t) * 2.5), 0.5),
    2.0
  );

  vec2 s3 = vec2(
    sdSphere(p - vec3(-2.0, 0.7, 1.5), 0.7),
    3.0
  );

  vec2 res = ground;
  if (s1.x < res.x) res = s1;
  if (s2.x < res.x) res = s2;
  if (s3.x < res.x) res = s3;
  return res;
}

vec3 getNormal(vec3 p) {
  float e = 0.001;
  return normalize(vec3(
    scene(p + vec3(e, 0, 0)).x - scene(p - vec3(e, 0, 0)).x,
    scene(p + vec3(0, e, 0)).x - scene(p - vec3(0, e, 0)).x,
    scene(p + vec3(0, 0, e)).x - scene(p - vec3(0, 0, e)).x
  ));
}

vec2 raymarch(vec3 ro, vec3 rd) {
  float t = 0.0;
  float id = -1.0;
  for (int i = 0; i < 80; i++) {
    vec3 p = ro + rd * t;
    vec2 res = scene(p);
    if (res.x < 0.001) { id = res.y; break; }
    if (t > 100.0) break;
    t += res.x;
  }
  return vec2(t, id);
}

void main() {
  vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;

  float camAngle = u_time * 0.2;
  vec3 ro = vec3(sin(camAngle) * 6.0, 3.5, cos(camAngle) * 6.0);
  vec3 target = vec3(0.0, 0.8, 0.0);
  vec3 fwd = normalize(target - ro);
  vec3 rgt = normalize(cross(vec3(0, 1, 0), fwd));
  vec3 up2 = cross(fwd, rgt);
  vec3 rd = normalize(uv.x * rgt + uv.y * up2 + 1.2 * fwd);

  vec2 hit = raymarch(ro, rd);

  vec3 color = vec3(0.06, 0.06, 0.12);

  if (hit.x < 100.0) {
    vec3 p = ro + rd * hit.x;
    vec3 n = getNormal(p);
    vec3 ld = normalize(vec3(0.8, 1.0, -0.6));
    float diff = max(dot(n, ld), 0.0);

    vec3 mat;
    if (hit.y < 0.5) {
      // ground: checkerboard
      float checker = mod(floor(p.x) + floor(p.z), 2.0);
      mat = mix(vec3(0.25, 0.25, 0.2), vec3(0.4, 0.4, 0.35), checker);
    } else if (hit.y < 1.5) {
      mat = vec3(0.85, 0.25, 0.2);  // red
    } else if (hit.y < 2.5) {
      mat = vec3(0.2, 0.75, 0.4);   // green
    } else {
      mat = vec3(0.25, 0.35, 0.85); // blue
    }

    color = mat * (diff + 0.12);
  }

  // subtle fog
  color = mix(color, vec3(0.06, 0.06, 0.12), 1.0 - exp(-0.03 * hit.x));

  gl_FragColor = vec4(color, 1.0);
}

Camera orbits slowly. Three colored spheres -- one static, one orbiting, one just sitting there. Checkerboard ground. Directional light. A subtle distance fog that blends far-away surfaces into the background color (the exp(-0.03 * hit.x) term -- exponential decay with distance). Fog is a one-liner that adds a surprising amount of depth to the scene. Things far away fade out. Things close are crisp. Your brain reads this as atmosphere and instantly the scene feels more three-dimensional.

Play with it. Move the spheres. Change the colors. Make all three orbit at different speeds. Add more spheres. Change the ground plane height. Mess with the fog density. This is your sandbox now and the only limits are the math you can dream up.

Performance notes

Raymarching performance depends on two things: how many steps each ray needs and how expensive the scene function is.

For our simple scenes with a few spheres and a plane, performance is excellent. Each sdSphere is a length call (one square root) and a subtraction. The total scene is three or four of those plus a min. Even at 80 iterations maximum, a modern GPU handles this at 60fps without breaking a sweat.

Where it gets expensive: complex SDF combinaitons (smooth unions, complex boolean operations), high iteration counts (200+), and narrow features that require many tiny steps. A scene with hundreds of objects, each with smooth min operations, can start lagging on less powerful hardware. But for learning and creative exploration, you're nowhere near those limits.

One optimization worth knowing: you can reduce the iteration count for rays that clearly won't hit anything (pointing at the sky, for example). Some people skip raymarching entirely for rays above the horizon when the scene is all ground-level. But again -- premature optimization for where we are. Write the clean version first. Optimize if your framerate drops.

Where this leads

We've covered the foundation today -- sphere tracing, camera setup, normal computation, basic diffuse lighting, material IDs, animation. This is the minimal viable raymarcher. It works, it looks decent, and you understand every line.

The distance function toolkit goes much further. In 2D we had circles, boxes, lines, and boolean operations. In 3D we have spheres, boxes, cylinders, torus, cones, capsules -- and those same boolean operations (union, intersection, subtraction) plus the smooth minimum for organic blending. Combining these primitives lets you model surprisingly complex shapes without ever touching a polygon. We'll explore that vocabulary in the next episodes.

And then lighting gets much richer. Specular highlights (shiny reflections), shadows (march a second ray toward the light to check if something blocks it), ambient occlusion (darken crevices and tight spaces), reflections (bounce the ray off the surface and march again). Each technique builds on what we did today -- it's all just "evaluate the scene function at some point" applied in different ways.

For now though, sit with this. A 3D world from pure math. No mesh. No model file. No 3D engine. Just a distance function, a loop, and a dot product. That's raymarching :-)

't Komt erop neer...

  • Raymarching renders 3D scenes by casting rays from the camera and stepping along them until they hit a surface
  • Sphere tracing: step by the distance to the nearest surface (from the SDF), so big steps in open space and tiny steps near surfaces
  • A sphere SDF in 3D is length(p) - r -- same as 2D circles, just with vec3 instead of vec2
  • A ground plane SDF is just p.y - height -- one subtraction
  • Combine shapes with min() (union), same boolean operations as 2D SDFs
  • Surface normals come from the gradient of the distance field -- sample the SDF at six surrounding points and take differences
  • Lambertian diffuse lighting: max(dot(normal, lightDir), 0.0) -- a single dot product gives realistic shading
  • Material IDs: return vec2(distance, id) from the scene function to identify which object was hit
  • Look-at camera: build right/up/forward vectors to let the camera orbit freely
  • The entire 3D renderer is ~50 lines of GLSL. No mesh data, no vertex buffers, no triangles -- just math

Sallukes! Thanks for reading.

X

@femdev