Three.js From Zero · Article s4-03
Shadow Techniques — PCF, VSM, ESM, CSM, Ray-traced
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.
The shadow-map pipeline
- Render the scene from the LIGHT's POV into a depth texture
- Re-render the scene from the camera. For each lit pixel, project back to light space
- 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
- Implement CSM on an outdoor scene. Compare 1 vs 2 vs 4 cascades at 2km distance.
- Moment shadow maps: 4-moment variant of VSM with better bleeding characteristics.
- 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.