Lighting decides whether a 3D scene looks alive or flat. Shadows decide whether objects look grounded or floating. Three.js ships six light types, three shadow-map algorithms, an add-on for cascaded shadow maps, and several "fake" shadow techniques (contact shadow, baked AO, shadow catcher). This post covers all of them, plus the bias / map-size / camera tuning that makes shadows actually look good rather than buggy.

Series map: 00 Foundations → 01 Geometry & Materials → 02 Cameras & Controls → 03 Lighting & Shadows (this post) → 04 Textures & PBR → 05 Asset Pipeline → 06 Animation → 07 Instancing & Batching → 08 Low-End Perf → 09 Shaders → 10 Post-Processing → 11 Physics → 12 R3F → 13 AI 3D.


Learning Outcome

  • Use all six light types: AmbientLight, HemisphereLight, DirectionalLight, PointLight, SpotLight, RectAreaLight.
  • Enable + configure shadow maps without artefacts.
  • Choose between BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap.
  • Tune shadow.bias, normalBias, radius, mapSize, and the shadow camera frustum.
  • Use Cascaded Shadow Maps (three-csm) for large outdoor terrains.
  • Pick when to use a "contact shadow" (cheap fake) or a baked AO map instead.

Chapter 1 — Light types

Light Direction Cast shadow? Cost When
AmbientLight uniform from all directions no free scene "fill" so dark sides aren't black
HemisphereLight sky colour above + ground colour below no very cheap outdoor fill (sky → blue, ground → green)
DirectionalLight parallel rays (like the sun) yes cheap-ish sun, key light
PointLight omni from a single point yes (6-face cube map) medium lamps, fires
SpotLight cone from a point yes medium flashlights, stage lights
RectAreaLight rectangle (area light) no (yet) medium windows, softboxes; needs RectAreaLightUniformsLib init
1
2
3
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
RectAreaLightUniformsLib.init(); // one-time

Intensity in 2026

Since Three.js r155, physically-correct lighting is the default. Intensity is in candela (point/spot) or lumens, and MeshStandardMaterial interprets it physically. A DirectionalLight intensity of 1.0 ≈ noon sun. A small PointLight for a desk lamp might be ~20-100.

To opt back into the old non-physical mode (for compatibility with older tutorials):

1
2
renderer.useLegacyLights = true;   // discouraged

Chapter 2 — Enabling shadows (the three-step ritual)

Shadows are off by default. Three steps to turn them on:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 1. Renderer
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

// 2. Lights that cast
const sun = new THREE.DirectionalLight(0xffffff, 1.0);
sun.position.set(10, 15, 10);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 50;
sun.shadow.camera.left = -20;
sun.shadow.camera.right = 20;
sun.shadow.camera.top = 20;
sun.shadow.camera.bottom = -20;
sun.shadow.bias = -0.0005;
sun.shadow.normalBias = 0.02;
scene.add(sun);
scene.add(new THREE.CameraHelper(sun.shadow.camera)); // debug

// 3. Meshes that cast / receive
model.traverse((c) => {
if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; }
});
floor.receiveShadow = true;

Forget any one of these three and you'll see no shadows.

How a directional light's shadow camera renders a shadow map, the three switches, and the bias fixes


Chapter 3 — Shadow map types

Type Cost Edge quality Notes
THREE.BasicShadowMap cheapest hard, pixelated rarely used
THREE.PCFShadowMap medium softer than basic, default OK
THREE.PCFSoftShadowMap medium-high much softer, blurry good default
THREE.VSMShadowMap high (but blurrable for free) very soft, can light-bleed needs shadow.blurSamples + radius tuning

Set globally on the renderer (above). For VSM, additionally:

1
2
3
sun.shadow.blurSamples = 25;
sun.shadow.radius = 4;

VSM has a unique benefit: the blur happens in screen space, so blur radius doesn't cost more shadow-map texture. Watch for light-bleeding artefacts at corners — usually fixed by adjusting shadow.bias and vsm parameters or by mixing with a tiny shadow.radius.


Chapter 4 — The shadow camera

