Physics and Collisions in Three.js

Physics simulations add a new dimension of realism to 3D web applications. In this tutorial, we’ll explore how to implement realistic physics and collision detection in Three.js using the cannon-es physics engine. You’ll learn how to create interactive 3D objects that respond to gravity, collisions, and user input. This is the third article in a series about Three.js:

Complete code for the series of blog posts is available on GitHub.

Introduction

Three.js excels at rendering 3D scenes in the browser, but it doesn’t include built-in physics capabilities. For realistic object interactions like bouncing, falling, and colliding, we need to add a physics engine. The cannon-es library is a modern JavaScript physics engine that pairs perfectly with Three.js to create physically accurate simulations.

In this tutorial, we’ll build an interactive physics playground where you can:

  • Create and drop various 3D objects into a scene
  • Adjust physics parameters like gravity and bounciness
  • Visualize physics bodies with wireframes
  • Control the simulation with pause/resume functionality

Advantages of Using Real Physics

Implementing a proper physics engine offers several advantages over manually animating objects:

  1. Realistic Interactions: Objects respond naturally to forces, collisions, and constraints.
  2. Reduced Complexity: The physics engine handles complex calculations for object interactions.
  3. Interactive Experiences: Users can directly interact with objects that respond in physically plausible ways.
  4. Emergent Behaviors: Combinations of simple physics rules can create complex and unexpected results.
  5. Simulation Accuracy: Physics engines like cannon-es implement academically validated physics models.

Using a physics engine is particularly valuable when your application needs to simulate real-world behaviors like bouncing balls, stacking objects, or anything involving gravity and collision response.

Collisions in Animation

There are three main approaches to handling collisions in 3D animations:

  1. Manual Collision Detection: Calculate intersections between objects and respond with custom code. This works for simple cases but becomes unmanageable for complex scenes.
  2. Three.js Built-in Collision: Using raycasting and bounding volumes within Three.js. This approach helps detect collisions but doesn’t handle physics responses like bouncing or sliding.
  3. Physics Engine Integration: Using a dedicated physics engine like cannon-es to handle both collision detection and physics responses. This provides the most realistic and comprehensive solution.

For our tutorial, we’ll use the third approach, integrating cannon-es with Three.js to create a complete physics simulation.

Installing the cannon-es Package

To get started with cannon-es, first install it in your Vue project:

npm install cannon-es

Cannon-es is a JavaScript module that works well with module bundlers like Vite. It’s a maintained fork of the original cannon.js with TypeScript support and performance improvements.

About the cannon-es Package

The cannon-es library provides several key components for physics simulations:

  1. World: The physics simulation container that manages all bodies and constraints.
  2. Body: Physical objects with properties like mass, position, and velocity.
  3. Shape: Geometric representations like Box, Sphere, and Plane for collision detection.
  4. Material: Defines how objects interact on contact (friction, restitution).
  5. Constraint: Connections between bodies like hinges, springs, and distance constraints.

The main workflow when using cannon-es with Three.js is:

  • Create a cannon.js physics world and Three.js scene
  • For each visual object in Three.js, create a corresponding physics body
  • During animation, update the Three.js object positions based on the physics body positions
  • Apply forces, constraints, and user inputs to the physics bodies

Code Sample: Building the Physics Playground

Let’s break down our physics playground component:

  1. First, we set up the standard Three.js environment with a scene, camera, and renderer
  2. We initialize a cannon-es physics world with gravity
  3. We create visual objects (meshes) and corresponding physics bodies
  4. In the animation loop, we step the physics simulation and update the visual objects
  5. We add UI controls to manipulate the physics parameters
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import * as CANNON from 'cannon-es';

const emit = defineEmits(['close']);

const containerRef = ref<HTMLDivElement | null>(null);
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let animationFrameId: number;

// Physics world
let world: CANNON.World;
const timeStep = 1/60;

// Objects
let meshes: THREE.Mesh[] = [];
let bodies: CANNON.Body[] = [];
const objectCount = ref(0);
const gravity = ref(-9.82);
const restitution = ref(0.7); // Bounciness

// UI controls
const showWireframes = ref(false);
const pauseSimulation = ref(false);

