Skip to content
DasPeTe Tutorials

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

Part 4: Slopes, Stairs and Steps

Walk along slopes instead of taking off from them, decide what is too steep, walk up stairs on an invisible ramp like most games do, and stay on the ground over the top of a slope.

Quest objectives

  • Turn the velocity along the ground, uphill and downhill
  • Decide which slopes are too steep with maxSlopeCosine
  • Know how high an edge the capsule gets over by itself
  • Walk up stairs on an invisible ramp
  • Stay on the ground when it drops away under the feet
  • Keep rays from hitting the player with collision layers

At the end of part 3 the HUD showed what is wrong: on the stairs the player hops from step to step, and on the way down it flies a little after each one. Both come from the same thing. The velocity the player asks for is flat, and the ground is not.

Along the ground

Walking downhill with a flat velocity, the player moves away from the slope, and gravity needs a moment to pull them back onto it: they take off, land and take off again. Uphill, the controller has to turn the flat velocity into the slope itself.

The fix is to turn the velocity into the plane of the ground before it is used. checkSupport() reports that plane as averageSurfaceNormal, the direction the ground faces. Subtracting the part of the velocity that points along the normal leaves the part along the ground, and scaling it back to the old length keeps the speed. Add the helpers to the end of src/player.ts:

src/player.ts
function capsule(height: number, scene: Scene): PhysicsShapeCapsule {
  const half = height / 2 - PLAYER.radius;
  const shape = new PhysicsShapeCapsule(new Vector3(0, half, 0), new Vector3(0, -half, 0), PLAYER.radius, scene);
  // A member of the player layer that only collides with the world, so queries with this
  // shape do not find the player's own capsule.
  shape.filterMembershipMask = LAYER.player;
  shape.filterCollideMask = SOLID;
  return shape;
}

/** Turns a flat velocity into one along the ground, at the same speed. */
function alongGround(velocity: Vector3, normal: Vector3): void {
  const speed = velocity.length();
  if (speed === 0) return;
  velocity.subtractInPlace(normal.scale(Vector3.Dot(velocity, normal)));
  velocity.normalize().scaleInPlace(speed);
}

capsule() comes into play further down. walk() uses alongGround() on the target velocity, so the player walks along slopes at walking speed, uphill and downhill:

src/player.ts
private walk(dt: number, support: CharacterSurfaceInfo): void {
    const grounded = support.supportedState === CharacterSupportedState.SUPPORTED;
    const drop = grounded ? 0 : this.groundBelow();
    if (drop === null) {
      this.mode = 'air';
      this.air(dt, support);
      return;
    }

    const target = this.wish(PLAYER.walkSpeed);
    if (grounded) alongGround(target, support.averageSurfaceNormal);
    approach(this.velocity, target, PLAYER.groundAcceleration * dt);
    // Just off the ground, such as over the top of a slope: move down onto it this frame.
    if (!grounded) this.velocity.y = -drop / dt;
  }

  /**
   * How far below the feet walkable ground is, if it is close: then the player is still
   * walking. Over the top of a slope the ground falls away faster than gravity pulls.
   */
  private groundBelow(): number | null {
    const center = this.controller.getPosition();
    const feet = center.y - this.controller.footOffset;
    const hit = raycast(center, new Vector3(center.x, feet - PLAYER.snapDistance, center.z));
    if (!hit || hit.hitNormalWorld.y < this.controller.maxSlopeCosine) return null;
    return feet - hit.hitPointWorld.y;
  }

Too steep

The controller decides which ground counts as walkable with maxSlopeCosine: the cosine of the steepest angle. On steeper ground, checkSupport() reports SLIDING, the player is in the air mode and gravity pulls them down the slope. The default is 60 degrees. The project sets it from the settings, so it is in one place with the other numbers. Add these two to PLAYER in src/settings.ts:

src/settings.ts
/**
   * The steepest ground to walk on. It also decides how high an edge the rounded bottom of
   * the capsule rolls over: about radius * (1 - cos(angle)) above its bottom, which floats a
   * few centimeters over the ground. The 20 cm steps in the level work.
   */
  maxSlopeDegrees: 60,
  /** Ground this close under the feet still counts as walking. */
  snapDistance: 0.3,

