three.js shadow map performance: count the shadow passes, then draw static shadows once

What three.js shadows cost per frame (a depth pass per casting light, six for a point light, and the map's pixels), and how a coding agent counts the shadow draw calls with the paprika3d plugin's open and perf, prices each change (maps drawn once, lamps without shadows, a smaller map) on a fresh page with perf variants, and checks with look that a static scene's picture did not change.

What a shadow costs each frame

Before three.js draws the camera's view, it draws the scene again from each light that casts a shadow, into that light's depth map. A directional or spot light draws its casters once. A point light draws them six times, once per face of a cube around it. Every one of those draws is another draw call, so a scene of 40 casters under five casting lights can send several times the draw calls the camera's view needs. Then the GPU fills each map, whose cost grows with shadow.mapSize squared, and every lit pixel on screen samples every map.

By default all of it happens every frame, whether anything moved or not. For a scene that stands still, renderer.shadowMap.autoUpdate = false keeps the maps from the last time they were drawn, and setting renderer.shadowMap.needsUpdate = true draws them once more when something does move. Each light also has its own pair, light.shadow.autoUpdate and light.shadow.needsUpdate, so static lamps can keep their maps while a sun that follows the player stays live.

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome. open adds a Lights line when a scene has more than a few casting lights. perf with costs: true gives the frame's draw calls beside the scene's objects, so the shadow passes show as the gap, and perf with variants runs each change on a fresh page and reports its draw calls and triangles. observe reads the renderer's counts on the live page, and look says what share of the picture changed since the last look.

This guide is about the cost of shadows that work. For shadows that do not show, see three.js shadows not showing.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

A night street

40 houses that cast and receive shadows, a moon (a directional light with a 2048 px map) and four street lamps, each a point light that casts with a 512 px map. Nothing in the scene moves.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Night street</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x141a26);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 200);
camera.position.set(0, 14, 30);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight(0x8899bb, 0x222222, 0.6));

// The moon: one directional light with a 2048 px shadow map over the whole street.
const moon = new THREE.DirectionalLight(0xbbccff, 1.2);
moon.name = 'moon';
moon.position.set(-15, 30, 10);
moon.castShadow = true;
moon.shadow.mapSize.set(2048, 2048);
Object.assign(moon.shadow.camera, { left: -30, right: 30, top: 30, bottom: -30, near: 1, far: 80 });
scene.add(moon);

const ground = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.MeshStandardMaterial({ color: 0x3a3a40 }));
ground.name = 'ground';
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

// 40 houses along both sides of the street; nothing in the scene ever moves.
const houses = new THREE.Group();
houses.name = 'houses';
const wall = new THREE.MeshStandardMaterial({ color: 0x9a8f80 });
for (let i = 0; i < 20; i++) for (const side of [-1, 1]) {
  const h = 3 + ((i * 7) % 5);
  const house = new THREE.Mesh(new THREE.BoxGeometry(2.4, h, 3, 4, 4, 4), wall);
  house.position.set(-24 + i * 2.6, h / 2, side * 7);
  house.castShadow = house.receiveShadow = true;
  houses.add(house);
}
scene.add(houses);

// Four street lamps, each a point light that casts: a point light renders its shadow map six times, once per cube face.
const lamps = new THREE.Group();
lamps.name = 'lamps';
for (let k = 0; k < 4; k++) {
  const lamp = new THREE.PointLight(0xffc477, 40, 18);
  lamp.position.set(-18 + k * 12, 4, 0);
  lamp.castShadow = true;
  lamp.shadow.mapSize.set(512, 512);
  lamps.add(lamp);
}
scene.add(lamps);

renderer.setAnimationLoop(() => renderer.render(scene, camera));
addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
</script>
</body></html>

The tool calls, in order

These replies were captured on 2026-10-28 on a Linux server with no GPU, so Chrome drew the page in software (SwiftShader). Draw calls and triangles are counts, the same on any machine. The times in the replies, and the Lights: line's share of the frame, come from a software renderer on a loaded host and are not used here. Run folder paths are shortened to <project>, and replies are cut to the lines this guide discusses, with nothing reworded.

1. Open the page. The Lights: line counts the lights that cost something per pixel and the ones that cast:

open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:38557/index.html in 0.3 s, paused at frame 0.
Lights: 4 point, 1 directional lights, 5 casting shadows (2048 maps): every lit material pays for each one; `perf` measures what they cost.
{"three":"186","scenes":1,"entities":["moon","ground","houses","lamps"]}

The map size it gives is the largest of the five.

2. Compare the frame with the scene. perf with costs: true:

perf {"costs": true}

792.1 ms median frame (p95 944.6 ms, 1 fps on average), 234 draw calls, 44738 triangles, 0 MB assets; over budget: frameMs 792.1 of 16.7 ms; desktop budget; software renderer (no GPU): frame time is no budget signal, do not optimize for it
Lights: 4 point, 1 directional lights, 5 casting shadows (2048 maps); the shadows cost 525.3 ms of a 792.1 ms frame. Light with a few real lights and make decorative lamps emissive materials; each shadow-casting point light draws the scene 6 more times.
Costs by named group: the main scene's visible objects, each under its nearest named ancestor as `look` names it, counted, not timed, and with no frustum culling (the frame draws only what its camera sees, plus a shadow pass per casting light); `variants` with `hide` times a group. In all 7682 triangles, 7680 casting shadows, 41 draw calls, 0 MB of textures (the first line's 44738 triangles and 234 draw calls are the frame as drawn, the camera's view plus the shadow pass):
  houses: 7680 triangles, 7680 casting shadows, 40 draw calls, 0 MB of textures
  ground: 2 triangles, 0 casting shadows, 1 draw calls, 0 MB of textures