const init = () => {
  if (!containerRef.value) return;
  
  // Create scene
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x1a1a2e);
  
  // Create camera
  camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  camera.position.set(0, 5, 10);
  
  // Create renderer
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  containerRef.value.appendChild(renderer.domElement);
  
  // Add orbit controls
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  
  // Setup physics world
  setupPhysicsWorld();
  
  // Add lights
  setupLights();
  
  // Create ground
  createGround();
  
  // Add some initial objects
  addSphere(0, 8, 0, 1);
  addBox(2, 10, 0, 1, 1, 1);
  addBox(-2, 12, 0, 1, 1, 1);
  
  // Handle window resize
  window.addEventListener('resize', onWindowResize);
  
  // Animation loop
  const animate = () => {
    animationFrameId = requestAnimationFrame(animate);
    
    if (!pauseSimulation.value) {
      // Step the physics world
      world.step(timeStep);
      
      // Update mesh positions based on physics bodies
      for (let i = 0; i < meshes.length; i++) {
        meshes[i].position.copy(bodies[i].position as any);
        meshes[i].quaternion.copy(bodies[i].quaternion as any);
      }
    }
    
    controls.update();
    renderer.render(scene, camera);
  };
  
  animate();
};

const setupPhysicsWorld = () => {
  world = new CANNON.World({
    gravity: new CANNON.Vec3(0, gravity.value, 0)
  });
  world.broadphase = new CANNON.SAPBroadphase(world);
  world.allowSleep = true;
};

const setupLights = () => {
  // Ambient light
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
  scene.add(ambientLight);
  
  // Directional light (sun)
  const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
  directionalLight.position.set(5, 10, 7.5);
  directionalLight.castShadow = true;
  
  // Optimize shadow settings
  directionalLight.shadow.mapSize.width = 1024;
  directionalLight.shadow.mapSize.height = 1024;
  directionalLight.shadow.camera.near = 0.5;
  directionalLight.shadow.camera.far = 50;
  directionalLight.shadow.camera.left = -10;
  directionalLight.shadow.camera.right = 10;
  directionalLight.shadow.camera.top = 10;
  directionalLight.shadow.camera.bottom = -10;
  
  scene.add(directionalLight);
};

const createGround = () => {
  // Three.js ground
  const groundGeometry = new THREE.PlaneGeometry(30, 30);
  const groundMaterial = new THREE.MeshStandardMaterial({ 
    color: 0x156289,
    roughness: 0.4,
    metalness: 0.2
  });
  const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
  groundMesh.rotation.x = -Math.PI / 2;
  groundMesh.receiveShadow = true;
  scene.add(groundMesh);
  
  // Cannon.js ground body
  const groundShape = new CANNON.Plane();
  const groundBody = new CANNON.Body({
    mass: 0, // Mass of 0 makes it static
    shape: groundShape
  });
  groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
  world.addBody(groundBody);
};

const addSphere = (x: number, y: number, z: number, radius: number) => {
  // Three.js sphere
  const sphereGeometry = new THREE.SphereGeometry(radius, 32, 32);
  const sphereMaterial = new THREE.MeshStandardMaterial({
    color: Math.random() * 0xffffff,
    roughness: 0.7,
    metalness: 0.3
  });
  const sphereMesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
  sphereMesh.position.set(x, y, z);
  sphereMesh.castShadow = true;
  sphereMesh.receiveShadow = true;
  scene.add(sphereMesh);
  
  // Cannon.js sphere body
  const sphereShape = new CANNON.Sphere(radius);
  const sphereBody = new CANNON.Body({
    mass: 1,
    shape: sphereShape,
    position: new CANNON.Vec3(x, y, z),
    material: new CANNON.Material({ restitution: restitution.value })
  });
  world.addBody(sphereBody);
  
  // Add wireframe if enabled
  if (showWireframes.value) {
    addWireframe(sphereMesh);
  }
  
  // Track objects
  meshes.push(sphereMesh);
  bodies.push(sphereBody);
  objectCount.value++;
};

