Simulate a Pulley System with Ammo.js Constraints
Simulating a realistic pulley system in a 3D web application requires a physics engine capable of handling complex joint constraints. This article provides a step-by-step guide on how to create a functional pulley system using ammo.js, the WebAssembly port of the Bullet physics engine. You will learn how to configure rigid bodies, implement constraints, and use a physics tick callback to enforce the coupling behavior of a pulley rope.
The Challenge of Pulleys in Ammo.js
Ammo.js does not feature a dedicated, out-of-the-box “pulley constraint.” To simulate a pulley realistically, you must choose between two primary methods:
- The Rope Chain Method: Creating a chain of small, connected rigid bodies draped over a collision cylinder. While visually realistic, this method is computationally expensive and prone to instability (joint stretching).
- The Coupled Constraint Method (Recommended): Using standard constraints (like sliders or point-to-point) to restrict the masses to linear paths, and applying a mathematical feedback loop in the physics tick to couple their movements.
This guide focuses on the Coupled Constraint Method, as it offers maximum physical stability and performance.
Step 1: Set Up the Rigid Bodies
First, define the two masses (Weight A and Weight B) that will hang from the pulley. These must be dynamic rigid bodies.
// Helper function to create a dynamic box
function createWeight(mass, x, y, z, width, height, depth) {
const transform = new Ammo.btTransform();
transform.setIdentity();
transform.setOrigin(new Ammo.btVector3(x, y, z));
const motionState = new Ammo.btDefaultMotionState(transform);
const colShape = new Ammo.btBoxShape(new Ammo.btVector3(width * 0.5, height * 0.5, depth * 0.5));
const localInertia = new Ammo.btVector3(0, 0, 0);
colShape.calculateLocalInertia(mass, localInertia);
const rbInfo = new Ammo.btRigidBodyConstructionInfo(mass, motionState, colShape, localInertia);
const body = new Ammo.btRigidBody(rbInfo);
// Prevent the body from rotating to keep the simulation simple
body.setAngularFactor(new Ammo.btVector3(0, 0, 0));
physicsWorld.addRigidBody(body);
return body;
}
const weightA = createWeight(2.0, -2, 5, 0, 1, 1, 1); // 2kg mass
const weightB = createWeight(1.0, 2, 5, 0, 1, 1, 1); // 1kg massStep 2: Restrict Movement Using Slider Constraints
To ensure both weights only move vertically (simulating them hanging
straight down from the pulley wheels), attach each weight to a vertical
btSliderConstraint.
function addVerticalSlider(body, anchorX) {
const localA = new Ammo.btTransform();
localA.setIdentity();
localA.getBasis().setEulerZYX(0, 0, Math.PI / 2); // Rotate axis to point vertically (Y-axis)
localA.setOrigin(new Ammo.btVector3(anchorX, 5, 0));
const localB = new Ammo.btTransform();
localB.setIdentity();
// Use a fixed reference frame in the world
const slider = new Ammo.btSliderConstraint(body, localB, true);
// Allow free movement along the Y axis
slider.setLowerLinLimit(-10.0);
slider.setUpperLinLimit(10.0);
physicsWorld.addConstraint(slider, true);
}
addVerticalSlider(weightA, -2);
addVerticalSlider(weightB, 2);Step 3: Implement the Pulley Coupling Logic
A true pulley system enforces the rule: when Weight A moves down by \(X\) units, Weight B must move up by \(X\) units.
To enforce this constraint in Ammo.js without causing jitter, use an internal tick callback. This callback calculates the velocities of both bodies and applies counter-forces to preserve the total “rope length.”
const ROPE_LENGTH = 10.0;
const PULLEY_Y = 10.0; // Y-coordinate of the pulley axle
physicsWorld.setInternalTickCallback((world, timeStep) => {
const transformA = new Ammo.btTransform();
const transformB = new Ammo.btTransform();
weightA.getMotionState().getWorldTransform(transformA);
weightB.getMotionState().getWorldTransform(transformB);
const posA = transformA.getOrigin().y();
const posB = transformB.getOrigin().y();
// Calculate distances from the pulley axle
const distA = PULLEY_Y - posA;
const distB = PULLEY_Y - posB;
const currentLength = distA + distB;
// Calculate length error
const error = ROPE_LENGTH - currentLength;
// Get current vertical velocities
const velA = weightA.getLinearVelocity().y();
const velB = weightB.getLinearVelocity().y();
// The sum of velocities along the rope direction should be zero: -velA - velB = 0
const relativeVelocity = -velA - velB;
// Proportional-Derivative (PD) control parameters for constraint enforcement
const kKp = 15.0; // Spring constant for position correction
const kKd = 1.5; // Damping constant to prevent oscillations
// Calculate corrective force
const correctionForce = (error * kKp) + (relativeVelocity * kKd);
// Apply equal and opposite vertical forces to simulate the rope tension
const forceVecA = new Ammo.btVector3(0, -correctionForce, 0);
const forceVecB = new Ammo.btVector3(0, correctionForce, 0);
weightA.applyCentralForce(forceVecA);
weightB.applyCentralForce(forceVecB);
});Step 4: Fine-Tuning the Physics
To ensure the simulation remains stable under varying frame rates:
- Mass Ratios: Avoid extreme differences in mass (e.g., 1kg vs 1000kg). Keep mass ratios under 1:10 for optimal stability.
- Sub-stepping: Ensure your main loop calls
physicsWorld.stepSimulation(deltaTime, 10, 1/60)with adequate sub-steps (the second argument) to keep the constraint callback precise. - Damping: If the weights bounce excessively,
increase linear damping on the rigid bodies using
body.setDamping(0.1, 0.0);.