rapier-3d-character-controller.component overview
Table of contents
- utils
- Rapier3dCharacterControllerComponent (class)
- syncColliderTransform (method)
- ignoredBodiesFilterPredicate (method)
- move (method)
- pushDynamicBodies (method)
- computeGroundNormal (method)
- clone (method)
- addToWorld (method)
- removeFromWorld (method)
- dispose (method)
- entity (property)
- name (property)
- radius (property)
- centersDistance (property)
- ignoredBodies (property)
- _nativeBody (property)
- _nativeCollider (property)
- _nativeController (property)
- debugBodySettings (property)
- collisionGroups (property)
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