const addBox = (x: number, y: number, z: number, width: number, height: number, depth: number) => {
  // Three.js box
  const boxGeometry = new THREE.BoxGeometry(width, height, depth);
  const boxMaterial = new THREE.MeshStandardMaterial({
    color: Math.random() * 0xffffff,
    roughness: 0.7,
    metalness: 0.3
  });
  const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);
  boxMesh.position.set(x, y, z);
  boxMesh.castShadow = true;
  boxMesh.receiveShadow = true;
  scene.add(boxMesh);
  
  // Cannon.js box body
  const boxShape = new CANNON.Box(new CANNON.Vec3(width/2, height/2, depth/2));
  const boxBody = new CANNON.Body({
    mass: 1,
    shape: boxShape,
    position: new CANNON.Vec3(x, y, z),
    material: new CANNON.Material({ restitution: restitution.value })
  });
  world.addBody(boxBody);
  
  // Add wireframe if enabled
  if (showWireframes.value) {
    addWireframe(boxMesh);
  }
  
  // Track objects
  meshes.push(boxMesh);
  bodies.push(boxBody);
  objectCount.value++;
};

const addWireframe = (mesh: THREE.Mesh) => {
  const wireframe = new THREE.LineSegments(
    new THREE.WireframeGeometry(mesh.geometry),
    new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.3 })
  );
  mesh.add(wireframe);
};

const toggleWireframes = () => {
  showWireframes.value = !showWireframes.value;
  
  // Add or remove wireframes from all meshes
  meshes.forEach(mesh => {
    // Remove existing wireframes
    mesh.children.forEach(child => {
      if (child instanceof THREE.LineSegments) {
        mesh.remove(child);
        if (child.geometry) child.geometry.dispose();
        if (child.material) {
          if (Array.isArray(child.material)) {
            child.material.forEach(m => m.dispose());
          } else {
            child.material.dispose();
          }
        }
      }
    });
    
    // Add new wireframes if enabled
    if (showWireframes.value) {
      addWireframe(mesh);
    }
  });
};

const updateGravity = () => {
  world.gravity.set(0, gravity.value, 0);
};

const updateRestitution = () => {
  // Create a default material with the new restitution value
  const defaultMaterial = new CANNON.Material('default');
  
  // Update all bodies with the new material
  bodies.forEach(body => {
    // Create a new material for each body
    const bodyMaterial = new CANNON.Material('body');
    bodyMaterial.restitution = restitution.value;
    body.material = bodyMaterial;
  });
  
  // Create a contact material between default and body materials
  const contactMaterial = new CANNON.ContactMaterial(
    defaultMaterial,
    defaultMaterial,
    {
      friction: 0.3,
      restitution: restitution.value
    }
  );
  
  // Remove existing contact materials and add the new one
  world.contactmaterials.length = 0;
  world.addContactMaterial(contactMaterial);
  
  // Set default contact material properties
  world.defaultContactMaterial.restitution = restitution.value;
};

const addRandomObject = () => {
  // Random position above the scene
  const x = (Math.random() - 0.5) * 10;
  const y = 10 + Math.random() * 5;
  const z = (Math.random() - 0.5) * 10;
  
  // 50% chance for sphere or box
  if (Math.random() > 0.5) {
    const radius = 0.5 + Math.random() * 1;
    addSphere(x, y, z, radius);
  } else {
    const size = 0.5 + Math.random() * 1;
    addBox(x, y, z, size, size, size);
  }
};

const resetSimulation = () => {
  // Remove all objects
  meshes.forEach(mesh => {
    scene.remove(mesh);
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) {
      if (Array.isArray(mesh.material)) {
        mesh.material.forEach(m => m.dispose());
      } else {
        mesh.material.dispose();
      }
    }
  });
  
  bodies.forEach(body => {
    world.removeBody(body);
  });
  
  meshes = [];
  bodies = [];
  objectCount.value = 0;
  
  // Add some initial objects
  addSphere(0, 8, 0, 1);
  addBox(2, 10, 0, 1, 1, 1);
  addBox(-2, 12, 0, 1, 1, 1);
};

const onWindowResize = () => {
  if (!camera || !renderer) return;
  
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
};

