Skip to content

rapier-3d-character-controller.component overview


Table of contents


utils

Rapier3dCharacterControllerComponent (class)

A capsule-shaped kinematic character controller backed by Rapier's own KinematicCharacterController (world.createCharacterController). move() is made fully synchronous (see ICharacterController3dComponent's doc for why this matters) by never relying on setNextKinematicTranslation + a later world.step() to actually reposition the body - the plain (non-"next") RigidBody.setTranslation/setRotation is used instead, immediately followed by World.propagateModifiedBodyPositionsToColliders so the capsule's new position is visible to Rapier's collider state (and thus to the next move() call, or to any raycast) without needing a simulation step in between. setNextKinematicTranslation/setNextKinematicRotation are additionally set to the same target so that dynamic bodies pushed by the character still get a reasonable velocity estimate on whatever world.step() happens to run afterwards - this is a nice-to-have, not load-bearing for the synchronous contract.

Note: a collider only enters Rapier's broad-phase as part of a World.step() - a level's static geometry (or this character itself) created and never stepped even once is invisible to move()'s sweep test, exactly as it would be to world.raycast(). This is a pre-existing engine property, not specific to this component; a normal per-frame game loop that calls physicsWorld.simulate() every tick already satisfies it after the first tick.

Note: unlike Rapier3dRigidBodyComponent/Rapier3dTriggerComponent, this component's native body handle is not registered in Rapier3dWorldComponent.handleIdEntityMap - world.raycast() cannot currently resolve a hit against a character controller back to this component (it will simply be absent from RaycastResult.hitBody). Wiring that up would require widening the reverse-map's and raycast()'s return-type generics repo-wide for a corner case outside this interface's contract; left as a documented limitation rather than done speculatively.

Signature

export declare class Rapier3dCharacterControllerComponent {
  constructor(
    protected readonly world: Rapier3dWorldComponent,
    protected readonly options: Required<CharacterController3dOptions>,
    protected _bodyDescr: RigidBodyDesc
  )
}

syncColliderTransform (method)

Makes any position/rotation change applied directly to _nativeBody (outside of move(), e.g. via the position/rotation setters) immediately visible to Rapier's collider state, without requiring a world.step() - see the class doc for why this matters. The pinned @dimforge/rapier3d-compat build only exposes propagateModifiedBodyPositionsToColliders() for this (no separate QueryPipeline/updateSceneQueries object to rebuild - the character controller queries World's live broadPhase/narrowPhase directly), so that's the only call needed here.

Signature

private syncColliderTransform(): void

ignoredBodiesFilterPredicate (method)

Builds computeColliderMovement's filterPredicate from ignoredBodies - undefined when empty (the common case) rather than an always-true closure, so an empty ignoredBodies set costs nothing extra per query. Compares by RigidBody.handle (a plain numeric id), not object identity - Collider.parent() isn't guaranteed to return the same wrapper instance across calls for the pinned @dimforge/rapier3d-compat build, only the same underlying native body.

Signature

private ignoredBodiesFilterPredicate(): ((collider: Collider) => boolean) | undefined

move (method)

Signature

move(desiredTranslation: Point3, dt?: number): void

pushDynamicBodies (method)

Shoves any dynamic body this tick's sweep bumped into - see addToWorld's doc for why this is hand-rolled rather than Rapier's own setApplyImpulsesToDynamicBodies. Mirrors AmmoCharacterControllerComponent.pushDynamicBody exactly: models the contact as a simple inelastic collision against a virtual body of mass options.pushMass moving at characterSpeed (this tick's horizontal displacement - vertical/jump motion never pushes anything sideways - converted to a real m/s via dt, not a raw per-tick distance), driving the hit body's velocity along the push direction towards characterSpeed * pushMass / (pushMass + bodyMass) and only ever adding forward velocity, never removing any (so a body already outrunning the character in that direction is left alone). computedCollision() already has everything needed - populated by the computeColliderMovement call above regardless of this method's own logic, so no extra sweep/query is needed to reach it.

