Implementing btKinematicCharacterController in Ammo.js
This article provides a step-by-step guide on how to successfully
implement a kinematic character controller using
btKinematicCharacterController in Ammo.js, the JavaScript
port of the Bullet physics engine. You will learn how to configure the
necessary physics world components, set up a pair-caching ghost object,
initialize the character controller, and handle user input to achieve
smooth, collision-aware character movement in your 3D applications.
Understanding the Kinematic Character Controller
Unlike dynamic rigid bodies that are fully simulated by gravity and collision forces, a kinematic character controller (KCC) is driven directly by your game logic code. It detects collisions and slides along walls but does not react to external physical forces unless you program it to do so. This makes it the ideal choice for player characters where precise, responsive movement control is required.
Step 1: Configure the Physics World for Ghost Objects
Before creating the character controller, your Bullet/Ammo.js physics world must be configured to handle ghost objects. Ghost objects are specialized collision objects that can detect overlaps without causing physical responses.
You must register a ghost pair callback with the physics world’s overlapping pair cache. Without this step, the character controller will not detect collisions.
// Initialize the standard physics world components
const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
const broadphase = new Ammo.btDbvtBroadphase();
const solver = new Ammo.btSequentialImpulseConstraintSolver();
const physicsWorld = new Ammo.btDiscreteDynamicsWorld(
dispatcher,
broadphase,
solver,
collisionConfiguration
);
// Crucial step: Enable ghost pair callbacks
const ghostPairCallback = new Ammo.btGhostPairCallback();
physicsWorld.getPairCache().setInternalGhostPairCallback(ghostPairCallback);Step 2: Create the Ghost Object and Collision Shape
The character’s physical presence is represented by a
btPairCachingGhostObject. A capsule shape is recommended
for characters because its rounded bottom prevents getting stuck on
uneven terrain or staircases.
const height = 1.8;
const radius = 0.5;
// Create the capsule shape
const shape = new Ammo.btCapsuleShape(radius, height);
// Create the ghost object
const ghostObject = new Ammo.btPairCachingGhostObject();
const transform = new Ammo.btTransform();
transform.setIdentity();
transform.setOrigin(new Ammo.btVector3(0, 5, 0)); // Starting position
ghostObject.setWorldTransform(transform);
ghostObject.setCollisionShape(shape);
ghostObject.setCollisionFlags(16); // CF_CHARACTER_OBJECT flagStep 3: Initialize the Kinematic Character Controller
With the ghost object and shape prepared, you can now instantiate the
btKinematicCharacterController. This object handles the
mathematical calculations for movement, gravity, and step-climbing.
const stepHeight = 0.35; // Maximum height of an obstacle the character can step over
const upAxis = 1; // 1 represents the Y-axis as "up"
const characterController = new Ammo.btKinematicCharacterController(
ghostObject,
shape,
stepHeight,
upAxis
);
// Configure gravity and jump settings
characterController.setGravity(new Ammo.btVector3(0, -9.8 * 3, 0));
characterController.setJumpSpeed(10);
characterController.setMaxJumpHeight(2.0);Step 4: Add Objects to the Physics World
To make the character active, you must add both the ghost object and
the character controller to your physics world. Use the collision filter
flags btBroadphaseProxy::CharacterFilter and
btBroadphaseProxy::StaticFilter to control what the
character collides with.
const characterFilter = 32; // Default CharacterFilter value
const staticFilter = 1 | 2; // Collide with static/default objects
physicsWorld.addCollisionObject(
ghostObject,
characterFilter,
staticFilter
);
physicsWorld.addAction(characterController);Step 5: Handling Movement and Input
To move the character, calculate a walk direction vector based on user input and pass it to the controller. The controller will resolve collisions and update the position of the ghost object during the physics simulation step.
const walkDirection = new Ammo.btVector3(0, 0, 0);
const speed = 0.1;
function updatePlayerMovement(keysPressed) {
let moveX = 0;
let moveZ = 0;
if (keysPressed['W']) moveZ -= 1;
if (keysPressed['S']) moveZ += 1;
if (keysPressed['A']) moveX -= 1;
if (keysPressed['D']) moveX += 1;
// Set walking direction
walkDirection.setValue(moveX * speed, 0, moveZ * speed);
characterController.setWalkDirection(walkDirection);
// Handle jumping
if (keysPressed['Space'] && characterController.canJump()) {
characterController.jump();
}
}Step 6: Syncing with your Render Engine
To display the movement on screen, you must copy the transform of the ghost object to your visual mesh (such as a Three.js Object3D) during your rendering loop.
const tempTransform = new Ammo.btTransform();
function tick(deltaTime) {
// Step physics world
physicsWorld.stepSimulation(deltaTime, 10);
// Get the updated transform from the physics engine
const ghostTransform = ghostObject.getWorldTransform();
const origin = ghostTransform.getOrigin();
const rotation = ghostTransform.getRotation();
// Apply coordinates to your visual mesh (e.g., Three.js mesh)
visualMesh.position.set(origin.x(), origin.y(), origin.z());
visualMesh.quaternion.set(rotation.x(), rotation.y(), rotation.z(), rotation.w());
}