Ammo.js Kinematic Character Controller Ground Detection
This article explains how to accurately detect if a kinematic
character controller in Ammo.js (the JavaScript port of the Bullet
physics engine) is touching the ground. We will explore the built-in
onGround() method, discuss its limitations, and demonstrate
how to implement a more robust downward raycast solution to ensure
reliable jump and gravity states in your 3D application.
Method 1: Using the
Built-in onGround() Method
The simplest way to detect ground contact is by using the native
method provided by the btKinematicCharacterController
class.
const isGrounded = characterController.onGround();How it Works
The onGround() method returns a boolean value
(true or false). It internally checks if the
character’s collision shape (usually a capsule) is resting on another
collision body beneath it based on the controller’s gravity and
step-handling calculations.
Limitations
While convenient, the built-in onGround() method can
sometimes be unreliable. It may return false momentarily
when the character is walking down slopes, sliding along walls, or
moving quickly over uneven terrain.
Method 2: Implementing a Downward Raycast (Recommended)
To achieve highly reliable ground detection, developers often pair the kinematic character controller with a manual downward raycast. This approach checks for colliders directly beneath the character’s feet.
Step-by-Step Implementation
- Get the Character’s Current Position: Retrieve the origin point of the character’s ghost object or transform.
- Define the Ray Start and End Points:
- Start: The center of the character (or slightly above the feet).
- End: A point projecting straight down, slightly past the bottom of the character’s collision capsule.
- Perform the Raycast: Use the Ammo.js dynamics world to cast the ray and check for intersections.
Code Example
function checkGroundRaycast(dynamicsWorld, characterController, rayLength = 0.1) {
const ghostObject = characterController.getGhostObject();
const transform = ghostObject.getWorldTransform();
const origin = transform.getOrigin();
// Assuming a capsule height. Adjust this based on your character's half-height.
const characterHalfHeight = 1.0;
// Define start and end vectors
const rayStart = new Ammo.btVector3(origin.x(), origin.y() - characterHalfHeight + 0.1, origin.z());
const rayEnd = new Ammo.btVector3(origin.x(), origin.y() - characterHalfHeight - rayLength, origin.z());
// Create a raycast callback
const rayCallback = new Ammo.ClosestRayResultCallback(rayStart, rayEnd);
// Perform raycast
dynamicsWorld.rayTest(rayStart, rayEnd, rayCallback);
let isGrounded = false;
if (rayCallback.hasHit()) {
const hitBody = Ammo.castToVoidPointer(rayCallback.get_m_collisionObject());
const ghostPointer = Ammo.castToVoidPointer(ghostObject);
// Ensure the ray did not hit the character's own ghost object
if (hitBody !== ghostPointer) {
isGrounded = true;
}
}
// Clean up Ammo memory
Ammo.destroy(rayStart);
Ammo.destroy(rayEnd);
Ammo.destroy(rayCallback);
return isGrounded;
}Combining Both Methods for Best Results
For the most stable physics behavior, combine both techniques. You
can consider the character grounded if either
characterController.onGround() returns true OR
your downward raycast registers a hit. This hybrid approach prevents
“floating” jitter on slopes while maintaining precise landing
detection.