Three.js From Zero · Article 03
Materials, Light & Shadow
Materials, Light & Shadow
This is the article where your scene stops looking like a tech demo and starts looking like a render. We're going to climb Three.js's material ladder from the unlit basics up to full PBR, wire every built-in light type, set up shadow maps properly, and get the tone mapping right so your exposure doesn't clip.
Drag the sliders in the demo. Every parameter in PBR is in there — metalness, roughness, clearcoat, transmission, sheen. And every light type. Play first, then read.
The material ladder
Three.js gives you six mesh materials that go from "no lighting, fastest" to "full physically based, most expensive". Pick the lowest rung that gives you the look you want — rendering cost scales with the ladder.
| Class | Lit? | Typical use |
|---|---|---|
MeshBasicMaterial | No | UI in 3D, unlit sprites, debug, sky domes. |
MeshLambertMaterial | Diffuse only | Matte surfaces where specular doesn't matter. |
MeshPhongMaterial | Diffuse + specular | Plastics, old-school shiny look. |
MeshToonMaterial | Stylized | Cel-shaded, cartoon, illustrative. |
MeshStandardMaterial | Full PBR | Default answer for almost everything. |
MeshPhysicalMaterial | PBR + extras | Car paint, glass, cloth, liquid. Extends Standard. |
There's also MeshNormalMaterial (visualizes normals), MeshMatcapMaterial
(a single image stands in for all lighting — cheap and gorgeous), and MeshDepthMaterial
/ MeshDistanceMaterial (used internally for shadows).
Real rule of thumb: default toMeshStandardMaterial. Step up toMeshPhysicalMaterialwhen you need clearcoat, transmission (glass/liquid), iridescence, sheen (fabric), or anisotropy. Step down only when you need to save milliseconds and you know the hit comes from shading, not overdraw.
PBR in ten lines
Physically Based Rendering is just shading math that respects energy conservation and
physical parameters. MeshStandardMaterial uses a metalness/roughness workflow —
the same one Blender, Substance, Marmoset, and Unreal use.
const mat = new THREE.MeshStandardMaterial({
color: 0xff6600,
metalness: 0.9, // 0 = dielectric (wood, plastic, skin), 1 = metal
roughness: 0.25, // 0 = mirror, 1 = chalk
envMapIntensity: 1, // reflection strength from environment map
normalMap: null, // tangent-space normals (bumps without extra triangles)
aoMap: null, // baked occlusion in crevices
});
MeshPhysicalMaterial extends it with:
clearcoat/clearcoatRoughness— a second specular layer on top (car paint).transmission+thickness+ior— refractive transparency (glass, liquid).sheen+sheenColor— cloth/velvet edge glow.iridescence— soap bubbles, beetle shells.anisotropy— brushed metal highlights.
Flip the material dropdown above to see the cost visually: Basic is flat, Lambert adds diffuse shading, Phong adds specular highlights, Standard gives you real reflections, Physical layers clearcoat/transmission on top.
Every light type
Seven lights ship with Three.js. Here's when to use each:
| Class | What it is | When you reach for it |
|---|---|---|
AmbientLight | Uniform, directionless | Fill. Cheap fake GI. Don't rely on it alone. |
HemisphereLight | Sky color + ground color | Outdoor fill. Better default than AmbientLight. |
DirectionalLight | Parallel rays (sun) | Main key light. Casts efficient shadows. |
PointLight | Omni, falls off with distance | Light bulbs, campfires. |
SpotLight | Cone from a point | Flashlights, theater lighting, stage. |
RectAreaLight | Rectangle that emits | Panel lights, softboxes, monitors. |
LightProbe | Baked SH / grid | Faking GI in static scenes. Advanced. |
The "good-enough default" lighting rig
If you don't know where to start, this rig looks good on almost anything:
scene.add(new THREE.HemisphereLight(0xffffff, 0x444466, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(5, 8, 3);
sun.castShadow = true;
scene.add(sun);
// Optional: environment map for real reflections.
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new THREE.Scene()).texture;
One directional light + one hemisphere + an environment map beats four point lights every time, for both realism and performance.
RectAreaLight has two caveats
It only works with MeshStandardMaterial and MeshPhysicalMaterial,
and you must manually import its LTC textures once per app:
import { RectAreaLightUniformsLib } from
'three/addons/lights/RectAreaLightUniformsLib.js';
RectAreaLightUniformsLib.init();
Shadows — the part everyone gets wrong
Shadows in Three.js are opt-in at three independent places:
- The renderer:
renderer.shadowMap.enabled = true - Each light that casts:
light.castShadow = true - Each object that casts or receives:
mesh.castShadow = true/mesh.receiveShadow = true
Miss any one and shadows disappear silently. That's 60% of "my shadows don't show up" forum posts.
Shadow map types
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
BasicShadowMap— hard, pixelated, fastest.PCFShadowMap— default, percentage-closer filtering, decent softness.PCFSoftShadowMap— extra-soft PCF. Usually what you want.VSMShadowMap— variance shadow maps. Rich blur, but can have light bleeding in corners.
Sizing the shadow camera
A DirectionalLight's shadow is rendered through an orthographic camera. That
camera's frustum defines where shadows exist. Make it too big → low-resolution shadow.
Too small → shadows get clipped.
sun.shadow.mapSize.set(2048, 2048);
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 = 50;
sun.shadow.bias = -0.0001; // tune until acne and peter-panning are both gone
scene.add(new THREE.CameraHelper(sun.shadow.camera)); // debug frustum
Acne (stippled self-shadow) → make bias more negative.
Peter-panning (shadow detached from object) → bias too negative. Dial back.
Tone mapping & exposure
Your shader math works in linear HDR, but monitors display in sRGB, usually SDR. Tone mapping is the curve that squishes HDR values into the 0..1 range the display can show. Pick a curve and an exposure that reads well for your scene — there is no one right answer.
renderer.toneMapping = THREE.ACESFilmicToneMapping; // cinematic, saturated
renderer.toneMappingExposure = 1.1;
| Tone map | Character | Best for |
|---|---|---|
NoToneMapping | Clip to 1.0 | UI, debug. |
Linear | Scale only | Never in practice. |
Reinhard | Gentle desaturation | HDR without drama. |
Cineon | Filmic, crushed blacks | Moody scenes. |
ACES Filmic | Saturated, filmic | Default for most. Overrated but fine. |
AgX | Modern Blender default | Best highlights of the bunch. |
Neutral | Khronos standard | Product viz, spec-accurate. |
Try the dropdown in the demo and watch how the sphere's highlights change under each curve — they're all the same scene, same lighting, just a different final mapping.
Environment maps are non-negotiable
PBR needs an environment to reflect. Without one, metals look flat and dielectrics lose specular detail. The cheapest credible option is a neutral room:
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
For outdoor / cinematic looks, load a real HDR environment map (EXR/HDR file from Poly
Haven) with RGBELoader. Article 04 has the full pipeline.
Common first-time pitfalls
- Everything is black with Standard/Physical. You have no lights AND no environment map. PBR without either is pure black.
- Shadows don't render. Missed one of the three opt-ins (renderer, light, object).
- Shadow edges look pixelated. Bump
mapSizeto 2048 and tighten the shadow camera frustum. - Highlights look blown out. Wrong tone map or exposure. Try ACES with exposure 1.0, then adjust.
- Material looks plastic no matter what. Metalness 1 + low roughness. Dielectrics should have metalness 0.
- Frame rate tanks with shadows on. Reduce shadow map size, or skip shadows for non-hero objects.
Exercises
- Recreate car paint:
MeshPhysicalMaterial,metalness = 0.9,roughness = 0.3,clearcoat = 1,clearcoatRoughness = 0.03. - Recreate frosted glass:
transmission = 1,roughness = 0.3,thickness = 0.5,ior = 1.5. - Make the directional light animate around a 5m arc over 8 seconds. Watch the shadows sweep. That's free drama.
What's next
Article 04 — Textures & Color Management. The other half of PBR is maps — color, normal, roughness, metalness, AO. And the color pipeline has exactly one correct path that 90% of tutorials silently get wrong.