The scene is 41 objects and 7682 triangles. The frame is 234 draw calls and 44738 triangles: the camera's view plus the shadow passes. The Lights: line measured the shadows by turning them off on a fresh page; its milliseconds belong to this host's software renderer, and its advice about point lights is the part that carries over.

3. Price each change. Each variant's code runs with renderer and scene on a fresh page:

perf {"variants": [{"label": "shadow maps drawn once", "code": "renderer.shadowMap.autoUpdate = false; renderer.shadowMap.needsUpdate = true;"}, {"label": "lamps cast no shadow", "code": "scene.getObjectByName(\"lamps\").children.forEach(l => { l.castShadow = false; })"}, {"label": "moon map 1024", "code": "const m = scene.getObjectByName(\"moon\"); m.shadow.mapSize.set(1024, 1024); m.shadow.map?.dispose(); m.shadow.map = null;"}]}

baseline: 751.2 ms median (p95 1207.8), work 1269.9 ms, 234 draw calls, 44738 triangles; over budget: frameMs 751.2 of 16.7 ms; desktop budget
shadow maps drawn once: 934.5 ms median (p95 1061.5), work 1074.4 ms, 38 draw calls, 7106 triangles; within the noise of the baseline; −84% draw calls, −84% triangles
lamps cast no shadow: 591 ms median (p95 631.4), work 491.9 ms, 78 draw calls, 14786 triangles; within the noise of the baseline; −67% draw calls, −67% triangles
moon map 1024: 1231.2 ms median (p95 1231.2), work 875 ms, 234 draw calls, 44738 triangles; within the noise of the baseline
Host load 28.5 on 64 cores. GPU: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). A software renderer (no GPU): frame times here say nothing of a real GPU's.

The counts split the 234 draw calls:

The times on this host go both ways within a noise of hundreds of milliseconds; they decide nothing. Lamps that must keep their shadows can keep them without the per-frame passes, since each light has its own switch:

perf {"variants": [{"label": "lamp maps drawn once", "code": "scene.getObjectByName(\"lamps\").children.forEach(l => { l.shadow.autoUpdate = false; l.shadow.needsUpdate = true; })"}]}

baseline: 770.5 ms median (p95 1123.6), work 1195 ms, 234 draw calls, 44738 triangles; over budget: frameMs 770.5 of 16.7 ms; desktop budget
lamp maps drawn once: 643.7 ms median (p95 655.7), work 625.5 ms, 78 draw calls, 14786 triangles; within the noise of the baseline; −67% draw calls, −67% triangles

The same 78 draw calls as turning the lamps' shadows off, with the lamps' shadows still in the picture.

4. Check the picture did not change. On the live page: draw two frames, read the counts, look, switch the maps to drawn-once, draw three more frames, and read and look again.

interact {"steps": [{"wait": 2, "draw": true}]}

observe {}

{"entities":{"moon":{"type":"DirectionalLight","position":[-15,30,10]},"ground":{"type":"Mesh","position":[0,0,0]},"houses":{"type":"Group","position":[0,0,0]},"lamps":{"type":"Group","position":[0,0,0]}},"harness":{"frame":2,"renderer":{"calls":234,"triangles":44738,"textures":7,"geometries":41,"programs":3},"scene":{"objects":49,"meshes":41,"lights":6,"cameras":0},"errors":[],"changed":null}}

look {}

Picture: <project>/paprika3d/runs/2026-10-28T10-27-47-835Z/look-1.png

eval {"code": "renderer.shadowMap.autoUpdate = false;\nrenderer.shadowMap.needsUpdate = true;   // draw the maps once more, then keep them"}

interact {"steps": [{"wait": 3, "draw": true}]}

observe {}

{"entities":{"moon":{"type":"DirectionalLight","position":[-15,30,10]},"ground":{"type":"Mesh","position":[0,0,0]},"houses":{"type":"Group","position":[0,0,0]},"lamps":{"type":"Group","position":[0,0,0]}},"harness":{"frame":5,"renderer":{"calls":38,"triangles":7106,"textures":7,"geometries":41,"programs":4},"scene":{"objects":49,"meshes":41,"lights":6,"cameras":0},"errors":[],"changed":{"harness":{"renderer":{"calls":[234,38],"triangles":[44738,7106],"programs":[3,4]}}}}}

look {}

Picture: <project>/paprika3d/runs/2026-10-28T10-27-47-835Z/look-2.png
Pixels: brightness 11%, colour saturation 46%, 2% near black, 0% blown out; 0% of the picture changed since the last look. Dark: a viewer sees little.

changed gives the draw calls going from 234 to 38, and look says 0% of the picture changed: the kept maps draw the same shadows. In the source, the two lines go after the scene is built, and renderer.shadowMap.needsUpdate = true goes wherever something that casts or receives moves.

Choosing a fix

Situation Fix
Nothing moves, or moves rarely renderer.shadowMap.autoUpdate = false, with needsUpdate = true on the frames that change
Static lamps and a moving player light.shadow.autoUpdate = false on the lamps; the light that follows the player stays live
Decorative lights no castShadow, or an emissive material and no light at all
Many casters that are too small to matter castShadow = false on them; the cost table's casting shadows column finds them
A sharp shadow over a large area a tighter shadow camera before a larger map

What paprika3d does not do

Pitfalls

Without paprika3d

renderer.info.render.calls read after a frame with shadows on and again with renderer.shadowMap.autoUpdate = false gives the same draw call split as step 3, and renderer.info.render.triangles the triangles. The browser's performance panel shows the frame's GPU time on a machine with a GPU, which is where a smaller map's saving shows. The three.js docs for WebGLRenderer.shadowMap and LightShadow list autoUpdate, needsUpdate, mapSize and the shadow camera.