How to Integrate Planck.js with Three.js?

Integrating a 2D physics engine like Planck.js (a JavaScript rewrite of Box2D) with a 3D WebGL renderer like Three.js is a powerful way to create performant 2D physics simulations, games, or interactive experiences with stunning 3D graphics. This guide outlines the essential steps to connect the two libraries, which involves setting up their respective worlds, mapping a coordinate scaling factor, and implementing an animation loop that copies the position and rotation data from the physics bodies directly onto the 3D visual meshes.

1. Initialize Both Engines

The first step is to set up the independent environments for both libraries. You need to create a Three.js scene, camera, and renderer to handle the visuals, and a Planck.js world to handle the physics calculations. Because Planck.js simulates physics in two dimensions, you will typically map the physics world’s \(x\) and \(y\) coordinates to the Three.js \(x\) and \(y\) (or \(x\) and \(z\)) coordinates.

// Initialize Three.js
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Initialize Planck.js World with gravity
const pl = planck;
const world = pl.World({
  gravity: pl.Vec2(0, -9.8)
});

2. Establish a Scale Factor

Planck.js is tuned to work with MKS (meters, kilograms, seconds) units, where moving objects should ideally be between 0.1 and 10 meters in size. Three.js units, however, are arbitrary and often scaled much larger for rendering purposes. To bridge this gap, define a constant scale factor (e.g., 30 pixels or units in Three.js equals 1 meter in Planck.js) to convert coordinates between the two worlds.

const SCALE = 30; // 30 Three.js units = 1 Planck.js meter

3. Create Paired Physics Bodies and Visual Meshes

For every interactive object in your scene, you must create a rigid body in Planck.js and a corresponding mesh in Three.js. Store a reference to the physics body inside the Three.js mesh userData object, or maintain an array of paired objects, so you can easily link them during the update cycle.

// Create Planck.js Dynamic Body
const body = world.createBody({
  type: 'dynamic',
  position: pl.Vec2(0 / SCALE, 300 / SCALE) // Convert Three.js start position to meters
});
body.createFixture(pl.Box(10 / SCALE, 10 / SCALE), { density: 1.0 });

// Create Matching Three.js Mesh
const geometry = new THREE.BoxGeometry(20, 20, 20); // Width and height match fixture dimensions * SCALE
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// Link them together
mesh.userData.physicsBody = body;

4. Implement the Sync Logic in the Animation Loop

The core of the integration lies within the requestAnimationFrame loop. In each frame, you must step the Planck.js physics world forward in time. Immediately after the physics step, iterate through your objects, retrieve the updated position and angle from the Planck.js body, apply the scale factor, and assign those values to the Three.js mesh.

const timeStep = 1 / 60;

function animate() {
  requestAnimationFrame(animate);

  // Step the physics world
  world.step(timeStep);

  // Sync Three.js mesh with Planck.js body
  const body = mesh.userData.physicsBody;
  const position = body.getPosition();
  const angle = body.getAngle();

  // Apply scale factor and update mesh position
  mesh.position.x = position.x * SCALE;
  mesh.position.y = position.y * SCALE;
  
  // Update mesh rotation (Z-axis for 2D physics in a 3D space)
  mesh.rotation.z = angle;

  // Render the Three.js scene
  renderer.render(scene, camera);
}

animate();