Three.js From Zero · Article 03

Materials, Light & Shadow

Article 03 · Three.js From Zero

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.

0 fps
drag to orbit · scroll to zoom

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.

Material ladder diagram: five materials side-by-side showing increasing visual complexity — MeshBasic (flat grey), MeshLambert (matte sun-lit), MeshPhong (plastic with specular), MeshStandard (PBR with environment), MeshPhysical (with clearcoat halo). A rule-of-thumb callout recommends defaulting to MeshStandardMaterial.
Each rung adds shading features. Default to MeshStandard; reach for MeshPhysical when you need clearcoat / transmission / sheen.
ClassLit?Typical use
MeshBasicMaterialNoUI in 3D, unlit sprites, debug, sky domes.
MeshLambertMaterialDiffuse onlyMatte surfaces where specular doesn't matter.
MeshPhongMaterialDiffuse + specularPlastics, old-school shiny look.
MeshToonMaterialStylizedCel-shaded, cartoon, illustrative.
MeshStandardMaterialFull PBRDefault answer for almost everything.
MeshPhysicalMaterialPBR + extrasCar 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 to MeshStandardMaterial. Step up to MeshPhysicalMaterial when 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:

ClassWhat it isWhen you reach for it
AmbientLightUniform, directionlessFill. Cheap fake GI. Don't rely on it alone.
HemisphereLightSky color + ground colorOutdoor fill. Better default than AmbientLight.
DirectionalLightParallel rays (sun)Main key light. Casts efficient shadows.
PointLightOmni, falls off with distanceLight bulbs, campfires.
SpotLightCone from a pointFlashlights, theater lighting, stage.
RectAreaLightRectangle that emitsPanel lights, softboxes, monitors.
LightProbeBaked SH / gridFaking 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:

  1. The renderer: renderer.shadowMap.enabled = true
  2. Each light that casts: light.castShadow = true
  3. 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 mapCharacterBest for
NoToneMappingClip to 1.0UI, debug.
LinearScale onlyNever in practice.
ReinhardGentle desaturationHDR without drama.
CineonFilmic, crushed blacksMoody scenes.
ACES FilmicSaturated, filmicDefault for most. Overrated but fine.
AgXModern Blender defaultBest highlights of the bunch.
NeutralKhronos standardProduct 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 mapSize to 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

  1. Recreate car paint: MeshPhysicalMaterial, metalness = 0.9, roughness = 0.3, clearcoat = 1, clearcoatRoughness = 0.03.
  2. Recreate frosted glass: transmission = 1, roughness = 0.3, thickness = 0.5, ior = 1.5.
  3. 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.