../

Lights, shadows & environment

Lighting in Three.js r18x: the light classes and their physical units, shadow maps and how to tune them, image-based lighting with scene.environment and PMREM, HDR loading, tone mapping and exposure, fog, baked lighting and what each choice costs. Materials that respond to light are in Geometry & materials; the loop and color pipeline in Fundamentals.

Light types

LightModelsIntensity unitShadowsCost
AmbientLight(color, i)flat fill from everywhererelativeno~free; flattens form
HemisphereLight(sky, ground, i)sky/ground gradient by normalrelativeno~free; good outdoor fill
DirectionalLight(color, i)parallel rays (sun)lux (as glTF)ortho shadow camera1 shadow pass
PointLight(color, i, distance, decay)bulb, all directionscandela (power in lumens)cube: 6 passesexpensive with shadows
SpotLight(color, i, dist, angle, penumbra, decay)conecandela (power in lumens)perspective1 shadow pass
RectAreaLight(color, i, w, h)emitting rectanglenits (power in lumens)noStandard/Physical only
LightProbe(sh, i)spherical-harmonics irradiancerelativenocheap diffuse IBL
scene.environmentimage-based lighting (IBL)environmentIntensitynoone texture; best realism per ms
const sun = new DirectionalLight(0xffffff, 3);
sun.position.set(5, 10, 3);  // direction = position → target
sun.target.position.set(0, 0, 0);
scene.add(sun, sun.target);  // target must be in the scene
                             // (or updated) if you move it
 
const bulb = new PointLight(0xffe0b0, 80, 0, 2);
bulb.position.set(0, 2.5, 0);
 
const spot = new SpotLight(0xffffff, 200);
spot.angle = Math.PI / 8;    // half-angle, radians
spot.penumbra = 0.3;         // 0 hard edge … 1 soft
spot.map = gobo;             // projected texture (cookie)

DirectionalLight and SpotLight point at light.target, not along their rotation. HemisphereLight uses its position as the "sky" direction (default straight up).

Physical light units

Physically correct lighting is the only mode: renderer.physicallyCorrectLights and renderer.useLegacyLights were deprecated and then removed (r165). What that means in practice:

RuleDetail
decay = 2 defaultinverse-square falloff, like real light; leave it
distance = 0 defaultno cut-off; a positive value adds a smooth window to zero at that range (a perf hint, not physics)
Scene scale matterspoint/spot intensity is candela; at 10 m, a light is 100× dimmer than at 1 m. Model in meters
light.powerpoint/spot/rect lights: set lumens instead (power = 800 ≈ a 60 W bulb)
Directional/ambient/hemisphereno falloff; values around 1–5 with exposure 1
Old tutorials look too darkthey used legacy scaling; point/spot values in the tens to hundreds are normal now
glTF KHR_lights_punctualsame units; GLTFLoader imports them as-is (Blender's watts are converted by its exporter)

Real-world values (sun ≈ 100 000 lux) only work with a matching toneMappingExposure; most apps treat intensities as relative and tune by eye with exposure 1.

RectAreaLight

import { RectAreaLightUniformsLib } from
  "three/addons/lights/RectAreaLightUniformsLib.js";
import { RectAreaLightHelper } from
  "three/addons/helpers/RectAreaLightHelper.js";
 
RectAreaLightUniformsLib.init(); // once, WebGLRenderer only
 
const panel = new RectAreaLight(0xffffff, 8, 2, 0.5);
panel.position.set(0, 2, 2);
panel.lookAt(0, 0, 0);           // emits along its -Z
panel.add(new RectAreaLightHelper(panel));
scene.add(panel);
  • Only MeshStandardMaterial and MeshPhysicalMaterial respond; no shadows.
  • WebGPURenderer needs RectAreaLightNode.setLTC(RectAreaLightTexturesLib.init()) instead (RectAreaLightTexturesLib from three/addons/lights/RectAreaLightTexturesLib.js).
  • Great for softboxes and screens; combine with an environment map for reflections of the panel itself.

Shadow maps

Each shadow-casting light renders the scene's casters from its own viewpoint into a depth texture, then every receiving fragment compares against it.

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = PCFShadowMap;  // default
 
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);      // default 512
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 40;
sun.shadow.bias = -0.0005;
sun.shadow.normalBias = 0.02;
sun.shadow.radius = 3;                   // PCF softness
 
mesh.castShadow = true;
ground.receiveShadow = true;
Shadow map typeLookNotes
BasicShadowMaphard, aliasedfastest; radius ignored
PCFShadowMapfiltered, soft edges via shadow.radiusdefault and the usual choice
VSMShadowMapblurred, very softradius + blurSamples; can light-bleed; no point lights
PCFSoftShadowMapremoved in r182: falls back to PCF with a warning

Flags on objects: castShadow and receiveShadow both default to false. Traverse a loaded model to set them. material.shadowSide picks which faces cast.

Shadow cameras

LightShadow cameraSize it with
DirectionalLightOrthographicCamera(-5, 5, 5, -5, 0.5, 500)left/right/top/bottom, near/far around the area that needs shadows
SpotLightPerspectiveCamera, fov from angleshadow.focus (0–1) narrows it; near/far
PointLightcube (6 × 90° perspective)near/far; keep mapSize small
const cam = sun.shadow.camera; // OrthographicCamera
cam.left = -15; cam.right = 15;
cam.top = 15;   cam.bottom = -15;
cam.near = 1;   cam.far = 50;
cam.updateProjectionMatrix();   // after every change
 
scene.add(new CameraHelper(cam)); // see the box

Shadow texel size = frustum width / mapSize. A 30 m box at 2048² gives ~1.5 cm texels; a 300 m box at the same size gives 15 cm blocks. Fit the box tightly, and for large outdoor scenes move the light and its target with the player, or use cascaded shadow maps (CSM from three/addons/csm/CSM.js).

Acne vs peter-panning

ArtifactLooks likeCauseFix
Shadow acnestripes / moiré on lit surfacessurface self-shadows due to depth quantisationsmall negative bias (-0.0001 … -0.001) or normalBias (0.01 … 0.05)
Peter-panningshadow detached from the object's basetoo much biasreduce bias; prefer normalBias
Blocky edgesstair-stepped outlinestexel too largetighter frustum, bigger mapSize, radius
Shadows cut offhard edge where shadow stopscaster/receiver outside the shadow cameraenlarge frustum or far; check with CameraHelper
No shadows at allshadowMap.enabled, castShadow, receiveShadow, light typeAmbientLight/Hemisphere/RectArea never cast
Light leaks through thin wallssingle-sided geometrymaterial.shadowSide = DoubleSide or thicker walls

normalBias offsets along the surface normal and handles grazing angles better than bias; start with normalBias and add a tiny bias only if acne remains.

Environment & IBL

An environment map lights every MeshStandardMaterial / MeshPhysicalMaterial with the surroundings (diffuse and reflections). It is usually the single biggest realism upgrade.

import { HDRLoader } from
  "three/addons/loaders/HDRLoader.js";
 
const hdr = await new HDRLoader().loadAsync(
  "/env/studio_small_2k.hdr",
);
hdr.mapping = EquirectangularReflectionMapping;
scene.environment = hdr;   // PMREM'd automatically
scene.background = hdr;    // optional
scene.backgroundBlurriness = 0.4;  // 0–1
scene.environmentIntensity = 1;
scene.environmentRotation.y = Math.PI / 2;
Loader (addons)FormatNotes
HDRLoaderRadiance .hdr (RGBE)replaces RGBELoader, deprecated in r180
EXRLoaderOpenEXR .exrfloat data
UltraHDRLoaderJPEG with HDR gain mapmuch smaller downloads
CubeTextureLoader (core)6 LDR imagesmarks them SRGBColorSpace itself
RoomEnvironmentprocedural studio roomno download; neutral product lighting
Scene / material propertyEffect
scene.environmentdefault env map for all PBR materials
scene.environmentIntensity, environmentRotationglobal strength and rotation
scene.backgroundColor, texture or cube texture
scene.backgroundBlurriness, backgroundIntensity, backgroundRotationbackground only
material.envMap, envMapIntensity, envMapRotationper-material override
GroundedSkybox (addon)projects the HDRI onto a ground dome so objects appear to stand on it