A shadow-casting light renders the scene from its own viewpoint into a depth texture (the shadow map). That viewpoint is a camera — orthographic for DirectionalLight, perspective for SpotLight.

The frustum must:

  • Be as small as possible while enclosing every shadow caster.
  • Not crop the receiving floor / walls.

If too large → wasted precision → blocky shadows. If too small → shadows fade out where the frustum ends.

1
2
3
4
5
6
7
const cam = sun.shadow.camera;
cam.near = 1;
cam.far = 50;
cam.left = cam.bottom = -25;
cam.right = cam.top = 25;
cam.updateProjectionMatrix(); // required after changes

Trick: add scene.add(new THREE.CameraHelper(sun.shadow.camera)) while tuning. You see the shadow camera frustum in real time. Remove for production.


Chapter 5 — Bias, normalBias, radius, mapSize

Four knobs decide whether shadows look right or covered in stripes / floating / pixelated.

Knob What Typical
mapSize resolution of the shadow texture 1024 / 2048 / 4096 (powers of 2)
bias offsets depth to prevent shadow acne (self-shadowing artefacts) -0.0005
normalBias offsets along surface normal — fixes "peter panning" (shadows detached from feet) 0.02
radius softening radius (PCF / VSM) 1-8

Classic recipe:

1
2
3
4
5
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.bias = -0.0005;
sun.shadow.normalBias = 0.02;
sun.shadow.radius = 4;

Symptoms / fixes:

Symptom Fix
Banding / stripes on lit surfaces ("shadow acne") increase bias magnitude (more negative)
Shadows detached from objects ("peter pan") increase normalBias
Edges very pixelated bigger mapSize (cost: GPU memory + fill rate)
Shadows fade to nothing at distance enlarge shadow camera frustum
Light-bleeding at corners (VSM) reduce radius, or fall back to PCFSoft

Chapter 6 — Cascaded Shadow Maps (CSM)

For huge outdoor terrains, a single shadow map is either pixelated everywhere (too big a frustum) or sharp near and gone far (too small). CSM splits the camera frustum into N slices (cascades), each with its own shadow map at decreasing resolution as distance grows.

Three.js doesn't ship CSM; use three-csm (or its r3f cousin react-three-csm):

1
2
npm install three-csm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { CSM } from 'three-csm';

const csm = new CSM({
maxFar: 200,
cascades: 4,
shadowMapSize: 2048,
lightDirection: new THREE.Vector3(-1, -1, -1).normalize(),
camera: camera,
parent: scene,
});
csm.update(); // call each frame

// Materials must be updated to read the cascade uniforms:
mesh.material = csm.setupMaterial(mesh.material);

Use CSM whenever the visible terrain is much larger than the camera's close-up area — landscape games, open worlds, architectural visualisations of city blocks.


Chapter 7 — Contact shadows + AO + shadow-catcher tricks

Real-time shadows aren't always needed. Cheaper effects often look better.

Contact shadow ("AccumulativeShadows" / soft ground shadow)

A pre-baked or accumulated render of the object from above, splatted onto a transparent plane under it. Looks gorgeous, costs nearly nothing per frame.

  • Drei: <AccumulativeShadows> + <RandomizedLight> — a one-liner in R3F.
  • Vanilla: render the object N times with jittered light, composite into a render target.

Ambient occlusion (AO)

The "darkening in corners + crevices" effect that ground objects in shadow without computing every light.

Two paths:

  1. Baked AO — bake in Blender / Substance, export as aoMap texture, apply via material.aoMap = ...; material.aoMapIntensity = 1.
  2. Screen-space AO (SSAO) — runtime post-processing pass (Post 10).

Baked is cheaper + higher quality where the geometry is static.

Shadow catcher

A plane that only displays the shadow of objects above it (transparent everywhere else). Useful for product viewers on a transparent background:

  • Drei: <ShadowMaterial> on a plane.
  • Vanilla: new THREE.ShadowMaterial({ opacity: 0.5 }).
1
2
3
4
5
6
7
8
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.ShadowMaterial({ opacity: 0.4 })
);
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;
scene.add(plane);

Chapter 8 — Performance budget for shadows

