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:
- Lifecycle —
world.removeCharacterController(controller) on unmount, or the controller leaks with the component.
- 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.
- 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.
- Apply —
computeColliderMovement() → 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.
Problem
Rapier ships
KinematicCharacterController, the standard way to move a player capsule: autostep, slope climb/slide limits, snap-to-ground, pushing dynamic bodies.@tresjs/rapierdoesn't wrap it, so the only way in is the raw world:From there you have to get four things right before anything moves on screen, and three of them fail silently:
world.removeCharacterController(controller)on unmount, or the controller leaks with the component.Collider, not aRigidBody.RigidBodybuilds its body in awatch+nextTick, sobodyRef.value?.instance?.collider(0)isundefinedfor 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.onBeforeStep(fires once per physics substep, with the real timestep), notonBeforeRender. Using the frame delta desyncs from the fixed-timestep accumulator inPhysics.vue.computeColliderMovement()→computedMovement()→setNextKinematicTranslation(), adding the delta to the current translation by hand.Worth calling out:
computeColliderMovementtakes a translation delta for the step, not a velocity. Easy to get wrong, and the failure mode (character moving at1/dtspeed) reads like a physics bug rather than a units bug.For context,
@react-three/rapierdoesn'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.
Responsibilities
ExposedRigidBody(or accept a collider ref directly), no-op until it existsmove(delta)runs compute → read →setNextKinematicTranslationisGrounded(fromcomputedGrounded()) and the collision list (numComputedCollisions/computedCollision) as refsinstanceas an escape hatchsetOffset,setMaxSlopeClimbAngle, autostep toggle, …)Explicitly out of scope
Those belong in a demo or in the docs, and later in
@tresjs/cientosif there's demand.Open questions
move()be user-called insideonBeforeStep, or should the composable register its own step callback and take a movement callback instead?<CharacterController>component wrapper make sense on top, givenRigidBody+ an explicit collider is already two lines?KinematicCharacterControlleralso exposescomputedCollision(i). Worth surfacing as events consistent with the existing collision event system?Definition of done
useCharacterControllerexported from@tresjs/rapieronBeforeStepvsonBeforeRender, collider not ready on the first frames)Context
Came out of building a player controller against a
tres gltf --physics rapiergenerated level (apps/playground/src/pages/rapier/GeneratedLevel.vue), where the stairs (-convcolonlycollision proxies) and the slope make a dynamic capsule unusable and the character controller the only sane option.