Steps and edges

The bottom of the capsule is round. When it touches an edge, the contact faces up and forward, like a slope, and if that slope is walkable the capsule rolls over the edge. That works up to about radius × (1 − cos(angle)) above the bottom of the capsule: 0.35 × (1 − cos 60°) = 17.5 cm. The controller keeps the capsule a few centimeters above the ground, so in practice the 15 cm curb is no obstacle, and the 20 cm steps work too, with the hops from part 3.

Stairs are ramps

Rolling over twenty edges in a row is what made the stairs bumpy. Most games solve it with a trick: the steps are only for show, and the player walks up an invisible ramp laid over their front edges. It feels the way stairs should, and the camera glides instead of shaking.

The level needs two new kinds of block for that: ghost blocks that are drawn but have no physics body, and hidden ones that have a body but are not drawn. Add them to the options in src/level.ts:

src/level.ts
interface BlockOptions {
  /** Width (x), height (y) and depth (z) in meters. */
  size: [number, number, number];
  /** Where the center of the block goes. */
  at: [number, number, number];
  color: ColorName;
  /** Rotation around the x axis, in degrees. */
  tilt?: number;
  /** Decoration only: no collision. */
  ghost?: boolean;
  /** Collision only: not drawn. */
  hidden?: boolean;
}

Replace block() in the Builder. The line with filterMembershipMask belongs to the layers below, and needs import { LAYER } from './physics.ts'; at the top of the file:

src/level.ts
block(name: string, options: BlockOptions): Mesh {
    const [width, height, depth] = options.size;
    // One grid square per meter on every face, whatever the size of the block.
    const faceUV = [
      new Vector4(0, 0, width, height),
      new Vector4(0, 0, width, height),
      new Vector4(0, 0, depth, height),
      new Vector4(0, 0, depth, height),
      new Vector4(0, 0, width, depth),
      new Vector4(0, 0, width, depth),
    ];
    const mesh = CreateBox(name, { width, height, depth, faceUV }, this.scene);
    mesh.position.set(...options.at);
    mesh.rotation.x = -((options.tilt ?? 0) * Math.PI) / 180;
    mesh.material = this.material(options.color);
    mesh.isVisible = !options.hidden;
    if (!options.ghost) {
      // Mass 0 makes a static body: it never moves, and costs almost nothing.
      const body = new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass: 0 }, this.scene);
      body.shape.filterMembershipMask = LAYER.world;
    }
    mesh.freezeWorldMatrix();
    return mesh;
  }

ramp() passes hidden on:

src/level.ts
/**
   * A slab that rises along +z from the floor at `z`, by `rise` over `run` meters. Its top
   * surface starts at floor level, so there is no edge to trip over.
   */
  ramp(name: string, options: { x: number; width: number; z: number; rise: number; run: number; hidden?: boolean }): void {
    const { x, width, z, rise, run, hidden } = options;
    const angle = Math.atan2(rise, run);
    const thickness = 0.2;
    // The middle of the top surface, moved down to the middle of the slab.
    const y = rise / 2 - (Math.cos(angle) * thickness) / 2;
    const middle = z + run / 2 + (Math.sin(angle) * thickness) / 2;
    this.block(name, {
      size: [width, thickness, Math.hypot(rise, run)],
      at: [x, y, middle],
      tilt: (angle * 180) / Math.PI,
      color: 'steps',
      hidden,
    });
  }

And the stairs become ghost steps with a hidden ramp from the floor in front of the first step to the edge of the last one, 2 m up over 3 m. Replace them in buildLevel():

src/level.ts
// Ahead: two staircases up to one platform. The low steps on the left are only for show:
  // the player walks up an invisible ramp over their front edges, as in most games, which
  // is smoother than climbing every step. The tall steps on the right have to be jumped.
  for (let i = 0; i < 10; i += 1) {
    const height = 0.2 * (i + 1);
    build.block(`step-${i}`, { size: [3, height, 0.3], at: [0, height / 2, 6.15 + 0.3 * i], color: 'steps', ghost: true });
  }
  build.ramp('stairs-ramp', { x: 0, width: 3, z: 5.7, rise: 2, run: 3, hidden: true });
  for (let i = 0; i < 4; i += 1) {
    const height = 0.5 * (i + 1);
    build.block(`tall-step-${i}`, { size: [2, height, 0.75], at: [2.5, height / 2, 6.375 + 0.75 * i], color: 'steps' });
  }
  build.block('stairs-top', { size: [5, 2, 3.3], at: [1, 1, 10.35], color: 'steps' });