const cleanup = () => {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }
  
  window.removeEventListener('resize', onWindowResize);
  
  if (containerRef.value && renderer) {
    containerRef.value.removeChild(renderer.domElement);
  }
  
  if (controls) {
    controls.dispose();
  }
  
  // Clean up all meshes
  meshes.forEach(mesh => {
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) {
      if (Array.isArray(mesh.material)) {
        mesh.material.forEach(m => m.dispose());
      } else {
        mesh.material.dispose();
      }
    }
  });
};

onMounted(() => {
  init();
});

onBeforeUnmount(() => {
  cleanup();
});
</script>

<template>
  <div class="scene-container">
    <div ref="containerRef" class="three-container"></div>
    <button @click="emit('close')" class="close-button">Close Scene</button>
    <div class="controls-container">
      <div class="control-panel">
        <h3>Physics Controls</h3>
        
        <div class="control-group">
          <button @click="addRandomObject" class="control-button">Add Random Object</button>
          <div class="object-counter">Objects: {{ objectCount }}</div>
        </div>
        
        <div class="control-group">
          <label for="gravity-slider">Gravity: {{ gravity }}</label>
          <input 
            type="range" 
            id="gravity-slider" 
            v-model.number="gravity" 
            min="-20" 
            max="0" 
            step="0.1"
            @input="updateGravity"
            class="slider"
          />
        </div>
        
        <div class="control-group">
          <label for="restitution-slider">Bounciness: {{ restitution.toFixed(2) }}</label>
          <input 
            type="range" 
            id="restitution-slider" 
            v-model.number="restitution" 
            min="0" 
            max="1" 
            step="0.05"
            @input="updateRestitution"
            class="slider"
          />
        </div>
        
        <div class="control-group buttons">
          <button @click="toggleWireframes" class="control-button" :class="{ active: showWireframes }">
            {{ showWireframes ? 'Hide Wireframes' : 'Show Wireframes' }}
          </button>
          
          <button @click="pauseSimulation = !pauseSimulation" class="control-button" :class="{ active: pauseSimulation }">
            {{ pauseSimulation ? 'Resume' : 'Pause' }}
          </button>
          
          <button @click="resetSimulation" class="control-button reset">
            Reset Simulation
          </button>
        </div>
        
        <div class="instructions">
          <p>Click "Add Random Object" to add more objects to the scene.</p>
          <p>Use mouse to rotate view, scroll to zoom, and right-click to pan.</p>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.scene-container {
  position: relative;
  width: 100%;
  height: 100vh;
}

.three-container {
  width: 100%;
  height: 100%;
}

.close-button {
  position: absolute;
  top: 20px;
  right: 20px;
  background-color: rgba(255, 255, 255, 0.8);
  border: none;
  border-radius: 4px;
  padding: 8px 16px;
  cursor: pointer;
  z-index: 10;
  font-weight: bold;
}

.close-button:hover {
  background-color: rgba(255, 255, 255, 1);
}

.controls-container {
  position: absolute;
  top: 20px;
  left: 20px;
  z-index: 10;
}

.control-panel {
  background-color: rgba(30, 30, 50, 0.8);
  border-radius: 8px;
  padding: 16px;
  color: white;
  width: 300px;
}

.control-panel h3 {
  margin-top: 0;
  margin-bottom: 16px;
  text-align: center;
  color: #42b883;
}

.control-group {
  margin-bottom: 16px;
}

.control-button {
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 8px 16px;
  cursor: pointer;
  font-weight: bold;
  transition: background-color 0.3s;
  width: 100%;
  margin-bottom: 8px;
}

.control-button:hover {
  background-color: #33a06f;
}

.control-button.active {
  background-color: #e67e22;
}

.control-button.reset {
  background-color: #e74c3c;
}

.control-button.reset:hover {
  background-color: #c0392b;
}

.object-counter {
  background-color: rgba(66, 184, 131, 0.2);
  border-radius: 4px;
  padding: 8px;
  text-align: center;
  font-weight: bold;
  margin-top: 8px;
}

.slider {
  width: 100%;
  margin-top: 8px;
  cursor: pointer;
}

.instructions {
  font-size: 0.9em;
  opacity: 0.8;
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid rgba(255, 255, 255, 0.2);
}

.instructions p {
  margin: 8px 0;
}

.buttons {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
</style>

Component Setup and State Management

import { onMounted, onBeforeUnmount, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import * as CANNON from 'cannon-es';

const emit = defineEmits(['close']);

const containerRef = ref<HTMLDivElement | null>(null);
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let animationFrameId: number;

// Physics world
let world: CANNON.World;
const timeStep = 1/60;

// Objects
let meshes: THREE.Mesh[] = [];
let bodies: CANNON.Body[] = [];
const objectCount = ref(0);
const gravity = ref(-9.82);
const restitution = ref(0.7); // Bounciness

// UI controls
const showWireframes = ref(false);
const pauseSimulation = ref(false);

This section defines our reactive state and non-reactive variables:

  • We use Vue refs for elements that will reactively update the UI (containerRefobjectCount, etc.)
  • We maintain parallel arrays of meshes and bodies to synchronize the visual and physical representations
  • We set initial physics parameters like gravity (-9.82 m/s², Earth’s gravity) and restitution (bounciness)

Initialization Function

const init = () => {
  if (!containerRef.value) return;
  
  // Create scene
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x1a1a2e);
  
  // Create camera
  camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  camera.position.set(0, 5, 10);
  
  // Create renderer
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  containerRef.value.appendChild(renderer.domElement);
  
  // Add orbit controls
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  
  // Setup physics world
  setupPhysicsWorld();
  
  // Add lights
  setupLights();
  
  // Create ground
  createGround();
  
  // Add some initial objects
  addSphere(0, 8, 0, 1);
  addBox(2, 10, 0, 1, 1, 1);
  addBox(-2, 12, 0, 1, 1, 1);
  
  // Handle window resize
  window.addEventListener('resize', onWindowResize);
  
  // Animation loop
  const animate = () => {
    animationFrameId = requestAnimationFrame(animate);
    
    if (!pauseSimulation.value) {
      // Step the physics world
      world.step(timeStep);
      
      // Update mesh positions based on physics bodies
      for (let i = 0; i < meshes.length; i++) {
        meshes[i].position.copy(bodies[i].position as any);
        meshes[i].quaternion.copy(bodies[i].quaternion as any);
      }
    }
    
    controls.update();
    renderer.render(scene, camera);
  };
  
  animate();
};

The initialization function has several key responsibilities:

  1. Setting up the Three.js environment (scene, camera, renderer, controls)
  2. Creating the physics world
  3. Adding initial objects (ground, spheres, boxes)
  4. Setting up the animation loop that ties everything together

The most critical part is the animation loop, which:

  • Steps the physics simulation forward when not paused
  • Synchronizes the positions and orientations of 3D meshes with their physics bodies
  • Updates the camera controls and renders the scene

Physics World Setup

const setupPhysicsWorld = () => {
  world = new CANNON.World({
    gravity: new CANNON.Vec3(0, gravity.value, 0)
  });
  world.broadphase = new CANNON.SAPBroadphase(world);
  world.allowSleep = true;
};

The physics world configuration is minimal but effective:

  • We set gravity along the Y-axis (negative to pull objects downward)
  • We use the SAPBroadphase (Sweep and Prune) algorithm for efficient collision detection
  • We enable body “sleeping” to improve performance by not computing physics for stationary objects

Lighting Setup

const setupLights = () => {
  // Ambient light
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
  scene.add(ambientLight);
  
  // Directional light (sun)
  const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
  directionalLight.position.set(5, 10, 7.5);
  directionalLight.castShadow = true;
  
  // Optimize shadow settings
  directionalLight.shadow.mapSize.width = 1024;
  directionalLight.shadow.mapSize.height = 1024;
  directionalLight.shadow.camera.near = 0.5;
  directionalLight.shadow.camera.far = 50;
  directionalLight.shadow.camera.left = -10;
  directionalLight.shadow.camera.right = 10;
  directionalLight.shadow.camera.top = 10;
  directionalLight.shadow.camera.bottom = -10;
  
  scene.add(directionalLight);
};

Proper lighting is essential for visual quality:

  • Ambient light provides base illumination to prevent completely dark areas
  • Directional light simulates sunlight and creates shadows
  • Shadow settings are configured for quality and performance

Ground Creation

const createGround = () => {
  // Three.js ground
  const groundGeometry = new THREE.PlaneGeometry(30, 30);
  const groundMaterial = new THREE.MeshStandardMaterial({ 
    color: 0x156289,
    roughness: 0.4,
    metalness: 0.2
  });
  const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
  groundMesh.rotation.x = -Math.PI / 2;
  groundMesh.receiveShadow = true;
  scene.add(groundMesh);
  
  // Cannon.js ground body
  const groundShape = new CANNON.Plane();
  const groundBody = new CANNON.Body({
    mass: 0, // Mass of 0 makes it static
    shape: groundShape
  });
  groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
  world.addBody(groundBody);
};

This function demonstrates the crucial pattern of creating paired visual and physical representations:

  1. A Three.js mesh (visual plane) for rendering the ground
  2. A cannon-es plane (physical) for collision detection
  3. Both are rotated to be horizontal (by default, planes are vertical in both libraries)
  4. The physics body has a mass of 0, making it immovable (static)

Object Creation Functions

const addSphere = (x: number, y: number, z: number, radius: number) => {
  // Three.js sphere
  const sphereGeometry = new THREE.SphereGeometry(radius, 32, 32);
  const sphereMaterial = new THREE.MeshStandardMaterial({
    color: Math.random() * 0xffffff,
    roughness: 0.7,
    metalness: 0.3
  });
  const sphereMesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
  sphereMesh.position.set(x, y, z);
  sphereMesh.castShadow = true;
  sphereMesh.receiveShadow = true;
  scene.add(sphereMesh);
  
  // Cannon.js sphere body
  const sphereShape = new CANNON.Sphere(radius);
  const sphereBody = new CANNON.Body({
    mass: 1,
    shape: sphereShape,
    position: new CANNON.Vec3(x, y, z),
    material: new CANNON.Material({ restitution: restitution.value })
  });
  world.addBody(sphereBody);
  
  // Add wireframe if enabled
  if (showWireframes.value) {
    addWireframe(sphereMesh);
  }
  
  // Track objects
  meshes.push(sphereMesh);
  bodies.push(sphereBody);
  objectCount.value++;
};

The addSphere and addBox functions follow the same pattern:

  1. Create a Three.js mesh with appropriate geometry and material
  2. Create a cannon-es body with matching shape and position
  3. Add optional wireframe visualization if enabled
  4. Store references to both objects in our tracking arrays

Note how the physics body includes a material with our current restitution value, allowing for dynamic bounciness adjustments.

Wireframe and UI Controls

const addWireframe = (mesh: THREE.Mesh) => {
  const wireframe = new THREE.LineSegments(
    new THREE.WireframeGeometry(mesh.geometry),
    new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.3 })
  );
  mesh.add(wireframe);
};

