Skip to content

Lights and Shadows

A Three.js guide for coding agents. Also covers lights, shadows, AmbientLight, DirectionalLight, HemisphereLight, PointLight, and 20 more.

Show all 26 aliases

lights, shadows, AmbientLight, DirectionalLight, HemisphereLight, PointLight, SpotLight, RectAreaLight, light helpers, shadow map, castShadow, receiveShadow, shadow.mapSize, shadow camera bounds, PCFSoftShadowMap, VSM shadow map, baked shadows, fake shadow plane, how do I add shadows in three.js, shadow looks blurry or pixelated, shadow is cropped or missing, spotlight target not working, light performance cost, fog and FogExp2, Timer vs Clock, z-fighting

Core Philosophy

  • Lights are added like meshes: instantiate the class, position it, scene.add(). The hard parts are choosing the cheapest light that sells the effect and taming the shadow system
  • Only materials that react to light show anything: MeshStandardMaterial, MeshPhysicalMaterial, MeshLambertMaterial, MeshPhongMaterial, MeshToonMaterial. MeshBasicMaterial ignores lights entirely, which also makes it the tool for baked lighting
  • Every real-time light and every shadow map is per-frame GPU work. The default posture is: as few lights as possible, shadows on as few lights and objects as possible, and bake whatever never moves

Light types and when to use each

LightWhat it isCost
AmbientLightOmnidirectional, lights every face equallyMinimal
HemisphereLightAmbient with a sky color and a ground colorMinimal
DirectionalLightSun: parallel rays from infinityModerate
PointLightInfinitely small bulb, radiates in all directionsModerate
SpotLightFlashlight cone with angle and penumbraHigh
RectAreaLightPhotoshoot softbox panelHigh
  • AmbientLight alone looks exactly like MeshBasicMaterial, because every face is lit identically. Its real job is faking light bounce: Three.js does not simulate bounced light, so a dim AmbientLight stands in for it and keeps shadowed faces from going pure black
  • HemisphereLight takes (skyColor, groundColor, intensity). Faces pointing up get the sky color, faces pointing down get the ground color. Cheap way to add color variation to ambient fill
  • DirectionalLight position sets only the ray direction. Distance from the scene does not matter, the rays are parallel from infinity
const directionalLight = new THREE.DirectionalLight(0x00fffc, 0.9)
directionalLight.position.set(1, 0.25, 0)
scene.add(directionalLight)
  • PointLight extra parameters: (color, intensity, distance, decay). distance: 0 means infinite reach. decay defaults to 2, which is the physically correct falloff, keep it there for realistic results and tune intensity instead
  • RectAreaLight (color, intensity, width, height) works ONLY with MeshStandardMaterial and MeshPhysicalMaterial. Orient it with lookAt:
const rectAreaLight = new THREE.RectAreaLight(0x4e00ff, 6, 1, 1)
rectAreaLight.position.set(-1.5, 0, 1.5)
rectAreaLight.lookAt(new THREE.Vector3()) // empty Vector3 = scene center
  • SpotLight (color, intensity, distance, angle, penumbra, decay). angle is the cone width, penumbra softens the cone edge

The SpotLight target gotcha

  • A SpotLight aims at its target, an Object3D. Setting spotLight.target.position does nothing until the target is added to the scene. Three.js only compiles transform matrices for objects in the scene graph, and an orphan target never gets one
spotLight.target.position.x = -0.75
scene.add(spotLight.target) // without this line, the light does not budge

Light helpers

  • Positioning lights blind is hard. Each light type has a helper: instantiate with the light (and optionally a size), add to the scene
scene.add(new THREE.HemisphereLightHelper(hemisphereLight, 0.2))
scene.add(new THREE.DirectionalLightHelper(directionalLight, 0.2))
scene.add(new THREE.PointLightHelper(pointLight, 0.2))
scene.add(new THREE.SpotLightHelper(spotLight))
  • RectAreaLightHelper is not in the THREE core, import it from addons:
