Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Shadow Mapping

Live Demos: Shadows | Spotlight Shadows

Nightshade uses cascaded shadow mapping for directional lights and a single shadow atlas for every spotlight in the scene.

How shadow mapping works

Shadow mapping is two passes. The first pass renders the scene from the light's point of view into a depth-only texture, the shadow map. The second pass is the regular geometry pass. Every fragment projects itself into the light's coordinate space and compares its depth against the value stored in the shadow map. If the fragment is farther from the light than the shadow map records, something closer to the light is blocking it, and the fragment is in shadow.

The shadow map records "closest surface to the light" at every texel. Anything behind that closest surface must be occluded. The cost of the lookup is one texture sample per fragment per light.

The resolution problem

A single shadow map has a fixed resolution. A directional light like the sun illuminates the entire scene, so the shadow map has to cover all of it. Objects near the camera want high-resolution shadows because the eye can see the edges clearly. Distant objects can tolerate much lower resolution because they cover a few pixels of screen space anyway. One global shadow map wastes resolution on geometry that is far away and starves the geometry that is close.

Cascaded shadow maps (CSM)

The fix is to split the camera's view frustum into depth ranges and give each range its own shadow map. Near cascades cover a small area at high texel density. Far cascades cover a large area at lower density. The total memory cost is fixed, but the texels go where they are seen.

The ShadowDepthPass renders four shadow cascades (NUM_SHADOW_CASCADES = 4) into a single large depth texture, one per quadrant.

  • Cascade 0 is the near range, highest detail, covering roughly 0 to 10 percent of the view distance.
  • Cascade 1 is the mid-near range, covering roughly 10 to 30 percent.
  • Cascade 2 is the mid-far range, covering roughly 30 to 60 percent.
  • Cascade 3 is the far range, lowest detail, covering roughly 60 to 100 percent.

Shadow map resolution

PlatformResolution
Native8192 x 8192
WASM4096 x 4096

Each cascade takes one quadrant of the texture, so each cascade gets an effective 4096 x 4096 on native and 2048 x 2048 on the web.

How cascades work

Every frame the engine does four things.

The first is frustum computation. The camera's view frustum is the truncated pyramid defined by the near plane, the far plane, and the field of view. The engine pulls the eight corners of that pyramid out of the camera matrices.

The second is frustum splitting. The frustum gets sliced into four depth ranges using a logarithmic-linear blend. Logarithmic splitting gives more resolution to near cascades. Linear splitting distributes resolution evenly. A blend of 0.5 to 0.8 toward logarithmic produces good results across most scenes.

The third is tight projection fitting. For each cascade, the engine takes the eight corners of that frustum slice, transforms them into light space, and builds an orthographic projection matrix that just encloses those points. The matrix is as tight as the slice allows, which keeps texels from being wasted on empty space outside the cascade's actual coverage.

The fourth is shadow rendering. Every shadow-casting mesh is drawn from the directional light's perspective into each cascade's viewport region of the shadow texture.

During the mesh pass, each fragment decides which cascade to sample based on its distance from the camera. The shader picks the highest-resolution cascade that still contains the fragment, projects into that cascade's light-space coordinates, and does the depth comparison.

Cascade selection and blending

At cascade boundaries, shadows can show visible seams because the resolution changes abruptly across the boundary. The fragment shader compares the fragment's view-space depth against the cascade split distances to pick a cascade. Implementations that need smoother transitions blend between adjacent cascades in a small band around the boundary.

Spotlight shadow atlas

Spotlights share one atlas instead of getting their own textures.

PlatformAtlas Size
Native4096 x 4096
WASM1024 x 1024

Every spotlight with cast_shadows: true gets a slot in the atlas. The atlas is subdivided to fit multiple spotlights at the cost of per-light resolution.

Enabling shadows

Directional light shadows

spawn_sun() creates a directional light with shadows enabled by default.

#![allow(unused)]
fn main() {
let sun = spawn_sun(world);
}

To configure manually:

#![allow(unused)]
fn main() {
world.core.set_light(entity, Light {
    light_type: LightType::Directional,
    cast_shadows: true,
    shadow_bias: 0.005,
    ..Default::default()
});
}

Spotlight shadows

#![allow(unused)]
fn main() {
world.core.set_light(entity, Light {
    light_type: LightType::Spot,
    cast_shadows: true,
    shadow_bias: 0.002,
    inner_cone_angle: 0.2,
    outer_cone_angle: 0.5,
    ..Default::default()
});
}

Per-mesh shadow casting

Control which meshes cast shadows.

#![allow(unused)]
fn main() {
world.core.add_components(entity, CASTS_SHADOW);
world.core.set_casts_shadow(entity, CastsShadow);

// Disable:
world.core.remove_components(entity, CASTS_SHADOW);
}

Shadow quality

Shadow bias

Shadow acne comes from the shadow map's finite resolution. A surface that should be lit samples the shadow map at a slightly different position than where it was rendered, and floating-point imprecision lets the surface report itself as in shadow. The result is a moire-like pattern of alternating lit and shadowed stripes on flat surfaces.

The fix is shadow bias. A small depth offset is added during the comparison, pushing the comparison point slightly toward the light so a surface cannot self-shadow. The tradeoff is peter-panning. Too much bias detaches the shadow from the base of the object because the comparison point gets pushed past the surface entirely.

shadow_bias controls the offset.

#![allow(unused)]
fn main() {
light.shadow_bias = 0.005;  // Good default for directional lights
light.shadow_bias = 0.002;  // Good default for spotlights
}

Spotlights need less bias because their shadow maps cover a smaller area at higher effective resolution, so the acne is less severe to begin with.

Cascade settings

Shadow cascades are configured at the renderer level. The engine uses 4 cascades (NUM_SHADOW_CASCADES = 4) with the shadow map resolution set at initialization (8192 native, 4096 WASM). These are not runtime-configurable through Graphics resources.