The platform at the top now starts where the ramp ends, at the edge of the last step, so there is no gap between them.

Staying on the ground

Over the top of a slope, at the top of the stairs on the way down, the ground falls away under the feet faster than gravity pulls. For a few frames the player is in the air, and the HUD flickers.

groundBelow() in walk() above handles it. When the controller finds no ground, a ray looks straight down from the center of the capsule. If walkable ground is less than snapDistance below the feet, the player is still walking, and the vertical speed is set so that this frame moves them down by exactly that distance: -drop / dt. The controller stops them on the ground. Only when there is no ground within 30 cm do they fall.

Collision layers

That ray starts in the middle of the player's own capsule, which is a physics body too, and the first thing it would hit is the player. Havok's answer is collision filters: every shape is a member of some layers, one bit each, and collides with others. Two shapes only collide if each one's members match the other's mask, and a ray or a shape cast has such masks too.

Replace src/physics.ts. It keeps the Havok plugin for queries now, and gets the layers and a helper for rays:

src/physics.ts
import {
  HavokPlugin,
  PhysicsRaycastResult,
  RegisterJoinedPhysicsEngineComponent,
  Vector3,
  type Scene,
} from '@babylonjs/core/pure';
import '@babylonjs/core/Physics/joinedPhysicsEngineComponent.types';
import HavokPhysics from '@babylonjs/havok';

RegisterJoinedPhysicsEngineComponent();

/**
 * Collision layers, one bit each. A shape is a member of some layers and collides with
 * others, and so is every query: rays and shape casts that look for the world skip the
 * player's own capsule.
 */
export const LAYER = {
  world: 1,
  player: 2,
};

/** What counts as solid: the ground and walls. */
export const SOLID = LAYER.world;

let plugin: HavokPlugin;

/** Loads Havok's WebAssembly module and turns physics on for the scene. */
export async function enablePhysics(scene: Scene): Promise<void> {
  const havok = await HavokPhysics();
  plugin = new HavokPlugin(true, havok);
  scene.enablePhysics(new Vector3(0, -9.81, 0), plugin);
}

const rayResult = new PhysicsRaycastResult();

/** The first solid surface between two points, or null. Shared result: read it right away. */
export function raycast(from: Vector3, to: Vector3, layers = SOLID): PhysicsRaycastResult | null {
  plugin.raycast(from, to, rayResult, { collideWith: layers });
  return rayResult.hasHit ? rayResult : null;
}

The level's blocks are members of the world layer, which is what the filterMembershipMask line in block() does. The player gets a capsule of its own from capsule(): a member of player that only collides with what is solid. A ray that looks for SOLID passes through the player as if it were not there.

The ray helper reuses one result object rather than creating one per ray. That saves garbage, but the result is overwritten by the next ray, so read it right away.

Give the controller the new capsule and the slope limit in the constructor:

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

The imports at the top of src/player.ts now look like this:

src/player.ts
import {
  CharacterSupportedState,
  PhysicsCharacterController,
  PhysicsShapeCapsule,
  Vector3,
  type CharacterSurfaceInfo,
  type Scene,
} from '@babylonjs/core/pure';
import type { Input } from './input.ts';
import type { Level } from './level.ts';
import type { Look } from './look.ts';
import { LAYER, SOLID, raycast } from './physics.ts';
import { PLAYER } from './settings.ts';

Try it

Walk up the stairs: the HUD stays at walk all the way up and all the way down again, and the speed on the stairs reads 3.3 m/s, because the HUD shows the speed across the ground and the rest goes up. Walk over the curb on the right, up the 20 and 40 degree ramps on the left, and try the 70 degree one: you slide back down.

The project at the end of part 4: stairs, ramps and the curb, without hopping.
Open in a new tab

In the next part the player learns to run and jump, with a little help that makes jumps feel fair.

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.