import { RectAreaLightHelper } from 'three/examples/jsm/helpers/RectAreaLightHelper.js'
scene.add(new RectAreaLightHelper(rectAreaLight))

Baking: the lighting escape hatch

  • Baking means computing the lighting in 3D software and painting it into the textures. At runtime there are zero lights, so the GPU cost drops to nothing
  • Tradeoff: nothing can move. Move an object or a light and the baked lighting is wrong, and every surface needs its own texture, so texture memory grows
  • Pair baked textures with MeshBasicMaterial, since no light needs to reach them

Shadow maps: how they work

  • Two kinds of shadow: core shadow (the unlit back of an object, free, comes from the lighting math) and drop shadow (one object shadowing another, this is what the shadow system adds)
  • Before the visible render, Three.js does one extra render per shadow-casting light, from the light's point of view, with every material swapped for MeshDepthMaterial. The result is a texture called a shadow map, projected onto every shadow-receiving material
  • Every shadow-casting light is therefore an extra scene render per frame. This is the whole reason shadow economy matters

Activating shadows

Three switches, all required:

// 1. Renderer
renderer.shadowMap.enabled = true

// 2. Per object: cast, receive, or both
sphere.castShadow = true
plane.receiveShadow = true

// 3. Per light
directionalLight.castShadow = true
  • Only PointLight, DirectionalLight, and SpotLight support shadows. Enable them on as few lights and as few objects as possible
  • Multiple shadow-casting lights produce shadows that do not merge or darken realistically where they overlap. Each map is independent, and there is nothing to do about it except limit the light count

Making the shadow look good

The default result is ugly. Tune in this order.

mapSize

  • The shadow map defaults to 512x512. Raise it for sharper shadows, keep it a power of 2 for mipmapping
directionalLight.shadow.mapSize.width = 1024
directionalLight.shadow.mapSize.height = 1024
  • Going the other way is also a tool: a LOW mapSize (256) gives naturally spread, blurry shadows that read as soft ambiance in a detailed scene, and it is cheaper. Blur by under-resolving is a legitimate look

Shadow camera: near, far, bounds

  • Each light renders its shadow map through a camera at light.shadow.camera. DirectionalLight uses an OrthographicCamera, SpotLight and PointLight use a PerspectiveCamera
  • Wrong near/far does not degrade quality, it causes bugs: shadows missing entirely or cropped abruptly. Fit them to the scene depth
  • For a DirectionalLight, the orthographic top/right/bottom/left control the shadow coverage area. Smaller bounds concentrate the same map resolution on less area, so the shadow gets sharper. Too small and shadows get clipped at the edges
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6
directionalLight.shadow.camera.top = 2
directionalLight.shadow.camera.right = 2
directionalLight.shadow.camera.bottom = -2
directionalLight.shadow.camera.left = -2
  • Debug with a CameraHelper on the shadow camera. Create the helper AFTER setting near/far/bounds, or it renders the stale values. Hide it with .visible = false when done
const helper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(helper)
  • SpotLight shadow camera: you cannot set fov, the light's angle overrides it. Tune near and far only
  • PointLight is the expensive one: it renders 6 times per frame to build a cube shadow map (the helper you see pointing down is just the last of the 6 renders). Avoid multiple shadow-casting PointLights. Only mapSize, near, and far are tunable

radius (blur)

directionalLight.shadow.radius = 10
  • A cheap uniform blur on the map edges. It does not vary with distance from the caster, so it is not contact-hardening, just softer edges
  • radius does NOT work with PCFSoftShadowMap. Pick one: radius blur on PCF, or the algorithm's own softening

Shadow map algorithms

Set on the renderer, applies to all shadow maps:

renderer.shadowMap.type = THREE.PCFSoftShadowMap
  • THREE.BasicShadowMap: fastest, ugly hard aliased edges
  • THREE.PCFShadowMap: the default, smoothed edges
  • THREE.PCFSoftShadowMap: softer edges, a bit slower, ignores shadow.radius
  • THREE.VSMShadowMap: slower, more constraints, can produce unexpected artifacts. Reach for it only when you know why you need it

Baked shadows and cheap alternatives

When real shadows cost too much or look messy, fake them.

Fully baked shadow texture

  • Turn off shadow maps (renderer.shadowMap.enabled = false) and put a pre-rendered shadow image on the floor as the map of a MeshBasicMaterial. Perfectly blurred, perfectly cheap, perfectly static: if the object or light moves, the lie is exposed
const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg')
bakedShadow.colorSpace = THREE.SRGBColorSpace // map textures are sRGB
const plane = new THREE.Mesh(
  new THREE.PlaneGeometry(5, 5),
  new THREE.MeshBasicMaterial({ map: bakedShadow })
)

Dynamic fake shadow (halo plane)

  • Less realistic but movable: a small plane hovering just above the floor, black material, a radial halo texture as alphaMap (white = visible, black = invisible), transparent: true
const sphereShadow = new THREE.Mesh(
  new THREE.PlaneGeometry(1.5, 1.5),
  new THREE.MeshBasicMaterial({
    color: 0x000000,
    transparent: true,
    alphaMap: simpleShadow
  })
)
sphereShadow.rotation.x = -Math.PI * 0.5
sphereShadow.position.y = plane.position.y + 0.01 // avoid z-fighting with the floor
  • Animate it in the tick: copy the object's x/z, and fade opacity with elevation so the shadow lightens as the object jumps
sphereShadow.position.x = sphere.position.x
sphereShadow.position.z = sphere.position.z
sphereShadow.material.opacity = (1 - sphere.position.y) * 0.3
  • There is no single right technique. Combine real shadow maps, baked textures, and halo planes per object based on what moves and what the frame budget allows

Scene-building takeaways (haunted house)

Transferable techniques from the practice lesson.

Composition and measurement

  • Decide what 1 unit means before modeling (1 unit = 1 meter for a house scene). Every dimension then comes from real-world reasoning: a door is about 2m, walls 2.5m
  • Put related meshes in a THREE.Group so the whole assembly can be moved or scaled at once. add() accepts multiple objects: house.add(bush1, bush2, bush3, bush4)
  • Geometry origins are at their center: a 2.5-tall box sits half-buried until position.y = 1.25. A cone roof on 2.5 walls goes to wallHeight + coneHeight / 2
  • No pyramid primitive exists: use ConeGeometry(radius, height, 4) and rotate Math.PI * 0.25 on y
  • Meshes at identical depths z-fight (GPU cannot order two coplanar faces, flickers). Fix by offsetting slightly: door.position.z = 2 + 0.01. Give temporarily invisible objects a loud debug color so mistakes like this surface early
  • Reuse ONE geometry and ONE material across many meshes (30 graves, 4 bushes). Each mesh gets its own transform for free, and the GPU uploads the data once
  • Scatter objects on a ring with trigonometry: random angle, randomized radius, same-angle sin/cos give x and z
const angle = Math.random() * Math.PI * 2
const radius = 3 + Math.random() * 4
grave.position.set(Math.sin(angle) * radius, Math.random() * 0.4, Math.cos(angle) * radius)
grave.rotation.set(
  (Math.random() - 0.5) * 0.4, // centered on 0, damped
  (Math.random() - 0.5) * 0.4,
  (Math.random() - 0.5) * 0.4
)
  • Fade a square floor's edges with a radial-gradient alphaMap plus transparent: true, so the plane never reads as a hard-edged square

Lighting a mood

  • Tint the ambient and directional lights the same atmospheric color (#86cdff for moonlight) and keep ambient dim; it mimics the directional light's bounce
  • Put a warm PointLight (#ff7d46) at the focal point (above the door). Warm against cold pulls the eye and makes the scene read as composed. Add it to the house group, not the scene, so it travels with the house
  • Tinting a material's color multiplies over its texture ('#ccffcc' shifts a leaf texture greener). Cheap art direction without editing the texture

Timer-driven animation

  • Prefer Timer (from three/addons/misc/Timer.js) over Clock: it fixes the multiple-getElapsedTime()-per-frame bug, handles inactive tabs, and needs a manual timer.update() each tick
  • Elapsed time is the angle: cos(t) and sin(t) on x and z orbit an object. Scale t for speed (t * 0.5), negate for direction, vary the multiplier and radius per object so nothing moves in lockstep
  • One sine looks mechanical. Multiply sines at unrelated frequencies for organic wandering, and prototype the curve in a graphing tool first
ghost1.position.y = Math.sin(a) * Math.sin(a * 2.34) * Math.sin(a * 3.45)
  • Roaming PointLights ARE the ghosts: no model needed, colored lights sweeping the scene sell the presence by what they illuminate

Shadow optimization at scene scale

  • Keep all shadow configuration in one Shadows section instead of scattering castShadow lines through the file
  • Choose casters deliberately: directional light and ghosts cast, the decorative door light does not (no visible payoff, real cost)
  • Objects built in a loop are reachable later via their group: for (const grave of graves.children) { grave.castShadow = true; grave.receiveShadow = true }
  • Drop mapSize to 256 and clamp far (10 for the roaming point lights, 20 for the directional) once the scene is textured: the low resolution reads as soft blur, not as an artifact, and performance improves

Fog

  • THREE.Fog(color, near, far) gives exact control over where fog starts and saturates. THREE.FogExp2(color, density) is the more realistic model: density compounds with distance
scene.fog = new THREE.FogExp2('#04343f', 0.1)
  • Develop with a loud debug color (pure red) to see exactly where the fog sits, then pick the final color from the bottom of the sky so scene and background merge seamlessly. Fog also hides the hard far edge of a finite floor
  • The addons Sky class gives a full shader sky; its parameters are set through sky.material.uniforms['...'].value, and it must be scaled up (sky.scale.set(100, 100, 100)) or it renders as a small box around the origin

Texture weight

  • Textures cost bandwidth AND GPU memory, and uploading big ones freezes the frame. Downscale (256 to 1024 per role) and convert to WEBP around quality 80: the haunted house set went from about 13MB to 1.6MB
  • Lossy compression is normally risky on data textures (normal maps), but on grungy organic surfaces the artifacts vanish, so compress everything. For clean surfaces keep normal maps lossless (PNG or lossless WEBP)

Common mistakes

  • Expecting light bounce: faces away from every light go black unless a dim AmbientLight fakes the bounce
  • Using RectAreaLight with a non-Standard/Physical material and seeing nothing
  • Moving spotLight.target.position without adding the target to the scene, so the light never turns
  • Forgetting one of the three shadow switches: renderer shadowMap.enabled, object castShadow/receiveShadow, light castShadow
  • Enabling castShadow on a light type that does not support shadows (only Point, Directional, Spot do)
  • Cranking mapSize when the real problem is the shadow camera bounds being far too large for the scene
  • Shrinking the shadow camera bounds so far the shadow gets cropped
  • Creating the shadow CameraHelper before setting near/far, so the helper shows stale values
  • Combining shadow.radius with PCFSoftShadowMap and wondering why the radius does nothing
  • Multiple shadow-casting PointLights: each one is 6 extra renders per frame
  • Forgetting colorSpace = THREE.SRGBColorSpace on color/map textures, giving a washed-out gray look
  • Placing a fake-shadow plane exactly on the floor plane, causing z-fighting flicker
  • Using Clock.getElapsedTime() twice in one frame instead of Timer
  • Leaving 4K textures in a scene that reads identically at 512, paying 8x the memory and load time

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "lights-and-shadows" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems