Skip to content
DasPeTe Tutorials

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

Part 6: Crouching

Swap the capsule for a shorter one without moving the feet, check for a ceiling with a shape cast before standing up again, and lower the view smoothly.

Quest objectives

  • Pick a crouch key the browser lets you have
  • Swap the capsule for a shorter one and keep the feet where they are
  • Check for a ceiling with a shape cast before standing up
  • Move the eyes down and up smoothly, at any frame rate
  • Move slower while crouched

Behind the ramps, this part adds a tunnel to the course. Its roof is 1.4 m high, too low to walk through standing. The player gets a second, shorter capsule to crawl through it.

src/level.ts
// Left, behind the ramps: a tunnel too low to stand up in.
  build.block('tunnel-wall-a', { size: [6, 2.5, 0.4], at: [-9, 1.25, -3.2], color: 'tunnel' });
  build.block('tunnel-wall-b', { size: [6, 2.5, 0.4], at: [-9, 1.25, -0.8], color: 'tunnel' });
  build.block('tunnel-roof', { size: [6, 1.1, 2.8], at: [-9, 1.95, -2], color: 'tunnel' });

Add the tunnel to buildLevel() after the boxes, and its color to COLORS: tunnel: '#8f7ae6'.

A key for crouching

Many PC games crouch with Ctrl. In a browser that is a trap: Ctrl+W closes the tab, and a page cannot prevent it, short of the Keyboard Lock API, which only works in full screen and only in Chromium browsers. Crouching while walking forward would close the game. So crouching is on C. Add it to the bindings in src/input.ts:

src/input.ts
/**
 * Which keys do what. `event.code` names the physical key, so WASD stays where it is on
 * any keyboard layout. Control is not used: Ctrl+W closes the tab, and no page can stop it.
 */
const BINDINGS = {
  forward: ['KeyW', 'ArrowUp'],
  back: ['KeyS', 'ArrowDown'],
  left: ['KeyA', 'ArrowLeft'],
  right: ['KeyD', 'ArrowRight'],
  run: ['ShiftLeft', 'ShiftRight'],
  jump: ['Space'],
  crouch: ['KeyC'],
};

Add · C crouch to the hint in the overlay in index.html too.

A shorter capsule

Crouched, the capsule is 1.1 m tall, the eyes are at 0.95 m, and the player moves at 2 m/s. Add these to PLAYER in src/settings.ts:

src/settings.ts
export const PLAYER = {
  radius: 0.35,
  standHeight: 1.8,
  crouchHeight: 1.1,
  /** Eye height above the feet. */
  standEye: 1.65,
  crouchEye: 0.95,

  walkSpeed: 4,
  runSpeed: 7,
  crouchSpeed: 2,

The player makes both capsules once, at the start, and keeps them. New fields go into the Player class:

src/player.ts
mode: Mode = 'air';
  crouching = false;
  running = false;
  /** Eye height above the feet, eased when the stance changes. */
  eyeHeight = PLAYER.standEye;

  private readonly controller: PhysicsCharacterController;
  private readonly standShape: PhysicsShapeCapsule;
  private readonly crouchShape: PhysicsShapeCapsule;

The constructor makes the capsules and starts with the standing one:

src/player.ts
constructor(scene: Scene, level: Level, input: Input, look: Look) {
    this.level = level;
    this.input = input;
    this.look = look;
    this.standShape = capsule(PLAYER.standHeight, scene);
    this.crouchShape = capsule(PLAYER.crouchHeight, scene);
    this.controller = new PhysicsCharacterController(
      level.spawn.add(new Vector3(0, PLAYER.standHeight / 2, 0)),
      { shape: this.standShape, capsuleHeight: PLAYER.standHeight, capsuleRadius: PLAYER.radius },
      scene,
    );
    this.controller.maxSlopeCosine = Math.cos((PLAYER.maxSlopeDegrees * Math.PI) / 180);
  }

Babylon.js's controller swaps its shape with setShapeOptions(). Its second argument keeps the feet in place: the controller's position is the center of the capsule, so without it the shorter capsule would hang 35 cm in the air, its center where the old one's was, and drop. Without a shape, it would build a new capsule from capsuleHeight and capsuleRadius each time, and a new capsule is in no collision layer, so rays would hit the player again. With one, the height is still needed: the controller works out where the feet are from it.

Room to stand up

Letting go of C in the tunnel must not stand the player up into the roof. Before standing up, the player checks for room with a shape cast: the crouching capsule is swept upwards by the difference in height, and if it hits anything on the way, there is a ceiling. A single ray up from the head would miss a ceiling that only covers part of the capsule.

Add the shape cast to src/physics.ts, after the rays:

src/physics.ts
const castInput = new ShapeCastResult();
const castHit = new ShapeCastResult();

/**
 * Sweeps a shape from one point to another and says whether anything solid is in the way.
 * Only the layers in the shape's collide mask count.
 */
export function sweepHits(shape: PhysicsShape, from: Vector3, to: Vector3): boolean {
  plugin.shapeCast(
    { shape, rotation: Quaternion.Identity(), startPosition: from, endPosition: to, shouldHitTriggers: false },
    castInput,
    castHit,
  );
  return castHit.hasHit;
}

A shape cast uses the shape's own collision filter, and the player's capsules only collide with what is solid, so the sweep does not find the player. The imports at the top of the file grow accordingly:

src/physics.ts
import {
  HavokPlugin,
  PhysicsRaycastResult,
  Quaternion,
  RegisterJoinedPhysicsEngineComponent,
  ShapeCastResult,
  Vector3,
  type PhysicsShape,
  type Scene,
} from '@babylonjs/core/pure';

Now the crouching itself, as a section at the end of the Player class. Import sweepHits from ./physics.ts:

src/player.ts
// Crouching -------------------------------------------------------------------------

  private updateStance(): void {
    const wants = this.input.down('crouch');
    if (wants && !this.crouching) this.setCrouching(true);
    else if (!wants && this.crouching && this.roomToStand()) this.setCrouching(false);
  }

  private setCrouching(crouching: boolean): void {
    this.crouching = crouching;
    // Swap the capsule and keep the feet where they are.
    this.controller.setShapeOptions(
      {
        shape: crouching ? this.crouchShape : this.standShape,
        capsuleHeight: crouching ? PLAYER.crouchHeight : PLAYER.standHeight,
        capsuleRadius: PLAYER.radius,
      },
      true,
    );
  }

  /** Sweeps the crouching capsule up to standing height: anything in the way is a ceiling. */
  private roomToStand(): boolean {
    const center = this.controller.getPosition();
    const raised = center.add(new Vector3(0, PLAYER.standHeight - PLAYER.crouchHeight, 0));
    return !sweepHits(this.crouchShape, center, raised);
  }

Holding C crouches at once. Letting go stands up as soon as there is room, so the player stays crouched in the tunnel until they leave it, and stands up by themselves outside.

The eyes

The capsule changes height in one frame, but the eyes should not jump 70 cm. The player keeps an eye height of its own and eases it towards the target. Replace eyePosition():

src/player.ts
/** The point between the eyes, in world space. */
  eyePosition(result = new Vector3()): Vector3 {
    return result.copyFrom(this.controller.getPosition()).addInPlaceFromFloats(0, this.eyeHeight - this.controller.footOffset, 0);
  }

And update(), which updates the stance before the modes and eases the eyes after them:

src/player.ts
update(dt: number): void {
    if (this.controller.getPosition().y < -20) this.respawn();
    const support = this.controller.checkSupport(dt, DOWN);
    this.velocity.copyFrom(this.controller.getVelocity());

    this.coyoteTimer = Math.max(this.coyoteTimer - dt, 0);
    this.jumpTimer = Math.max(this.jumpTimer - dt, 0);
    if (this.input.pressedNow('jump')) this.jumpTimer = PLAYER.jumpBuffer;
    this.running = !this.crouching && this.input.down('run') && this.input.axis('back', 'forward') > 0;

    this.updateStance();

    switch (this.mode) {
      case 'walk':
        this.walk(dt, support);
        break;
      case 'air':
        this.air(dt, support);
        break;
    }

    this.controller.setVelocity(this.velocity);
    this.controller.integrate(dt, support, GRAVITY);

    const eye = this.crouching ? PLAYER.crouchEye : PLAYER.standEye;
    this.eyeHeight += (eye - this.eyeHeight) * (1 - Math.exp(-12 * dt));
  }

1 - Math.exp(-12 * dt) is the share of the remaining distance to close this frame. It closes the same share per second at any frame rate, and half the distance in about 60 milliseconds. The common value += (target - value) * 0.2 would move twice as fast at 120 frames per second as at 60.

A crouching player cannot run, and moves at crouching speed:

src/player.ts
private groundSpeed(): number {
    if (this.crouching) return PLAYER.crouchSpeed;
    return this.running ? PLAYER.runSpeed : PLAYER.walkSpeed;
  }

Finally the HUD shows the stance. In src/hud.ts:

src/hud.ts
const stance = player.crouching ? 'crouching' : player.running ? 'running' : 'standing';

Try it

Hold C: the view sinks smoothly, the HUD shows crouching and 2.0 m/s. Walk into the tunnel behind the ramps, let go of C in the middle, and you stay down. Walk out of the other end, and you stand up by yourself. Standing, the tunnel stops you at the entrance.

The project at the end of part 6: crouch with C and crawl through the tunnel.
Open in a new tab

In the next part the player climbs for the first time: up a ladder, with a state machine that is ready for more.

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.