Shading Raymarched Scenes

Lighting, shadows, and atmosphere

A raymarched scene without shading is like a sculpture in complete darkness. You can find the surfaces, but you cannot see them. Shading transforms raw geometry into something that feels real, with depth, weight, and presence.

In this chapter, we bring light to our raymarched worlds.

Computing Normals from SDFs

Before we can light a surface, we need to know which way it faces. This direction is called the surface normal, a unit vector pointing outward from the surface at each point.

For explicit geometry like triangles, normals come from the vertex data. For SDFs, we compute them on the fly using a beautiful mathematical trick: the gradient of the distance function.

The gradient of a function points in the direction of steepest increase. For a signed distance function, that direction is perpendicular to the surface, exactly what we need.

We approximate the gradient by sampling the SDF at six nearby points:

vec3 getNormal(vec3 p) {
  float eps = 0.001;
  return normalize(vec3(
    sceneSDF(p + vec3(eps, 0, 0)) - sceneSDF(p - vec3(eps, 0, 0)),
    sceneSDF(p + vec3(0, eps, 0)) - sceneSDF(p - vec3(0, eps, 0)),
    sceneSDF(p + vec3(0, 0, eps)) - sceneSDF(p - vec3(0, 0, eps))
  ));
}
glsl

Each component measures how the distance changes along one axis. Together, they form a vector that points away from the surface.

Interactive: Surface Normals Visualization

Normals mapped to RGB: red is +X, green is +Y, blue is +Z

Notice how the colors reveal the surface orientation. The top of the sphere is green because its normal points upward (positive Y). The sides blend between red and blue as the normal rotates around the horizontal axes.

Diffuse Lighting

Diffuse lighting models how matte surfaces reflect light. Think of chalk, paper, or unpolished stone. Light hitting these surfaces scatters in all directions, and the brightness depends only on the angle between the surface and the light.

The math is elegant. We take the dot product between the surface normal and the direction to the light:

vec3 lightDir = normalize(lightPos - surfacePoint);
float diffuse = max(dot(normal, lightDir), 0.0);
glsl

When the surface faces the light directly, the dot product is 1 (full brightness). When the surface faces away, the dot product is 0 or negative (darkness). The max clamps negative values since surfaces facing away from the light should not glow.

Interactive: Diffuse Lighting

Drag the sliders to move the light source

Move the light and watch how the brightness shifts across the surface. The gradual falloff from lit to unlit areas gives the sphere its sense of roundness.

Specular Highlights

Shiny surfaces reflect light differently. Instead of scattering uniformly, they bounce light in a preferred direction, creating bright highlights where the reflection angle aligns with our view.

The Blinn-Phong model captures this with a halfway vector between the light direction and view direction:

vec3 viewDir = normalize(cameraPos - surfacePoint);
vec3 halfDir = normalize(lightDir + viewDir);
float specular = pow(max(dot(normal, halfDir), 0.0), shininess);
glsl

The shininess exponent controls how tight the highlight appears. Low values create broad, soft highlights like brushed metal. High values create tight, focused spots like polished chrome.

Interactive: Specular Highlights

Higher shininess creates tighter, more focused highlights

Specular highlights are what make materials feel metallic or wet. Without them, everything looks dusty and matte.

Soft Shadows

Shadows ground objects in their environment. Without shadows, objects float disconnected from the world around them.

The simplest shadow technique is to raymarch from the surface point toward the light. If we hit something before reaching the light, the point is in shadow. But this creates hard, sharp-edged shadows that look artificial.

Real shadows have soft edges called penumbrae, caused by light sources having physical size. We can approximate this by tracking how close our shadow ray comes to occluding geometry:

float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
  float res = 1.0;
  float t = mint;
  for (int i = 0; i < 64; i++) {
    float h = sceneSDF(ro + rd * t);
    res = min(res, k * h / t);
    t += clamp(h, 0.02, 0.1);
    if (h < 0.001 || t > maxt) break;
  }
  return clamp(res, 0.0, 1.0);
}
glsl

The key insight is k * h / t. Close passes at large distances create softer shadows than close passes nearby. The k parameter controls overall softness: larger values produce harder shadows.

Interactive: Soft Shadows

Higher k values create harder shadow edges

Toggle between hard and soft shadows to see the difference. Soft shadows feel more natural, more grounded in physical reality.

Ambient Occlusion

Some areas receive less light simply because of their geometry. Corners, crevices, and the undersides of objects are naturally darker because light has trouble reaching them.

Ambient occlusion approximates this by sampling the scene around each point. We step outward along the normal and check if the SDF grows as expected. If nearby geometry blocks these samples, the point is occluded:

float ambientOcclusion(vec3 p, vec3 n) {
  float occ = 0.0;
  float scale = 1.0;
  for (int i = 0; i < 5; i++) {
    float h = 0.01 + 0.12 * float(i);
    float d = sceneSDF(p + n * h);
    occ += (h - d) * scale;
    scale *= 0.95;
  }
  return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}
glsl

At each step, we compare the expected distance h to the actual SDF value d. If d is smaller than h, something is blocking the path, contributing to occlusion.

Interactive: Ambient Occlusion

Ambient occlusion darkens corners and crevices where light cannot easily reach

Ambient occlusion adds contact shadows and depth to corners. It makes objects feel like they belong in space rather than floating on top of it.

Atmospheric Fog

Distance creates atmosphere. Objects far away appear faded, desaturated, shifted toward the color of the sky. This is atmospheric perspective, and we simulate it with fog.

The simplest fog model blends the surface color toward a background color based on distance:

float fogAmount = 1.0 - exp(-fogDensity * distance);
vec3 finalColor = mix(surfaceColor, fogColor, fogAmount);
glsl

The exponential falloff creates a natural progression: nearby objects are crisp, distant objects fade gradually. Adjusting the fog density controls how quickly this transition happens.

Interactive: Atmospheric Fog

Objects fade into the background as distance increases

Fog does more than add realism. It creates depth cues, draws attention to foreground elements, and establishes mood. A dense fog feels mysterious; a light haze feels open and vast.

Putting It All Together

Each lighting technique contributes something essential. Diffuse lighting reveals form. Specular highlights suggest material. Shadows provide grounding. Ambient occlusion adds contact and depth. Fog creates atmosphere.

Combined, they transform mathematical shapes into convincing scenes:

Interactive: Full Lighting Controls

Toggle each lighting feature to see how they contribute to the final image

Toggle each feature on and off to see its contribution. Notice how flat the scene looks without shadows, how artificial without ambient occlusion, how disconnected without fog.

The magic is not in any single technique but in how they work together. A simple sphere with proper shading is more convincing than an elaborate model lit poorly.

Key Takeaways

  • Surface normals come from the gradient of the SDF, computed by finite differences
  • Diffuse lighting uses the dot product between normal and light direction
  • Specular highlights use a halfway vector with a shininess exponent
  • Soft shadows track near-misses during the shadow ray march
  • Ambient occlusion samples the SDF along the normal to detect enclosure
  • Fog blends surface color toward background based on exponential distance falloff
  • Each technique adds a layer of realism; together they create convincing scenes