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
| Light | What it is | Cost |
|---|---|---|
| AmbientLight | Omnidirectional, lights every face equally | Minimal |
| HemisphereLight | Ambient with a sky color and a ground color | Minimal |
| DirectionalLight | Sun: parallel rays from infinity | Moderate |
| PointLight | Infinitely small bulb, radiates in all directions | Moderate |
| SpotLight | Flashlight cone with angle and penumbra | High |
| RectAreaLight | Photoshoot softbox panel | High |
- 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: 0means infinite reach.decaydefaults to2, which is the physically correct falloff, keep it there for realistic results and tuneintensityinstead - RectAreaLight
(color, intensity, width, height)works ONLY with MeshStandardMaterial and MeshPhysicalMaterial. Orient it withlookAt:
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).angleis the cone width,penumbrasoftens the cone edge
The SpotLight target gotcha
- A SpotLight aims at its
target, an Object3D. SettingspotLight.target.positiondoes 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/fardoes 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/leftcontrol 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 = falsewhen done
const helper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(helper)
- SpotLight shadow camera: you cannot set
fov, the light'sangleoverrides it. Tunenearandfaronly - 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, andfarare 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
radiusdoes 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 edgesTHREE.PCFShadowMap: the default, smoothed edgesTHREE.PCFSoftShadowMap: softer edges, a bit slower, ignoresshadow.radiusTHREE.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 themapof 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.Groupso 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 towallHeight + coneHeight / 2 - No pyramid primitive exists: use
ConeGeometry(radius, height, 4)and rotateMath.PI * 0.25on 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
alphaMapplustransparent: true, so the plane never reads as a hard-edged square
Lighting a mood
- Tint the ambient and directional lights the same atmospheric color (
#86cdfffor 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
colormultiplies over its texture ('#ccffcc'shifts a leaf texture greener). Cheap art direction without editing the texture
Timer-driven animation
- Prefer
Timer(fromthree/addons/misc/Timer.js) overClock: it fixes the multiple-getElapsedTime()-per-frame bug, handles inactive tabs, and needs a manualtimer.update()each tick - Elapsed time is the angle:
cos(t)andsin(t)on x and z orbit an object. Scaletfor 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
Shadowssection instead of scatteringcastShadowlines 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
mapSizeto 256 and clampfar(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
Skyclass gives a full shader sky; its parameters are set throughsky.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.positionwithout adding the target to the scene, so the light never turns - Forgetting one of the three shadow switches: renderer
shadowMap.enabled, objectcastShadow/receiveShadow, lightcastShadow - Enabling
castShadowon a light type that does not support shadows (only Point, Directional, Spot do) - Cranking
mapSizewhen 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.radiuswith PCFSoftShadowMap and wondering why the radius does nothing - Multiple shadow-casting PointLights: each one is 6 extra renders per frame
- Forgetting
colorSpace = THREE.SRGBColorSpaceon 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 ofTimer - Leaving 4K textures in a scene that reads identically at 512, paying 8x the memory and load time