Skip to content
DasPeTe Tutorials

A First-Person Controller in Babylon.js · Part 11 of 11 in the series

Part 11: Camera Feel

Give the camera the small movements that make a body out of a floating eye: a head bob that follows the steps, a dip on landing, a lean into sideways moves and a wider view when running.

Quest objectives

  • Bob the head with the steps, not with the clock
  • Dip the view on landing with a spring
  • Lean a little into sideways movement
  • Widen the view when running
  • Keep every effect independent of the frame rate, and off the aim

The controller from the parts before moves well, but its camera is glued to the capsule: it glides along like an eye on a rail. Real heads bob with every step, sink a little when the feet hit the ground and lean into a turn. Games copy a little of that, and players read it as weight and speed without noticing it.

All of it lives in the camera. The player only reports landings, and nothing of it touches the angles the mouse sets, so the effects never spoil the aim.

The player reports landings

The camera needs to know how hard a landing was. Add a callback to the Player class, next to eyeHeight:

src/player.ts
/** Called on landing, with the speed the player came down at. */
  onLand: (speed: number) => void = () => {};

When air() sees the landing, the controller has already stopped the fall: the velocity it left behind points along the floor. So the player remembers the fall speed of the last frame in the air. Add the field next to rising:

src/player.ts
/** How fast the player was falling in the last frame in the air. */
  private fallSpeed = 0;

And replace air():

src/player.ts
private air(dt: number, support: CharacterSurfaceInfo): void {
    if (this.jumpTimer > 0 && this.coyoteTimer > 0) {
      this.jump();
      return;
    }
    if (this.velocity.y <= 0) this.rising = false;
    if (support.supportedState === CharacterSupportedState.SUPPORTED && !this.rising) {
      // The controller has already stopped the fall, so use the speed from the frame before.
      this.onLand(this.fallSpeed);
      this.mode = 'walk';
      this.walk(dt, support);
      return;
    }
    if (this.tryLadder() || this.tryGrab()) return;

    // Without input the player keeps their momentum. With it, they steer, but slowly.
    if (this.input.axis('back', 'forward') || this.input.axis('left', 'right')) {
      const target = this.wish(this.groundSpeed());
      target.y = this.velocity.y;
      approach(this.velocity, target, PLAYER.airAcceleration * dt);
    }
    this.velocity.y -= PLAYER.gravity * dt;
    this.fallSpeed = Math.max(-this.velocity.y, 0);
  }

The camera also needs the player's velocity, for the lean. Add a getter next to horizontalSpeed():

src/player.ts
getVelocity(): Vector3 {
    return this.controller.getVelocity();
  }

The camera

Replace src/camera.ts. It hooks into the landings in the constructor and gets the frame time in update(), because everything in it moves over time:

src/camera.ts
import { FreeCamera, Vector3, type Scene } from '@babylonjs/core/pure';
import type { Look } from './look.ts';
import type { Player } from './player.ts';
import { CAMERA, PLAYER } from './settings.ts';

/** The player's eyes: the camera, put where the player is and turned where they look. */
export class PlayerCamera {
  readonly camera: FreeCamera;

  private bobPhase = 0;
  private bobWeight = 0;
  private dip = 0;
  private dipSpeed = 0;
  private roll = 0;
  private nudge = 0;

  constructor(scene: Scene, player: Player) {
    this.camera = new FreeCamera('eyes', Vector3.Zero(), scene);
    // A near plane close to the eye lets the player hug a wall without seeing through it.
    this.camera.minZ = 0.05;
    this.camera.fov = CAMERA.fov;
    player.onLand = (speed) => {
      if (speed > 2) this.dipSpeed -= speed * 0.4;
    };
  }

  update(dt: number, player: Player, look: Look): void {
    const speed = player.horizontalSpeed();
    const walking = player.mode === 'walk';

    // Head bob: one dip per step. It follows the distance walked, not the time, so the
    // steps match the speed.
    this.bobPhase += (speed * dt * Math.PI) / 0.8;
    const weight = walking ? Math.min(speed / PLAYER.runSpeed, 1) : 0;
    this.bobWeight += (weight - this.bobWeight) * ease(10, dt);
    const bobUp = ((Math.cos(this.bobPhase * 2) - 1) / 2) * 0.06 * this.bobWeight;
    const bobSide = Math.sin(this.bobPhase) * 0.03 * this.bobWeight;

    // Landing: a spring that is pushed down and settles back, critically damped.
    const stiffness = 150;
    this.dipSpeed += (-stiffness * this.dip - 2 * Math.sqrt(stiffness) * this.dipSpeed) * dt;
    this.dip += this.dipSpeed * dt;

    // Lean a little into sideways movement, and look down a bit while climbing over a ledge.
    const sideways = Vector3.Dot(player.getVelocity(), look.right()) / PLAYER.runSpeed;
    this.roll += (-sideways * 0.03 - this.roll) * ease(8, dt);
    this.nudge += ((player.mode === 'mantle' ? 0.15 : 0) - this.nudge) * ease(8, dt);

    player.eyePosition(this.camera.position);
    this.camera.position.addInPlace(look.right().scaleInPlace(bobSide));
    this.camera.position.y += bobUp + this.dip;
    this.camera.rotation.set(look.pitch + this.nudge, look.yaw, this.roll);

    // A wider view when running makes the speed felt.
    const fov = CAMERA.fov + (player.running && speed > PLAYER.walkSpeed ? 0.14 : 0);
    this.camera.fov += (fov - this.camera.fov) * ease(6, dt);
  }
}

/** The share of the way to close this frame, for smoothing that does not depend on the frame rate. */
function ease(rate: number, dt: number): number {
  return 1 - Math.exp(-rate * dt);
}

In src/main.ts, the camera gets the player, new PlayerCamera(scene, player), and the frame time: view.update(dt, player, look).

ease() is the smoothing from the eye height in part 6, as a function: the share of the way to close in this frame, the same per second at any frame rate. Every effect eases towards its target with it, so none of them snaps on or off.

Head bob

The bob follows the distance walked, not the time: the phase advances with speed × dt, so walking faster takes faster steps, and standing still stops them. Each step is 0.8 m. The view dips once per step, up to 6 cm at running speed, and sways 3 cm to the side once per two steps, left foot and right foot.

The bobWeight fades the bob with the speed, and to zero when the player is not walking: in the air, on a ladder or on a wall, there are no steps.

Landing

A landing pushes a spring: the landing speed sets how fast the view moves down, and the spring pulls it back. It is critically damped, the damping 2 × √stiffness, the fastest way back without bouncing past the rest position. A jump lands at about 7 m/s, a drop from the 2 m platform at about 9 m/s, and the view dips about 7 cm then. Landings slower than 2 m/s, such as from a step, move nothing.

Lean and view

  • The lean rolls the camera a little towards a sideways move: up to 0.03 radians, under 2 degrees, at running speed. In Babylon.js a positive roll tilts the view to the left, so a move to the right gets a negative one.

  • While climbing over a ledge, the view tilts down by 0.15 radians, towards the top the player climbs onto.

  • When running, the field of view widens by 0.14 radians, 8 degrees. The edges of the screen move faster then, and the speed shows.

Try it

Walk, and the view bobs gently. Run, and it bobs more, and the view widens. Strafe, and it leans. Jump off the platform at the top of the stairs, and it dips when you land. Compare it with the demo of part 10: the movement is the same, but this one feels like a body.

The finished controller: walk, run, jump, crouch and climb, with a camera that moves with you.
Open in a new tab

What you built

Eleven parts later, the controller walks and runs, takes slopes, stairs and curbs, jumps with coyote time and a jump buffer, crouches where the ceiling is low, and climbs in four ways: up ladders, onto ledges, along ledges it hangs from and all over climbable walls. Each of those is a mode with its own code, and a mode that does not fit any more hands over to the next.

Where to go next

  • Moving platforms. Babylon.js's controller carries the character on bodies with the motion type ANIMATED. checkSupport() reports their velocity as averageSurfaceVelocity: add it to the target velocity in walk().

  • Things to push. Dynamic bodies get pushed by the controller, and characterStrength sets how hard.

  • A gamepad. The Gamepad API gives sticks as numbers from -1 to 1. The left stick fits into axis(), and the right one turns the view at an angular speed times dt.

  • Footsteps. The bob's phase knows when a foot comes down: play a sound each time the dip reaches its lowest point.

  • A real level. Build it in a 3D tool, load it as glTF, and give it static bodies with simple collision shapes, like the boxes of the test course.

Have fun with it, and show what you build in the comments.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.