const toggleWireframes = () => {
  showWireframes.value = !showWireframes.value;
  
  // Add or remove wireframes from all meshes
  meshes.forEach(mesh => {
    // Remove existing wireframes
    mesh.children.forEach(child => {
      if (child instanceof THREE.LineSegments) {
        mesh.remove(child);
        if (child.geometry) child.geometry.dispose();
        if (child.material) {
          if (Array.isArray(child.material)) {
            child.material.forEach(m => m.dispose());
          } else {
            child.material.dispose();
          }
        }
      }
    });
    
    // Add new wireframes if enabled
    if (showWireframes.value) {
      addWireframe(mesh);
    }
  });
};

The wireframe functionality helps visualize physics shapes:

  • Wireframes are created as line segments using the mesh’s geometry
  • They’re added as children of the mesh so they move together
  • The toggle function properly cleans up resources when removing wireframes

Physics Parameter Updates

const updateGravity = () => {
  world.gravity.set(0, gravity.value, 0);
};

const updateRestitution = () => {
  // Create a default material with the new restitution value
  const defaultMaterial = new CANNON.Material('default');
  
  // Update all bodies with the new material
  bodies.forEach(body => {
    // Create a new material for each body
    const bodyMaterial = new CANNON.Material('body');
    bodyMaterial.restitution = restitution.value;
    body.material = bodyMaterial;
  });
  
  // Create a contact material between default and body materials
  const contactMaterial = new CANNON.ContactMaterial(
    defaultMaterial,
    defaultMaterial,
    {
      friction: 0.3,
      restitution: restitution.value
    }
  );
  
  // Remove existing contact materials and add the new one
  world.contactmaterials.length = 0;
  world.addContactMaterial(contactMaterial);
  
  // Set default contact material properties
  world.defaultContactMaterial.restitution = restitution.value;
};