PMREM (prefiltered, mipmapped radiance environment map) pre-blurs the map per roughness level. Equirect and cube textures assigned to scene.environment are converted automatically; generate explicitly when you want to reuse or dispose it yourself, or for procedural scenes:

import { RoomEnvironment } from
  "three/addons/environments/RoomEnvironment.js";
 
const pmrem = new PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(
  new RoomEnvironment(), 0.04, // sigma: slight blur
).texture;
pmrem.dispose(); // keep the texture, free the generator

Good free HDRIs: Poly Haven (opens in a new tab). 1k–2k is enough for lighting; use a separate, larger or blurred image if the background must look sharp.

Tone mapping & exposure

Lighting produces HDR values above 1.0; tone mapping compresses them into displayable range before the sRGB conversion. It runs when rendering to the canvas (or in OutputPass), not into render targets.

renderer.toneMapping = AgXToneMapping;
renderer.toneMappingExposure = 1.0; // photographic stops
                                    // = log2 of this
ConstantCharacterUse when
NoToneMappingclamp at 1default; UI, unlit, stylised
LinearToneMappingexposure onlydebugging
ReinhardToneMappingsoft, desaturated highlightssimple scenes
CineonToneMappingfilmic, contrastylegacy looks
ACESFilmicToneMappingpunchy, shifts bright colors toward yellow/whitegames, cinematic
AgXToneMappinggraceful highlight desaturation, neutral huesHDR-heavy scenes, Blender-matching
NeutralToneMappingKhronos PBR Neutral: keeps base colors accuratee-commerce, product viewers
CustomToneMappingyour GLSL via ShaderChunk.tonemapping_pars_fragmentspecial grading

Per material: toneMapped: false skips it (UI colors that must match CSS exactly). Changing renderer.toneMapping at runtime recompiles materials; switch rarely.

Fog

scene.fog = new Fog(0xb8c6d9, 10, 80);   // linear
scene.fog = new FogExp2(0xb8c6d9, 0.02); // exponential²
scene.background = new Color(0xb8c6d9);  // match it!
Fog(color, near, far)FogExp2(color, density)
Fallofflinear from near to far1 - exp(-(density·d)²)
Feelscontrollable, game-likenatural haze
Tipset camera far ≈ fog far to hide poppingdensity 0.01–0.05 for meter scenes

Materials opt out with fog: false. ShaderMaterial needs fog: true plus the fog chunks (fog_pars_vertex, fog_vertex, fog_pars_fragment, fog_fragment).

Baked lighting

Real-time lights are per-frame cost; baked lighting is a texture lookup.

TechniqueHow
Bake to base colorbake lighting in Blender into the albedo, render with MeshBasicMaterial; zero lights needed
lightMapseparate baked texture on uv1 (texture.channel = 1), lightMapIntensity; keeps PBR reflections
aoMapambient occlusion; only affects indirect light (env, ambient, hemisphere)
Static shadowsshadowMap.autoUpdate = false, then shadowMap.needsUpdate = true when things move
Contact shadowsa blurred depth render on a plane under the object (drei's ContactShadows in R3F)
Shadow bloba transparent radial-gradient plane under characters; cheapest of all
Light probesLightProbeGenerator.fromCubeRenderTarget(renderer, rt) (addon) for diffuse irradiance

glTF has no standard lightmap slot: export the baked texture separately (or name it by convention) and assign it after loading.

Performance

CostWhyMitigation
Each shadow-casting lightrenders all casters again1 directional shadow; bake the rest
Point-light shadow6 renders per frameavoid, or tiny mapSize, or autoUpdate = false
Many lightsper-fragment loop over every light3–5 dynamic lights; env map for the rest
Adding/removing/hiding lightschanges the shader: all lit materials recompile (stall)create lights up front; animate intensity to 0 instead
mapSize 4096²16× the fill of 1024²fit the frustum first
MeshPhysicalMaterial.transmissionextra opaque-scene passfake glass with env reflections + opacity
Env mapone PMREM texture, cheap per pixelprefer it over many fill lights
renderer.shadowMap.autoUpdatere-renders shadows every framefalse for static scenes

Recipes

Studio product lighting

Use for model viewers and configurators: neutral IBL, one key light with a soft shadow, accurate colors.

import {
  DirectionalLight, NeutralToneMapping, PCFShadowMap,
  PMREMGenerator,
} from "three";
import type { Scene, WebGLRenderer } from "three";
import { RoomEnvironment } from
  "three/addons/environments/RoomEnvironment.js";
 
export function studio(
  scene: Scene, renderer: WebGLRenderer,
): DirectionalLight {
  renderer.toneMapping = NeutralToneMapping;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = PCFShadowMap;
 
  const pmrem = new PMREMGenerator(renderer);
  scene.environment = pmrem
    .fromScene(new RoomEnvironment(), 0.04).texture;
  pmrem.dispose();
 
  const key = new DirectionalLight(0xffffff, 1.5);
  key.position.set(3, 6, 4);
  key.castShadow = true;
  key.shadow.mapSize.set(1024, 1024);
  key.shadow.radius = 4;
  key.shadow.normalBias = 0.02;
  scene.add(key);
  return key;
}

Fit the shadow camera to a box

Use after loading a scene: sizes the directional shadow frustum to the content so texels are as small as possible.

import { Box3, Sphere, Vector3 } from "three";
import type { DirectionalLight, Object3D } from "three";
 
export function fitShadow(
  light: DirectionalLight, content: Object3D,
): void {
  const s = new Box3().setFromObject(content)
    .getBoundingSphere(new Sphere());
  const dir = light.position.clone()
    .sub(light.target.position).normalize();
  light.target.position.copy(s.center);
  light.position.copy(s.center)
    .addScaledVector(dir, s.radius * 2);
  light.target.updateMatrixWorld();
 
  const cam = light.shadow.camera;
  cam.left = cam.bottom = -s.radius;
  cam.right = cam.top = s.radius;
  cam.near = s.radius * 0.5;
  cam.far = s.radius * 3.5;
  cam.updateProjectionMatrix();
}

Sun that follows the player

Use in open worlds: a small, sharp shadow box travels with the camera instead of covering the map.

const SUN_DIR = new Vector3(-0.5, 1, 0.3).normalize();
const sun = new DirectionalLight(0xfff4e0, 3);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, {
  left: -20, right: 20, top: 20, bottom: -20,
  near: 1, far: 120,
});
sun.shadow.camera.updateProjectionMatrix();
scene.add(sun, sun.target);
 
function followSun(focus: Vector3): void {
  // snap to texel grid to stop shadow edge shimmer
  const texel = 40 / 2048;
  const x = Math.round(focus.x / texel) * texel;
  const z = Math.round(focus.z / texel) * texel;
  sun.target.position.set(x, 0, z);
  sun.position.set(x, 0, z).addScaledVector(SUN_DIR, 60);
}

The texel snap is approximate (it snaps in world XZ rather than light space) but removes most crawling at the shadow edges while moving.

HDRI with explicit PMREM

Use when you switch environments at runtime and want to dispose the old ones deterministically.

import {
  EquirectangularReflectionMapping, PMREMGenerator,
} from "three";
import type { Scene, Texture, WebGLRenderer } from "three";
import { HDRLoader } from
  "three/addons/loaders/HDRLoader.js";
 
const loader = new HDRLoader();
let current: Texture | null = null;
 
export async function setEnv(
  url: string, scene: Scene, renderer: WebGLRenderer,
): Promise<void> {
  const hdr = await loader.loadAsync(url);
  hdr.mapping = EquirectangularReflectionMapping;
  const pmrem = new PMREMGenerator(renderer);
  const env = pmrem.fromEquirectangular(hdr).texture;
  hdr.dispose();
  pmrem.dispose();
  scene.environment = env;
  scene.background = env;
  current?.dispose();
  current = env;
}

Static scene, shadows once

Use for architecture and dioramas: shadows cost nothing per frame until something moves.

renderer.shadowMap.enabled = true;
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true; // first frame
 
function onSceneEdited(): void {
  renderer.shadowMap.needsUpdate = true; // next frame
}

References