Shadows are often the most expensive part of a Three.js scene. Rough budget:

Setup Frame cost (mid-range desktop)
No shadows ~0
1 DirectionalLight, 1024 map, PCF ~0.5-1 ms
1 DirectionalLight, 2048 map, PCFSoft ~1-3 ms
1 DirectionalLight, 4096 map, VSM ~3-8 ms
4-cascade CSM, 2048 each ~6-15 ms
PointLight shadow (6-face cube) ~3-5× a DirectionalLight
3+ shadow-casting lights quickly exceeds budget on mobile

Mobile cuts these costs in half on optimistic GPUs and 3× on bad ones. Plan for ≤ 1 shadow-casting light + 1024 map on phones.

Trick: for indoor scenes with many lights, bake the lightmap (Blender → Cycles → bake → export texture → assign as lightMap). Static lighting becomes a texture — zero per-frame cost. Real-time lights are only used for dynamic objects.


Chapter 9 — frustumCulled, matrixAutoUpdate, and shadow update modes

Two perf flags worth knowing in the lighting context:

1
2
3
sun.shadow.autoUpdate = false;        // freeze the shadow map (static scene)
sun.shadow.needsUpdate = true; // ask for a one-shot re-render

For mostly-static scenes (architectural visualisations), turning off autoUpdate and only flagging needsUpdate when something moves is a free speed boost.

1
2
3
4
mesh.frustumCulled = true;            // default — meshes outside camera are skipped
mesh.matrixAutoUpdate = false; // if mesh never moves
mesh.updateMatrix(); // call once after final transform

These also help shadow rendering: the shadow camera does its own frustum culling, so meshes with stale matrices waste work.


Practice Exercises

Exercise 1 — Basic shadow setup

A cube on a plane. One DirectionalLight. Walk through the three-step ritual. Confirm a shadow appears.

Exercise 2 — Compare shadow types

Same scene, cycle through BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap. Note difference in edges + framerate.

Exercise 3 — Tune bias

Deliberately set bias = 0. Observe acne. Lower to -0.001 until it disappears. Then experiment with normalBias on a slanted surface.

Exercise 4 — Cascaded shadow maps

Build a 200×200 terrain (PlaneGeometry(200, 200)). Place 100 trees (cubes for now) on it. Compare a single 4096 shadow map vs a 4-cascade CSM. Note the close-up shadow sharpness.

Exercise 5 — Shadow catcher

Load a single product model (any GLB) on a transparent ShadowMaterial plane. Render with renderer.setClearAlpha(0). The result: model + soft shadow, no ground visible.

Exercise 6 — Lightmap workflow

In Blender, build a simple room (4 walls + floor), bake AO, export the AO texture. In Three.js, load the room as GLB, attach the AO texture as aoMap. Compare with and without.


Cheat-sheet

What How
Enable shadows renderer.shadowMap.enabled = true
Soft shadow type renderer.shadowMap.type = THREE.PCFSoftShadowMap
Cast light.castShadow = true + mesh.castShadow = true
Receive mesh.receiveShadow = true (and floor)
Shadow camera frustum tighten via shadow.camera.{left,right,top,bottom,near,far}
After frustum change shadow.camera.updateProjectionMatrix()
Shadow resolution light.shadow.mapSize.set(2048, 2048)
Bias defaults bias = -0.0005, normalBias = 0.02
Debug new THREE.CameraHelper(light.shadow.camera)
Cascaded shadows three-csm library, 2-4 cascades
Contact shadow accumulated splat or Drei <AccumulativeShadows>
AO bake to aoMap for static; SSAO in post for dynamic
Static scene speedup shadow.autoUpdate = false; flag needsUpdate on change
Physically correct in 2026 default since r155; tune intensities accordingly

What's next

Post 04 covers textures and PBR: how to load and configure colour, normal, roughness, metallic, AO maps; the sRGB / linear colour pipeline; mipmaps + anisotropy; environment maps (IBL); and KTX2 compressed textures.


References

Note: this post is in progress. Planned additions: ESM (Exponential Shadow Map) plugin survey; baking lightmaps in Blender step-by-step; cubeUV reflections lighting.