Adding Shadows

Crossy Road with Three.js ● Chapter 3

In this chapter, we add shadows, define a shadow camera, and ensure that the objects in the scene cast and receive shadows.

Enable Shadow Maps in the Renderer

To enable shadows in the scene, first, we have to enable shadow maps in the renderer.

src/components/Renderer.ts
import * as THREE from "three";
export function Renderer() {
const canvas = document.querySelector("canvas.game");
if (!canvas) throw new Error("Canvas not found");
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
return renderer;
}
src/components/Renderer.js
import * as THREE from "three";
export function Renderer() {
const canvas = document.querySelector("canvas.game");
if (!canvas) throw new Error("Canvas not found");
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
return renderer;
}

Directional Light with Shadows

Let’s update our directional light to cast shadows. We need to set up a few more properties to define how. As our directional light has more properties now, we move it in its own file.

First, we need to set the light’s castShadow property to enable shadow casting. We also need to set the shadow.mapSize.width and height to set the resolution of the shadow map. Higher values result in sharper shadows.

Then, we need to define a shadow camera. A shadow camera renders the scene from the perspective of the light to calculate shadows.

We need to define a camera frustum. In the case of directional light, the shadow camera is an orthographic camera, and its frustum is a box. Shadows are only calculated within this box.

src/components/DirectionalLight.ts
import * as THREE from "three";
export function DirectionalLight() {
const dirLight = new THREE.DirectionalLight();
dirLight.position.set(-100, -100, 200);
dirLight.up.set(0, 0, 1);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.up.set(0, 0, 1);
dirLight.shadow.camera.left = -400;
dirLight.shadow.camera.right = 400;
dirLight.shadow.camera.top = 400;
dirLight.shadow.camera.bottom = -400;
dirLight.shadow.camera.near = 50;
dirLight.shadow.camera.far = 400;
return dirLight;
}
src/components/DirectionalLight.js
import * as THREE from "three";
export function DirectionalLight() {
const dirLight = new THREE.DirectionalLight();
dirLight.position.set(-100, -100, 200);
dirLight.up.set(0, 0, 1);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.up.set(0, 0, 1);
dirLight.shadow.camera.left = -400;
dirLight.shadow.camera.right = 400;
dirLight.shadow.camera.top = 400;
dirLight.shadow.camera.bottom = -400;
dirLight.shadow.camera.near = 50;
dirLight.shadow.camera.far = 400;
return dirLight;
}

In the demo below the green dot still represents our main camera that points towards the player.

We define the shadow camera’s frustum like when we set up our main camera. We define a box by the distance from the position of the light to the box’s near, far, left, right, top, and bottom sides.

Drag the demo below to see how it works. Here, the red dot represents the position of the directional light, and the line going to the player represents its direction. All other light rays are parallel with this vector.

The bigger gray box around the scene represents the shadow camera frustum. The near, far, left, right, top, and bottom values represent how far each side of the frustum box is from the camera position along the different axes from the camera’s perspective.

The red rectangle shows what part of the ground is part of the shadow camera frustum. As the red rectangle covers more than the green rectangle representing the visible area, every object in the scene should be able to cast and receive shadows.

Now that we have defined the directional light in its own file, let’s replace the original directional light in the main file with a call to this new function.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.render(scene, camera);
src/main.js
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.render(scene, camera);

Set castShadow and receiveShadow properties on the Meshes

To enable shadows on a mesh, you need to set the castShadow and receiveShadow properties. The castShadow property tells the mesh to cast shadows, while the receiveShadow property tells the mesh to receive shadows.

In the Grass and Road components, we need to set the receiveShadow property on the mesh to allow them to receive shadows.

src/components/Grass.ts
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Grass(rowIndex: number) {
const grass = new THREE.Group();
grass.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.BoxGeometry(tilesPerRow * tileSize, tileSize, 3),
new THREE.MeshLambertMaterial({ color: 0xbaf455 })
);
foundation.position.z = 1.5;
foundation.receiveShadow = true;
grass.add(foundation);
return grass;
}
src/components/Grass.js
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Grass(rowIndex) {
const grass = new THREE.Group();
grass.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.BoxGeometry(tilesPerRow * tileSize, tileSize, 3),
new THREE.MeshLambertMaterial({ color: 0xbaf455 })
);
foundation.position.z = 1.5;
foundation.receiveShadow = true;
grass.add(foundation);
return grass;
}
src/components/Road.ts
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Road(rowIndex: number) {
const road = new THREE.Group();
road.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.PlaneGeometry(tilesPerRow * tileSize, tileSize),
new THREE.MeshLambertMaterial({ color: 0x454a59 })
);
foundation.receiveShadow = true;
road.add(foundation);
return road;
}
src/components/Road.js
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Road(rowIndex) {
const road = new THREE.Group();
road.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.PlaneGeometry(tilesPerRow * tileSize, tileSize),
new THREE.MeshLambertMaterial({ color: 0x454a59 })
);
foundation.receiveShadow = true;
road.add(foundation);
return road;
}

Then, for the Player, Tree, Car, and Truck components, we need to set both the castShadow and receiveShadow properties on the meshes to allow them to cast and receive shadows.