Signature

private pushDynamicBodies(desiredTranslation: Point3, dt: number | undefined): void

computeGroundNormal (method)

Rapier's character controller doesn't expose a single "ground normal" directly - only a list of per-obstacle collisions (computedCollision) from the last computeColliderMovement call, each with its own contact normal. Best-effort approach: scan those collisions and return whichever normal points most nearly along up (i.e. the most floor-like of the bunch, however steep it actually is) - this deliberately does not discard a candidate merely for being steep (e.g. balanced on the flank of a sphere/cylinder, far past maxSlopeClimbAngleRad): that judgment belongs entirely to CharacterController3dEntity.isWalkableGround at the core level, which needs the real contact normal to make it, not a value already pre-filtered down here. An earlier version discarded any candidate with dot(normal, up) <= 0.1 and fell back to the plain up vector when nothing cleared that bar - which silently reported perfectly-flat ground for a character resting against a normal steep enough to fail that same threshold, defeating isWalkableGround entirely (confirmed empirically: a character run-and-jumped onto the flank of a static sphere, landing on a contact whose true outward normal was ~70° off up - well past the default ~50° maxSlopeClimbAngleRad - permanently reported groundNormal: {0,0,1} instead, so the core entity kept treating it as resting on flat ground and it never slid off, visibly stuck balanced on a sliver of the sphere even with every input released).

numComputedCollisions() itself is frequently 0 on a call that is still genuinely grounded - computeColliderMovement doesn't record an entry for a character caught by snap-to-ground alone (no obstacle actually blocked the desired movement that call), which in practice is most idle ticks: a character standing still (desiredTranslation exactly {0,0,0}, e.g. player released every key) has nothing for the sweep to hit, so it settles into being grounded via snap alone, over and over, tick after tick, without ever producing a fresh collision entry again. Guessing flat up on every such tick is exactly the bug above, just via a different, far more common path than "no collision was ever recorded" suggests - it's not a rare edge case, it's what happens the very first idle tick after any landing (including this one, right after the collision that did populate the true steep normal above). Fix: on a 0-collision grounded call, reuse whichever normal this same field already held before this call (the character's own contact geometry hasn't changed just because this particular call didn't happen to re-sweep it) rather than guessing - move() only overwrites this._groundNormal with this method's return value after calling it, so reading the field here still sees the previous call's result. Only when there is no prior normal to reuse either (the very first grounded call ever, landing exactly via snap with nothing recorded yet) does this fall back to the plain up vector. Returns null if not grounded at all - _groundNormal naturally clears itself the moment the character goes airborne, so a later landing never reuses a stale value from a previous, unrelated surface.

Signature

private computeGroundNormal(): Point3 | null

clone (method)

Signature

clone(): Rapier3dCharacterControllerComponent

addToWorld (method)

Signature

addToWorld(world: Rapier3dGgWorld): void

removeFromWorld (method)

Signature

removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void

dispose (method)

Signature

dispose(): void

entity (property)

Signature

entity: Entity3d<Gg3dWorldTypeDocRepo> | null

name (property)

Signature

name: string

radius (property)

Signature

readonly radius: number

centersDistance (property)

Signature

readonly centersDistance: number

ignoredBodies (property)

See ICharacterController3dComponent.ignoredBodies's doc. Consulted fresh every move() call via computeColliderMovement's own filterPredicate - unlike AmmoCharacterControllerComponent (which has to fake this by temporarily pulling ignored bodies out of the collision world), Rapier's character controller supports excluding specific colliders from a single query natively, so no such trick is needed here.

Signature

readonly ignoredBodies: Set<Rapier3dRigidBodyComponent>

_nativeBody (property)

Signature

_nativeBody: RigidBody | null

_nativeCollider (property)

Signature

_nativeCollider: Collider | null

_nativeController (property)

Signature

_nativeController: KinematicCharacterController | null

debugBodySettings (property)

Signature

readonly debugBodySettings: DebugBody3DSettings

collisionGroups (property)

Signature

collisionGroups: number