These functions allow real-time adjustment of physics parameters:

  • Updating gravity is straightforward, just changing the world’s gravity vector
  • Updating restitution is more complex:
    1. Create a new material with the desired bounciness
    2. Apply it to all physics bodies
    3. Set up contact materials to define how different materials interact
    4. Update the default contact material as a fallback

User Interaction Functions

const addRandomObject = () => {
  // Random position above the scene
  const x = (Math.random() - 0.5) * 10;
  const y = 10 + Math.random() * 5;
  const z = (Math.random() - 0.5) * 10;
  
  // 50% chance for sphere or box
  if (Math.random() > 0.5) {
    const radius = 0.5 + Math.random() * 1;
    addSphere(x, y, z, radius);
  } else {
    const size = 0.5 + Math.random() * 1;
    addBox(x, y, z, size, size, size);
  }
};

const resetSimulation = () => {
  // Remove all objects
  meshes.forEach(mesh => {
    scene.remove(mesh);
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) {
      if (Array.isArray(mesh.material)) {
        mesh.material.forEach(m => m.dispose());
      } else {
        mesh.material.dispose();
      }
    }
  });
  
  bodies.forEach(body => {
    world.removeBody(body);
  });
  
  meshes = [];
  bodies = [];
  objectCount.value = 0;
  
  // Add some initial objects
  addSphere(0, 8, 0, 1);
  addBox(2, 10, 0, 1, 1, 1);
  addBox(-2, 12, 0, 1, 1, 1);
};

These functions provide the core user interactions:

  • addRandomObject creates either a sphere or box with random properties, positioned above the scene and our gravity does the rest
  • resetSimulation properly cleans up all current objects, disposes of resources, and resets to the initial state

Cleanup and Lifecycle Hooks

const cleanup = () => {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }
  
  window.removeEventListener('resize', onWindowResize);
  
  if (containerRef.value && renderer) {
    containerRef.value.removeChild(renderer.domElement);
  }
  
  if (controls) {
    controls.dispose();
  }
  
  // Clean up all meshes
  meshes.forEach(mesh => {
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) {
      if (Array.isArray(mesh.material)) {
        mesh.material.forEach(m => m.dispose());
      } else {
        mesh.material.dispose();
      }
    }
  });
};

onMounted(() => {
  init();
});

onBeforeUnmount(() => {
  cleanup();
});

Proper cleanup is crucial for complex 3D applications:

  • The cleanup function cancels animation frames, removes event listeners, and disposes of 3D resources
  • Vue lifecycle hooks ensure the scene initializes when the component mounts and cleans up when it unmounts

This pattern prevents memory leaks and improves application performance over time, especially when navigating between pages or components.

Conclusion

In this tutorial, we’ve built a complete physics playground using Three.js and cannon-es. We’ve learned how to:

  1. Set up a physics world with cannon-es
  2. Create synchronized 3D objects in Three.js and cannon-es
  3. Implement real-time physics with gravity and collisions
  4. Create an interactive UI for manipulating physics parameters
  5. Properly clean up resources to prevent memory leaks

Using physics engines like cannon-es dramatically enhances the realism and interactivity of 3D web applications. The same principles we’ve explored can be extended to create games, interactive product demos, educational simulations, and more.

Further Experimentation

Now that you have a solid foundation, here are some ways to extend this project:

  1. Additional Shapes: Add cylinders, cones, or compound shapes
  2. Constraints: Implement hinges, springs, or fixed connections between objects
  3. User Interaction: Add the ability to throw or drag objects using raycasting
  4. Materials: Create different material types with varying friction and bounciness
  5. Destructible Objects: Implement object breaking or shattering on impact

Continue to the next tutorial to learn about shadows and fog in Three.js.