Lesson 38 - Three.js Game Development

Three.js Lighting and Shadows Without Frame Stutter

Lighting should explain the world, not merely decorate it. This lesson builds a stable day scene and shows why innocent-looking light changes can freeze a game for a moment.

IntermediateThree.js60-80 minutesLightingShadows

1. Build a readable two-light base

A hemisphere light supplies broad sky and ground color. A directional light creates form and a clear world direction. This combination is cheaper and more legible than many small lights.

const skyLight = new THREE.HemisphereLight(0xbfdcff, 0x27351f, 1.15);
scene.add(skyLight);

const sun = new THREE.DirectionalLight(0xfff1d0, 2.4);
sun.position.set(35, 55, 20);
sun.target.position.set(0, 0, 0);
scene.add(sun, sun.target);

How this code works

Execution flow

The hemisphere light first supplies broad ambient color from sky and ground directions. The directional light then adds a strong, parallel sun contribution and targets the scene center, producing consistent highlights and shadows across a large play area.

Key Three.js decisions

HemisphereLight is a cheap readability tool rather than physically accurate global illumination. DirectionalLight matches sunlight because its rays are parallel, and a separate target object makes the intended direction explicit instead of relying on rotation values.

Adapt it

Tint the sky and ground colors for desert, snow, night, or alien biomes while preserving contrast. In an indoor scene, replace the sun with carefully budgeted spot or point lights, but keep a low-cost fill light so enemies remain legible.

Watch out

Adding many overlapping dynamic lights can trigger additional shader work and destroy mobile performance. If everything looks flat, reduce hemisphere intensity before increasing the sun; if surfaces are black, verify normals, materials, and light layers.

2. Configure one useful shadow camera

Shadow quality depends on map size and camera volume. A huge shadow box wastes texels. Fit it to the playable area and keep the near and far planes honest.

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
sun.castShadow = true;
sun.shadow.mapSize.set(1536, 1536);
sun.shadow.camera.left = -35;
sun.shadow.camera.right = 35;
sun.shadow.camera.top = 35;
sun.shadow.camera.bottom = -35;
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 130;
sun.shadow.bias = -0.00015;

How this code works

Execution flow

The renderer enables shadow maps, the sun starts casting, and a finite orthographic shadow camera is fitted around the playable region. Map resolution, near and far distance, and bias together determine shadow detail and artifact behavior.

Key Three.js decisions

PCFShadowMap is a supported filtered shadow mode in Three.js revision 184. A directional light uses an orthographic shadow camera, so tighter left, right, top, and bottom bounds concentrate available texels where the player can see them.

Adapt it

For a chase game, move the light target and shadow camera with a snapped region around the player. For a small arena, set static bounds once; for low quality, reduce map size or disable distant casters before removing all grounding shadows.

Watch out

An enormous shadow volume produces blurry shadows even with a large texture, while excessive negative bias causes detached shadows and too little bias causes acne. Use CameraHelper temporarily to inspect the actual shadow volume instead of guessing.

3. Assign shadow roles deliberately

Usually the player and large obstacles cast shadows; terrain receives them. Tiny particles, pickups, and distant decoration rarely justify extra shadow rendering.

player.traverse((object) => {
  if (object.isMesh) object.castShadow = true;
});
terrain.receiveShadow = true;

for (const tree of nearbyTrees) {
  tree.castShadow = quality !== "low";
  tree.receiveShadow = false;
}

How this code works

Execution flow

The code traverses the player model and enables casting only on meshes, marks terrain as a receiver, and toggles tree casting according to quality. This creates a clear hierarchy before rendering any shadow pass.

Key Three.js decisions

castShadow and receiveShadow are independent because not every object needs both operations. Traversal handles nested imported models, while the quality condition keeps the same scene graph and avoids rebuilding trees when graphics settings change.

Adapt it

Let bosses and nearby architecture cast shadows, but exclude particles, tiny pickups, distant foliage, and hidden collision meshes. On mobile, retain the player's shadow or a cheap blob so jumps and ground contact remain readable.

Watch out

Enabling castShadow on every leaf and decorative mesh multiplies shadow draw calls. If a model refuses to cast, inspect its child meshes and material alpha behavior; setting the flag only on the root Group does not affect descendants automatically.

4. Keep runtime feedback shader-stable

Do not add and remove lights for every hit. Animate an existing emissive value and overlay mesh so materials keep the same defines and the renderer avoids surprise compilation.

const hitOverlay = new THREE.Mesh(
  player.geometry,
  new THREE.MeshBasicMaterial({ color: 0xff4d4d, transparent: true, opacity: 0 })
);
player.add(hitOverlay);

function updateHitFlash(timeLeft) {
  hitOverlay.material.opacity = THREE.MathUtils.clamp(timeLeft * 5, 0, 0.65);
  hitOverlay.visible = hitOverlay.material.opacity > 0.01;
}

How this code works

Execution flow

A duplicate overlay mesh is prepared before gameplay with a transparent Basic material. Each update changes only opacity and visibility according to remaining hit time, so the render path reuses already known geometry and shader configuration.

Key Three.js decisions

MeshBasicMaterial is ideal for a brief damage flash because it ignores scene lighting and remains visible. Keeping material defines stable avoids a new light count, shadow variant, or complex Standard-material compilation at the exact moment combat contact occurs.

Adapt it

Use reusable overlays for selection, invulnerability blinking, checkpoints, and objective highlights. Imported characters can instead expose a shared emissive uniform, provided that material variant is created and compiled during loading rather than on first damage.

Watch out

Creating a new mesh, material, or point light on every hit can cause garbage collection or shader-compilation freezes. If the first hit stutters but later hits do not, profile shader compilation and pre-create or precompile the feedback resources.

5. Real Supagames example

Production source: biggames/peak-hopper/src/main.js

Play the published example: Peak Hopper

Peak Hopper exposed a real stutter when light or shadow state changed during contact events. The production fix keeps the lighting configuration stable and uses reusable visual feedback instead of compiling new shader variants mid-game.

The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.

6. Common mistakes

  • Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
  • Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
  • Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.

7. Build checklist

  • Serve ES modules through HTTP during development instead of opening the page through file://.
  • Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
  • Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
  • Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.
Previous: Geometry, materials, and textures Next: Delta time and game loops