DEV Community

Malted
Malted

Posted on

First person character controllers in Three.js

I recently made a package that allows you to control a first person character for use with the the Three.js web graphics library. I thought it would be helpful to demonstrate how to use it in the context of a real Three.js scene, so we are going to create a basic demo scene to get you familiar with the package.

At the end of this you will have a little scene in which you can walk, look, and jump around in. Let's get into it!

First of all, we need to set up our devlopment environment. I'm going to use the Parcel bundler for this guide, as it's based as fuck and requires zero configuration.

Let's install it.

npm install --save-dev parcel
Enter fullscreen mode Exit fullscreen mode

Next, we need to install the charactercontroller package.

npm install charactercontroller
Enter fullscreen mode Exit fullscreen mode

Once we have all our packages installed, we can go ahead and start the parcel development server. This will both give us a port on localhost to view our page, as well as automatically reloading the page when we make a change in the code.

npx parcel index.html
Enter fullscreen mode Exit fullscreen mode

Now we can begin.

First, make sure you set your script tag's type to module. This is needed for dynamic imports (ie import { foo } from "bar").

To get rid of the border around the canvas, you might want to set the margin on it to zero and make it full-width. Here's my index.html;

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width">
        <title>CharacterController</title>
        <style>
            html, body, canvas {
                margin: 0;
                height: 100%;
                width: 100%;
            }
        </style>
    </head>

    <body>
        <script type="module" src="script.js"></script>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Let's initialise a basic Three.js scene.

This guide isn't intended to teach you about the very basics of Three.js; if you don't know what the below code does, then go and learn it.

import * as THREE from "three";

const scene = new THREE.Scene();

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

function animate() {
    requestAnimationFrame(animate);

    renderer.render(scene, /* We need a camera! */);
};
animate();
Enter fullscreen mode Exit fullscreen mode

Notice how we haven't added a camera. This is because our character controller will contain the camera that is used to render the scene. Let's add the character now.

const controller = new CharacterController(scene, {});
scene.add(controller);

// ...

    renderer.render(scene, controller.camera);
Enter fullscreen mode Exit fullscreen mode

Here, we are importing then initialising our character controller. As arguments, it takes both our scene and an obect containing settings and configuration. Let's leave that settings object blank for now; it will use the default values. Then we can add the character to our scene.
Now that we have a camera (controller.camera), we can use that to render the scene in the animate method too.

There's only one more thing we have to do before our character is ready to go.
Every frame, we need to tell the controller that it's time to do new calculations ready for the frame to be rendered, for example moving the player or rotating the camera.

The CharacterController package makes this piss-easy. Simply call controller.update() in your update method.

/// ...

function animate() {
    requestAnimationFrame(animate);

    controller.update();

    renderer.render(scene, controller.camera);
};
animate();
Enter fullscreen mode Exit fullscreen mode

Awesome stuff. You can't see it right now because there's no frame of reference, but our character is actually plummeting down, and will do forever, because we didn't add a floor for the poor sod to stand on.
Let's fix that.

// ...

const floorMaterial = new THREE.MeshBasicMaterial({ color: 0x00FF00 });
const floorGeometry = new THREE.PlaneGeometry(10, 10);
const floorMesh = new THREE.Mesh(floorGeometry, floorMaterial);
scene.add(floorMesh);

function animate() {
// ..
Enter fullscreen mode Exit fullscreen mode

You'll notice that we can't actually see the floor we just made until we jump. When we spawn, we are actually intersecting with the floor, as we are being spawned at z level 0, but so is the floor. Let's make sure we spawn on the ground, not in it.
I'm placing the character at z level 1 because that's the height of the player (the default floorDistance value is 1).

const controller = new CharacterController(scene, {});
controller.player.position.z = 1;
scene.add(controller.player);
Enter fullscreen mode Exit fullscreen mode

You want to know what's actually happening? You've come to the right fella.
When the character controller is initialised, a Three.js Group is created, which the camera is then added to. Neither the Group or the camera have any inherent volume or height to them, but the player's apparent height is actually created by an invisible ray being sent out pointing down from the Group's location. That ray is floorDistance long (an option you can pass into the character controller options object), and if the ray intersects with an object in the scene, the character stops falling. You can change the character's "height" by adjusting this value, because the character will stop being pulled down and "stand" closer/further away from the ground. Neat stuff, I know.
When the character spawns inside the floor plane, you can't see it because the camera is perfectly aligned with the infinitely thin floor. The camera doesn't see any geometry, so doesn't render pixels to the screen. The player doesn't fall down as the ray is still technically intersecting with the floor, even if it is only 0 units away.

To wrap up, we can add some quality of life changes. Firstly, we'll resize the renderer when the window is resized. Then, we'll lock the pointer in place when the player is moving their mouse.

We will implement both of these in event listeners. I like to put event listeners at the bottom of my scripts to keep things organised, but you can put them anywhere you like as long as the values referenced within them are initialised and in scope at the point you choose.

Here's the pointer lock:

document.addEventListener("click", () => {
    const canvas = renderer.domElement;
    canvas.requestPointerLock =
        canvas.requestPointerLock ||
        canvas.mozRequestPointerLock;
    canvas.requestPointerLock()
});
Enter fullscreen mode Exit fullscreen mode

And the canvas resizing:

window.addEventListener("resize", () => {
    controller.camera.aspect = window.innerWidth / window.innerHeight;
    controller.camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
}, false);
Enter fullscreen mode Exit fullscreen mode

That's everything working now. You have a minimal Three.js scene with a CharacterController character moving around in your scene! Go forth and create cool shit. I'm interested to see what people will make with this, so shoot me a DM on Twitter if you make something half-decent.

Here's the full code for reference;

import * as THREE from "three";
import CharacterController from "charactercontroller";

const scene = new THREE.Scene();
const controller = new CharacterController(scene, {});
scene.add(controller);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const floorMaterial = new THREE.MeshBasicMaterial({ color: 0x00FF00 });
const floorGeometry = new THREE.PlaneGeometry(10, 10);
const floorMesh = new THREE.Mesh(floorGeometry, floorMaterial);
scene.add(floorMesh);

function animate() {
    requestAnimationFrame(animate);

    controller.update();

    renderer.render(scene, controller.camera);
};
animate();

document.addEventListener("click", () => {
    const canvas = renderer.domElement;
    canvas.requestPointerLock =
        canvas.requestPointerLock ||
        canvas.mozRequestPointerLock;
    canvas.requestPointerLock()
});

window.addEventListener("resize", () => {
    controller.camera.aspect = window.innerWidth / window.innerHeight;
    controller.camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
}, false);
Enter fullscreen mode Exit fullscreen mode

Discussion (0)