| name | rapier-physics-worker |
| description | Guide for implementing Rapier physics engine with Web Workers for off-main-thread physics simulation. Use this when working with physics simulation, rigid body dynamics, collision detection, or Web Worker threading. |
Rapier Physics Engine with Web Workers
This skill provides guidance for implementing the Rapier physics engine in a Web Worker for high-performance off-main-thread physics simulation in browser games.
Technology Stack
- Rapier v0.17+ (Rust → WebAssembly)
- Web Workers for off-main-thread computation
- SharedArrayBuffer for efficient data transfer (optional)
- Fixed timestep at 60Hz (16.67ms per step)
Rapier.js Installation
npm install @dimforge/rapier3d
npm install @dimforge/rapier2d
Football Physics Parameters
Regulation football specifications for realistic simulation:
const BALL_CONFIG = {
shape: 'ball',
radius: 0.143,
mass: 0.41,
restitution: 0.35,
friction: 0.7,
linearDamping: 0.12,
angularDamping: 0.18
};
const PLAYER_CONFIG = {
shape: 'capsule',
halfHeight: 0.9,
radius: 0.3,
mass: 100,
restitution: 0.1,
friction: 0.9
};
Web Worker Architecture
Main Thread → Worker Communication Protocol
interface PhysicsMessage {
type: 'INIT' | 'STEP' | 'THROW' | 'RESET_BALL' | 'MOVE_PLAYER';
data?: any;
}
interface StepMessage {
type: 'STEP';
data: {
delta: number;
};
}
interface ThrowMessage {
type: 'THROW';
data: {
force: [number, number, number];
spin: [number, number, number];
};
}
interface StateUpdateMessage {
type: 'STATE_UPDATE';
timestamp: number;
bodies: {
id: string;
position: [number, number, number];
rotation: [number, number, number, number];
}[];
}
Worker Implementation
import RAPIER from '@dimforge/rapier3d';
let world: RAPIER.World;
let ball: RAPIER.RigidBody;
let ballCollider: RAPIER.Collider;
const FIXED_TIMESTEP = 1 / 60;
let accumulator = 0;
async function initPhysics() {
await RAPIER.init();
const gravity = { x: 0.0, y: -9.81, z: 0.0 };
world = new RAPIER.World(gravity);
const groundColliderDesc = RAPIER.ColliderDesc.cuboid(50.0, 0.1, 50.0);
world.createCollider(groundColliderDesc);
const ballBodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(0.0, 1.8, 0.0)
.setLinearDamping(0.12)
.setAngularDamping(0.18);
ball = world.createRigidBody(ballBodyDesc);
const ballColliderDesc = RAPIER.ColliderDesc.ball(0.143)
.setRestitution(0.35)
.setFriction(0.7)
.setDensity(0.41 / (4/3 * Math.PI * 0.143**3));
ballCollider = world.createCollider(ballColliderDesc, ball);
}
self.onmessage = async (event: MessageEvent) => {
const { type, data } = event.data;
switch (type) {
case 'INIT':
await initPhysics();
self.postMessage({ type: 'READY' });
break;
case 'STEP':
accumulator += data.delta / 1000;
while (accumulator >= FIXED_TIMESTEP) {
world.step();
accumulator -= FIXED_TIMESTEP;
}
const alpha = accumulator / FIXED_TIMESTEP;
self.postMessage({
type: 'STATE_UPDATE',
timestamp: performance.now(),
ball: serializeBall(ball),
alpha
});
break;
case 'THROW':
ball.setTranslation({ x: 0, y: 1.8, z: 0 }, true);
ball.setLinvel({ x: data.force[0], y: data.force[1], z: data.force[2] }, true);
ball.setAngvel({ x: data.spin[0], y: data.spin[1], z: data.spin[2] }, true);
ball.wakeUp();
break;
case 'RESET_BALL':
ball.setTranslation({ x: 0, y: 1.8, z: 0 }, true);
ball.setLinvel({ x: 0, y: 0, z: 0 }, true);
ball.setAngvel({ x: 0, y: 0, z: 0 }, true);
ball.sleep();
break;
}
};
function serializeBall(body: RAPIER.RigidBody) {
const pos = body.translation();
const rot = body.rotation();
const vel = body.linvel();
const angVel = body.angvel();
return {
position: [pos.x, pos.y, pos.z],
velocity: [vel.x, vel.y, vel.z],
rotation: [rot.x, rot.y, rot.z, rot.w],
angularVelocity: [angVel.x, angVel.y, angVel.z],
isActive: body.isMoving()
};
}
Fixed Timestep Physics Loop
The accumulator pattern ensures deterministic physics regardless of frame rate:
let lastTime = performance.now();
function gameLoop(currentTime: number) {
const delta = currentTime - lastTime;
lastTime = currentTime;
physicsWorker.postMessage({
type: 'STEP',
data: { delta }
});
requestAnimationFrame(gameLoop);
}
Interpolation for Smooth Rendering
Interpolate between physics states for buttery-smooth visuals:
let previousState: PhysicsState;
let currentState: PhysicsState;
physicsWorker.onmessage = (event) => {
if (event.data.type === 'STATE_UPDATE') {
previousState = currentState;
currentState = event.data.ball;
}
};
function render(alpha: number) {
if (!previousState || !currentState) return;
const x = lerp(previousState.position[0], currentState.position[0], alpha);
const y = lerp(previousState.position[1], currentState.position[1], alpha);
const z = lerp(previousState.position[2], currentState.position[2], alpha);
const rotation = slerpQuaternion(previousState.rotation, currentState.rotation, alpha);
ballMesh.position.set(x, y, z);
ballMesh.quaternion.set(...rotation);
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
Inline Worker Pattern (No Separate File)
For simpler deployment, create workers from inline code:
function createPhysicsWorker(): Worker {
const workerCode = `
const GRAVITY = -9.81;
const AIR_RESISTANCE = 0.12;
const ANGULAR_DAMPING = 0.18;
const GROUND_Y = 0.143;
const FIXED_TIMESTEP = 1000 / 60;
let state = {
ball: {
position: [0, 1.8, 0],
velocity: [0, 0, 0],
rotation: [0, 0, 0, 1],
angularVelocity: [0, 0, 0],
isActive: false,
},
player: { position: [0, 0, 0] },
};
let accumulator = 0;
function stepPhysics(dt) {
if (!state.ball.isActive) return;
const dtSeconds = dt / 1000;
// Apply gravity
state.ball.velocity[1] += GRAVITY * dtSeconds;
// Apply air resistance
state.ball.velocity[0] *= 1 - AIR_RESISTANCE * dtSeconds;
state.ball.velocity[1] *= 1 - AIR_RESISTANCE * dtSeconds * 0.5;
state.ball.velocity[2] *= 1 - AIR_RESISTANCE * dtSeconds;
// Update position
state.ball.position[0] += state.ball.velocity[0] * dtSeconds;
state.ball.position[1] += state.ball.velocity[1] * dtSeconds;
state.ball.position[2] += state.ball.velocity[2] * dtSeconds;
// Ground collision
if (state.ball.position[1] <= GROUND_Y) {
state.ball.position[1] = GROUND_Y;
state.ball.velocity = [0, 0, 0];
state.ball.isActive = false;
}
}
self.onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'STEP':
accumulator += data.delta;
while (accumulator >= FIXED_TIMESTEP) {
stepPhysics(FIXED_TIMESTEP);
accumulator -= FIXED_TIMESTEP;
}
self.postMessage({ type: 'STATE_UPDATE', state });
break;
case 'THROW':
state.ball.velocity = [...data.force];
state.ball.angularVelocity = [...data.spin];
state.ball.isActive = true;
break;
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob));
}
Collision Detection
world.contactsWith(ballCollider, (otherCollider) => {
const userData = otherCollider.parent()?.userData;
if (userData?.type === 'target') {
onTargetHit(userData.id);
}
});
Integration Parameters
const integrationParameters = new RAPIER.IntegrationParameters();
integrationParameters.dt = 1 / 60;
integrationParameters.numSolverIterations = 4;
integrationParameters.numAdditionalFrictionIterations = 0;
References