src/components/Player.ts
import * as THREE from "three";
export const player = Player();
function Player() {
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.position.z = 10;
body.castShadow = true;
body.receiveShadow = true;
return body;
}
src/components/Player.js
import * as THREE from "three";
export const player = Player();
function Player() {
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.position.z = 10;
body.castShadow = true;
body.receiveShadow = true;
return body;
}
src/components/Tree.ts
import * as THREE from "three";
import { tileSize } from "../constants";
export function Tree(tileIndex: number, height: number) {
const tree = new THREE.Group();
tree.position.x = tileIndex * tileSize;
9 collapsed lines
const trunk = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: 0x4d2926,
flatShading: true,
})
);
trunk.position.z = 10;
tree.add(trunk);
const crown = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, height),
new THREE.MeshLambertMaterial({
color: 0x7aa21d,
flatShading: true,
})
);
crown.position.z = height / 2 + 20;
crown.castShadow = true;
crown.receiveShadow = true;
tree.add(crown);
return tree;
}
src/components/Tree.js
import * as THREE from "three";
import { tileSize } from "../constants";
export function Tree(tileIndex, height) {
const tree = new THREE.Group();
tree.position.x = tileIndex * tileSize;
9 collapsed lines
const trunk = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: 0x4d2926,
flatShading: true,
})
);
trunk.position.z = 10;
tree.add(trunk);
const crown = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, height),
new THREE.MeshLambertMaterial({
color: 0x7aa21d,
flatShading: true,
})
);
crown.position.z = height / 2 + 20;
crown.castShadow = true;
crown.receiveShadow = true;
tree.add(crown);
return tree;
}
src/components/Car.ts
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Car(
initialTileIndex: number,
direction: boolean,
color: THREE.ColorRepresentation
) {
const car = new THREE.Group();
car.position.x = initialTileIndex * tileSize;
if (!direction) car.rotation.z = Math.PI;
const main = new THREE.Mesh(
new THREE.BoxGeometry(60, 30, 15),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
main.position.z = 12;
main.castShadow = true;
main.receiveShadow = true;
car.add(main);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(33, 24, 12),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
cabin.position.x = -6;
cabin.position.z = 25.5;
cabin.castShadow = true;
cabin.receiveShadow = true;
car.add(cabin);
const frontWheel = Wheel(18);
car.add(frontWheel);
const backWheel = Wheel(-18);
car.add(backWheel);
return car;
}
src/components/Car.js
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Car(initialTileIndex, direction, color) {
const car = new THREE.Group();
car.position.x = initialTileIndex * tileSize;
if (!direction) car.rotation.z = Math.PI;
const main = new THREE.Mesh(
new THREE.BoxGeometry(60, 30, 15),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
main.position.z = 12;
main.castShadow = true;
main.receiveShadow = true;
car.add(main);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(33, 24, 12),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
cabin.position.x = -6;
cabin.position.z = 25.5;
cabin.castShadow = true;
cabin.receiveShadow = true;
car.add(cabin);
const frontWheel = Wheel(18);
car.add(frontWheel);
const backWheel = Wheel(-18);
car.add(backWheel);
return car;
}
src/components/Truck.ts
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Truck(
initialTileIndex: number,
direction: boolean,
color: THREE.ColorRepresentation
) {
const truck = new THREE.Group();
truck.position.x = initialTileIndex * tileSize;
if (!direction) truck.rotation.z = Math.PI;
const cargo = new THREE.Mesh(
new THREE.BoxGeometry(70, 35, 35),
new THREE.MeshLambertMaterial({
color: 0xb4c6fc,
flatShading: true,
})
);
cargo.position.x = -15;
cargo.position.z = 25;
cargo.castShadow = true;
cargo.receiveShadow = true;
truck.add(cargo);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, 30),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
cabin.position.x = 35;
cabin.position.z = 20;
cabin.castShadow = true;
cabin.receiveShadow = true;
truck.add(cabin);
const frontWheel = Wheel(37);
truck.add(frontWheel);
const middleWheel = Wheel(5);
truck.add(middleWheel);
const backWheel = Wheel(-35);
truck.add(backWheel);
return truck;
}
src/components/Truck.js
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Truck(initialTileIndex, direction, color) {
const truck = new THREE.Group();
truck.position.x = initialTileIndex * tileSize;
if (!direction) truck.rotation.z = Math.PI;
const cargo = new THREE.Mesh(
new THREE.BoxGeometry(70, 35, 35),
new THREE.MeshLambertMaterial({
color: 0xb4c6fc,
flatShading: true,
})
);
cargo.position.x = -15;
cargo.position.z = 25;
cargo.castShadow = true;
cargo.receiveShadow = true;
truck.add(cargo);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, 30),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
cabin.position.x = 35;
cabin.position.z = 20;
cabin.castShadow = true;
cabin.receiveShadow = true;
truck.add(cabin);
const frontWheel = Wheel(37);
truck.add(frontWheel);
const middleWheel = Wheel(5);
truck.add(middleWheel);
const backWheel = Wheel(-35);
truck.add(backWheel);
return truck;
}
Previous:
Rendering the Map
Next:
Animating the Vehicles