Following the Player

Crossy Road with React Three Fiber ● Chapter 7

Now, we can move around with the player on the map, but the camera still points to the starting position. Let’s follow the player with the camera. In this chapter, we attach the camera to the player so that they move together. We also adjust how the shadow camera casts shadows.

Following the Player with the Camera

Attach the Camera to the Player

We defined the camera in the Scene component. By default, it has a static position. Instead of that, we want to move it with the player. We could adjust its position at every animation frame just like the player, but it’s easier to attach the camera to the Player component so that they move together.

We can access the camera using the useThree hook from @react-three/fiber. This returns a Three.js camera object that we can add to the player group.

We already have a reference to the group representing the player. We can attach the camera to the player by adding it as a child of the player group. Because the player reference is undefined on the first render, we need to use the useEffect hook to attach the camera only once the player reference is set.

src/components/Player.tsx
import * as THREE from "three";
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
export function Player() {
const player = useRef<THREE.Group>(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
// Attach the camera to the player
player.current.add(camera);
});
return (
12 collapsed lines
<Bounds fit clip observe margin={10}>
<group ref={player}>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
</Bounds>
);
}
src/components/Player.jsx
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
export function Player() {
const player = useRef(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
// Attach the camera to the player
player.current.add(camera);
});
return (
12 collapsed lines
<Bounds fit clip observe margin={10}>
<group ref={player}>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
</Bounds>
);
}

Once we attached the camera to the player, some strange things happen. The camera is moving together with the player now, but it’s also turning whenever the player turns, and moving up and down whenever the player jumps. This is not what we want. We want the camera to follow the player, but we want it to stay level and not turn or jump.

Fix the Player Animation

To ensure the camera follows the player but doesn’t turn or jump with it, we wrap the player objects into another group. The original outer group will be a container that moves horizontally on the map but doesn’t jump or turn. We attach the camera to this group. Then, the new inner group containing the player objects won’t move horizontally but turns and jumps the player.

Let’s adjust the player animation to use this new structure.

src/components/Player.tsx
import * as THREE from "three";
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
export function Player() {
11 collapsed lines
const player = useRef<THREE.Group>(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
// Attach the camera to the player
player.current.add(camera);
});
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
</group>
</Bounds>
);
}
src/components/Player.jsx
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
export function Player() {
11 collapsed lines
const player = useRef(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
// Attach the camera to the player
player.current.add(camera);
});
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
</group>
</Bounds>
);
}

In the utility functions of the usePlayerAnimation hook, we need to adjust how we set the player’s position and rotation to work with the new structure. When moving the player along the x and y axes, we need to move the outer group, but when the player turns or jumps, we need to adjust the inner group.

These functions receive a reference to the outer group. We set the x and y positions as before. When we set the position or rotation along the z-axis, we set it on the inner group with player.children[0].

src/hooks/usePlayerAnimation.ts
33 collapsed lines
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { tileSize } from "../constants";
import { state, stepCompleted } from "../stores/player";
export default function usePlayerAnimation(
ref: React.RefObject<THREE.Group | null>
) {
const moveClock = new THREE.Clock(false);
useFrame(() => {
if (!ref.current) return;
if (!state.movesQueue.length) return;
const player = ref.current;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(
1,
moveClock.getElapsedTime() / stepTime
);
setPosition(player, progress);
setRotation(player, progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
});
}
function setPosition(player: THREE.Group, progress: number) {
const startX = state.currentTile * tileSize;
const startY = state.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (state.movesQueue[0] === "left") endX -= tileSize;
if (state.movesQueue[0] === "right") endX += tileSize;
if (state.movesQueue[0] === "forward") endY += tileSize;
if (state.movesQueue[0] === "backward") endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.children[0].position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(player: THREE.Group, progress: number) {
let endRotation = 0;
if (state.movesQueue[0] == "forward") endRotation = 0;
if (state.movesQueue[0] == "left") endRotation = Math.PI / 2;
if (state.movesQueue[0] == "right") endRotation = -Math.PI / 2;
if (state.movesQueue[0] == "backward") endRotation = Math.PI;
player.children[0].rotation.z = THREE.MathUtils.lerp(
player.children[0].rotation.z,
endRotation,
progress
);
}
src/hooks/usePlayerAnimation.js
31 collapsed lines
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { tileSize } from "../constants";
import { state, stepCompleted } from "../stores/player";
export default function usePlayerAnimation(ref) {
const moveClock = new THREE.Clock(false);
useFrame(() => {
if (!ref.current) return;
if (!state.movesQueue.length) return;
const player = ref.current;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(
1,
moveClock.getElapsedTime() / stepTime
);
setPosition(player, progress);
setRotation(player, progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
});
}
function setPosition(player, progress) {
const startX = state.currentTile * tileSize;
const startY = state.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (state.movesQueue[0] === "left") endX -= tileSize;
if (state.movesQueue[0] === "right") endX += tileSize;
if (state.movesQueue[0] === "forward") endY += tileSize;
if (state.movesQueue[0] === "backward") endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.children[0].position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(player, progress) {
let endRotation = 0;
if (state.movesQueue[0] == "forward") endRotation = 0;
if (state.movesQueue[0] == "left") endRotation = Math.PI / 2;
if (state.movesQueue[0] == "right") endRotation = -Math.PI / 2;
if (state.movesQueue[0] == "backward") endRotation = Math.PI;
player.children[0].rotation.z = THREE.MathUtils.lerp(
player.children[0].rotation.z,
endRotation,
progress
);
}

Disappearing Shadows

As the player moves further away, another strange behavior occurs. At one point, the objects no longer cast shadows.

If we zoom out even more and add a helper box to show the shadow camera’s frustum, we can see that some parts of the game are now outside it. Here, the gray box shows the shadow camera’s frustum and the red rectangle represents its projection to the ground. Drag the scene to see how it works.

Shadows are only calculated within the frustum of the shadow camera, which is statically attached to the directional light. We need to attach the directional light to the player as well.

Add the Directional Light to the Player

We need to attach the directional light to the player to make the shadows follow the player.

First, let’s remove it from the Scene component:

src/components/Scene.tsx
import { Canvas } from "@react-three/fiber";
import { DirectionalLight } from "./DirectionalLight";
type Props = {
children: React.ReactNode;
};
export const Scene = ({ children }: Props) => {
return (
<Canvas
orthographic={true}
shadows={true}
camera={{
up: [0, 0, 1],
position: [300, -300, 300],
}}
>
<ambientLight />
<DirectionalLight />
{children}
</Canvas>
);
};
src/components/Scene.jsx
import { Canvas } from "@react-three/fiber";
import { DirectionalLight } from "./DirectionalLight";
export const Scene = ({ children }) => {
return (
<Canvas
orthographic={true}
shadows={true}
camera={{
up: [0, 0, 1],
position: [300, -300, 300],
}}
>
<ambientLight />
<DirectionalLight />
{children}
</Canvas>
);
};

Then, add it to the player in the Player component. We add the light within the outer group so that it follows the player but doesn’t jump or turn with the player.

Make sure to add it as the second element because the usePlayerAnimation hook modifies the first element on jumps and turns.

src/components/Player.tsx
import * as THREE from "three";
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
import { DirectionalLight } from "./DirectionalLight";
export function Player() {
11 collapsed lines
const player = useRef<THREE.Group>(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
// Attach the camera to the player
player.current.add(camera);
});
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
<DirectionalLight />
</group>
</Bounds>
);
}
src/components/Player.jsx
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
import { DirectionalLight } from "./DirectionalLight";
export function Player() {
11 collapsed lines
const player = useRef(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
// Attach the camera to the player
player.current.add(camera);
});
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
<DirectionalLight />
</group>
</Bounds>
);
}

Target the Player

Directional lights shine from their position toward a target. By default, the target is the origin [0, 0, 0]. This was fine when the light was attached to the scene, but now that it’s attached to the player, we also need to adjust the target. Otherwise, the light and shadows will keep changing direction.

To modify the light’s target property, we need a reference to the underlying THREE.DirectionalLight object in the Player component. We can achieve this by forwarding a ref to the DirectionalLight component.

We change the target to follow the player automatically. In the useEffect hook once our references are ready, we set the target of the light to the player group element.

src/components/Player.tsx
import * as THREE from "three";
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
import { DirectionalLight } from "./DirectionalLight";
export function Player() {
const player = useRef<THREE.Group>(null);
const lightRef = useRef<THREE.DirectionalLight>(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
if (!lightRef.current) return;
// Attach the camera to the player
player.current.add(camera);
lightRef.current.target = player.current;
});
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
10 collapsed lines
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
<DirectionalLight ref={lightRef} />
</group>
</Bounds>
);
}
src/components/Player.jsx
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
import { DirectionalLight } from "./DirectionalLight";
export function Player() {
const player = useRef(null);
const lightRef = useRef(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
if (!lightRef.current) return;
// Attach the camera to the player
player.current.add(camera);
lightRef.current.target = player.current;
});
return (
<Bounds fit clip observe margin={10}>
<group ref={player}>
10 collapsed lines
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
<DirectionalLight ref={lightRef} />
</group>
</Bounds>
);
}

Then, we attach this ref to the directionalLight element.

src/components/DirectionalLight.tsx
import * as THREE from "three";
type Props = {
ref: React.RefObject<THREE.DirectionalLight | null>;
};
export function DirectionalLight({ ref }: Props) {
return (
<directionalLight
ref={ref}
position={[-100, -100, 200]}
up={[0, 0, 1]}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-400}
shadow-camera-right={400}
shadow-camera-top={400}
shadow-camera-bottom={-400}
shadow-camera-near={50}
shadow-camera-far={400}
/>
);
}
src/components/DirectionalLight.jsx
export function DirectionalLight({ ref }) {
return (
<directionalLight
ref={ref}
position={[-100, -100, 200]}
up={[0, 0, 1]}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-400}
shadow-camera-right={400}
shadow-camera-top={400}
shadow-camera-bottom={-400}
shadow-camera-near={50}
shadow-camera-far={400}
/>
);
}
Previous:
Restricting Player Movement
Next:
Generating the Map