Three.js From Zero · Article s4-03

Shadow Techniques — PCF, VSM, ESM, CSM, Ray-traced

← threejs-from-zeroS4 · Article 03 Season 4
Article S4-03 · Three.js From Zero

Shadow Techniques — PCF, VSM, ESM, CSM, Ray-traced

Shadows from S1-03 used PCFSoftShadowMap. That's one of five+ algorithms. Each has different tradeoffs — quality, softness, cost, artifacts. This article tours the catalog so you can pick and tune deliberately.

loading…

The shadow-map pipeline

  1. Render the scene from the LIGHT's POV into a depth texture
  2. Re-render the scene from the camera. For each lit pixel, project back to light space
  3. Compare: "Is this pixel's light-space depth > the shadow map's stored depth?" If yes → shadowed
lightSpacePos = bias · lightProjection · lightView · worldPos
shadowDepth   = texture2D(shadowMap, lightSpacePos.xy).r
inShadow      = lightSpacePos.z > shadowDepth ? 1.0 : 0.0

That's the core. Every technique below is a variant of "how we sample + compare".

BasicShadowMap — hard, pixelated

Single sample. Fast, ugly. Use only for debug or an art-style choice.

PCF — Percentage-Closer Filtering

Sample the shadow map N times in a kernel around the projection point, count how many "fail" the depth test, average. Gives soft edges.

float pcf(vec3 projCoord) {
  float total = 0.0;
  for (int i = 0; i < 9; i++) {
    vec2 offset = kernel[i] * texelSize;
    float shadow = texture2D(shadowMap, projCoord.xy + offset).r;
    total += projCoord.z - bias < shadow ? 1.0 : 0.0;
  }
  return total / 9.0;
}

Three.js does 2x2 bilinear PCF by default. PCFSoftShadowMap does a 5x5 Poisson-disc kernel for extra softness.

VSM — Variance Shadow Maps

Store BOTH depth and depth². Use Chebyshev's inequality to get an analytic upper bound on "what fraction of the kernel is in shadow", without iterating a kernel. Allows arbitrary Gaussian blur of the shadow map itself.

Pros: rich soft shadows, arbitrary blur radius, fast. Cons: can "bleed" — shadows leak through thin occluders. Fix: light-bleeding reduction.

vec2 moments = texture2D(shadowMap, projCoord.xy).rg;
float p = smoothstep(projCoord.z - 0.001, projCoord.z, moments.x);
float variance = max(moments.y - moments.x * moments.x, 0.00002);
float d = projCoord.z - moments.x;
float pmax = variance / (variance + d * d);
return max(p, pmax);

ESM — Exponential Shadow Maps

Store exp(k · depth) instead of depth. Comparison becomes multiplication. Trivially blurable like VSM, but without the bleeding. Downside: numerical overflow at high k.

CSM — Cascaded Shadow Maps

For large outdoor scenes. Split the view frustum into N cascades (by depth range). Render a separate shadow map per cascade, at progressively coarser resolution. Near cascade has crisp shadows; far cascade has blurry but cheap shadows.

Three.js's CSM add-on (from three/addons/csm/CSM.js):

import { CSM } from 'three/addons/csm/CSM.js';

const csm = new CSM({
  maxFar: 100,
  cascades: 4,
  shadowMapSize: 2048,
  lightDirection: new THREE.Vector3(1, -1, 1).normalize(),
  camera, parent: scene,
});

// Each material needs patching to use CSM uniforms
const mat = new THREE.MeshStandardMaterial({...});
csm.setupMaterial(mat);

// In the loop:
csm.update();

Ray-traced shadows (WebGPU)

True ray-traced shadows: per pixel, shoot a ray toward the light, test geometry. Perfect soft shadows (use a cone, not a ray), no map resolution, correct penumbrae.

WebGPU's GPURayTracingPipeline was drafted but not yet universally shipped (2025). Until then, compute-shader BVH traversal approximates it. For static scenes, bake shadows with a path tracer and ship as lightmaps (S6-03).

Shadow bias tuning

  • Too little negative bias → surface acne (stippled self-shadow)
  • Too much negative bias → peter-panning (shadow detached from object)
  • Sweet spot varies with map size and light angle. Start at -0.0001.

Shadow camera frustum

DirectionalLight shadow uses an orthographic frustum. Set it tight to the scene:

sun.shadow.camera.left = -10; sun.shadow.camera.right = 10;
sun.shadow.camera.top = 10; sun.shadow.camera.bottom = -10;
sun.shadow.camera.near = 0.1; sun.shadow.camera.far = 40;
sun.shadow.mapSize.set(2048, 2048);

Too big → low effective resolution (texels / world unit). Too small → objects outside the frustum cast no shadow. Use CameraHelper(sun.shadow.camera) to visualize.

Common first-time pitfalls

  • Shadows don't show up. 3 opt-ins (renderer.shadowMap.enabled, light.castShadow, mesh.castShadow/receiveShadow). Miss any → silent fail.
  • Pixelated edges. Shadow map too small, or shadow frustum too big. Bump map size to 2048+ and tighten the frustum.
  • Far objects have no shadow. Scene exceeds shadow frustum. Use CSM.
  • Shadow bleeds through walls. VSM light-bleeding. Apply reduction: smoothstep(0.2, 1.0, visibility).
  • Frame rate tanks on shadows. Shadow rendering is a whole extra pass. Limit shadowcasters to hero objects.

Exercises

  1. Implement CSM on an outdoor scene. Compare 1 vs 2 vs 4 cascades at 2km distance.
  2. Moment shadow maps: 4-moment variant of VSM with better bleeding characteristics.
  3. Bake a lightmap: render the scene as a ray-traced shadow pass offline, apply as UV1 texture.

What's next

S4-04 — Screen-space reflections. Ray-march in screen space to get mirror reflections cheaper than raytracing.