Skip to content

feat(rapier): useCharacterController composable for kinematic character controllers #1473

Description

@alvarosabu

Problem

Rapier ships KinematicCharacterController, the standard way to move a player capsule: autostep, slope climb/slide limits, snap-to-ground, pushing dynamic bodies. @tresjs/rapier doesn't wrap it, so the only way in is the raw world:

const { world } = useRapier()
const controller = world.value.createCharacterController(0.02)

From there you have to get four things right before anything moves on screen, and three of them fail silently:

  1. Lifecycleworld.removeCharacterController(controller) on unmount, or the controller leaks with the component.
  2. Collider resolution — the controller needs a Collider, not a RigidBody. RigidBody builds its body in a watch + nextTick, so bodyRef.value?.instance?.collider(0) is undefined for the first frames. Without a guard you get a wasm-side throw; with a naive guard you get a character that never moves and no error to explain why.
  3. Step timing — movement has to be computed in onBeforeStep (fires once per physics substep, with the real timestep), not onBeforeRender. Using the frame delta desyncs from the fixed-timestep accumulator in Physics.vue.
  4. ApplycomputeColliderMovement()computedMovement()setNextKinematicTranslation(), adding the delta to the current translation by hand.

Worth calling out: computeColliderMovement takes a translation delta for the step, not a velocity. Easy to get wrong, and the failure mode (character moving at 1/dt speed) reads like a physics bug rather than a units bug.

For context, @react-three/rapier doesn't appear to wrap this either, so it'd be a differentiator rather than catching up.

Proposed scope: a thin lifecycle wrapper

Own the plumbing, not the gameplay. Gravity, input, camera and jump stay in userland, so the composable doesn't encode opinions about what a "player" is.

const { move, isGrounded, collisions, instance } = useCharacterController(bodyRef, {
  offset: 0.02,
  autostep: { maxHeight: 0.5, minWidth: 0.2, includeDynamicBodies: true },
  snapToGround: 0.5,
  maxSlopeClimbAngle: 50,
  minSlopeSlideAngle: 35,
  applyImpulsesToDynamicBodies: true,
  characterMass: 70,
  up: { x: 0, y: 1, z: 0 },
})

// userland still owns gravity + input
onBeforeStep((dt) => {
  move({ x: dir.x * speed * dt, y: verticalVelocity * dt, z: dir.z * speed * dt })
})

Responsibilities

  • create the controller, apply the options, tear it down on scope dispose
  • resolve the collider from the passed ExposedRigidBody (or accept a collider ref directly), no-op until it exists
  • move(delta) runs compute → read → setNextKinematicTranslation
  • expose isGrounded (from computedGrounded()) and the collision list (numComputedCollisions / computedCollision) as refs
  • expose the raw instance as an escape hatch
  • reactive options wherever Rapier has a setter (setOffset, setMaxSlopeClimbAngle, autostep toggle, …)

Explicitly out of scope

  • gravity integration / vertical velocity
  • jump, coyote time, jump buffering
  • keyboard/gamepad input
  • camera-relative movement, follow cameras

Those belong in a demo or in the docs, and later in @tresjs/cientos if there's demand.

Open questions

  • Slope angles in degrees (ergonomic) or radians (consistent with three)? Leaning degrees with a documented note.
  • Should move() be user-called inside onBeforeStep, or should the composable register its own step callback and take a movement callback instead?
  • Does a <CharacterController> component wrapper make sense on top, given RigidBody + an explicit collider is already two lines?
  • KinematicCharacterController also exposes computedCollision(i). Worth surfacing as events consistent with the existing collision event system?

Definition of done

  • useCharacterController exported from @tresjs/rapier
  • unit tests for lifecycle (create/dispose) and the deferred-collider case
  • playground demo: capsule walking a level with stairs and a slope, pushing dynamic bodies around
  • docs page covering the gotchas above (delta vs velocity, onBeforeStep vs onBeforeRender, collider not ready on the first frames)

Context

Came out of building a player controller against a tres gltf --physics rapier generated level (apps/playground/src/pages/rapier/GeneratedLevel.vue), where the stairs (-convcolonly collision proxies) and the slope make a dynamic capsule unusable and the character controller the only sane option.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions