Three.js 與 Cannon‑es 整合教學
簡介
在 3D 網頁應用中,真實感的互動往往是吸引用戶的關鍵。單純的模型渲染只能呈現靜態畫面,若想讓物件在場景中自然地碰撞、彈跳、滾動,就需要結合物理引擎。Three.js 本身只負責渲染,並未內建物理模擬功能;因此在實作遊戲、模擬或互動式可視化時,我們常會把 Cannon‑es(Cannon.js 的 ES6 重寫版)作為物理層的解決方案。
Cannon‑es 以 輕量、模組化 為設計目標,支援剛體(RigidBody)、約束(Constraint)以及多種碰撞形狀(Shape),且完全以 ES6+ 模組發布,與現代前端開發流程(Webpack、Vite、ESM)相容。透過本篇文章,你將學會:
- 在 Three.js 專案中正確安裝與初始化 Cannon‑es。
- 把 Three.js Mesh 與 Cannon‑es Body 互相綁定,實現同步更新。
- 常見的陷阱與最佳實踐,讓你的物理模擬更穩定、更易維護。
核心概念
1. 剛體(Body)與形狀(Shape)
Cannon‑es 中的 Body 代表一個具質量、速度與力的物理實體;而 Shape 則描述它的碰撞體積(如 Box、Sphere、Plane、ConvexPolyhedron 等)。在 Three.js 中,我們用 Mesh 來呈現視覺模型,兩者必須保持 位置、旋轉 的同步。
重點:不要把 Mesh 的幾何體直接當作 Shape,因為 Shape 必須是簡化的碰撞體,過於複雜的幾何會大幅降低模擬效能。
2. 世界(World)
所有的 Body 必須加入同一個 World,World 會負責時間步進(step)與碰撞檢測。常見的設定包括重力(gravity)與求解器(solver)參數。
3. 同步機制
每一幀(frame)我們需要依序:
- World.step(dt):讓物理引擎根據時間步長更新所有 Body。
- Mesh.position / Mesh.quaternion ← Body.position / Body.quaternion:把物理結果寫回視覺模型。
如果不做同步,畫面會與物理狀態脫節,產生「穿牆」或「卡頓」的問題。
程式碼範例
以下示範一個最小可執行的 Three.js + Cannon‑es 範例,涵蓋 安裝、初始化、建立剛體、同步更新 四個步驟。每段程式碼都加入詳細註解,方便初學者快速上手。
1️⃣ 安裝與匯入
# 使用 npm 安裝
npm i three cannon-es
// main.js
import * as THREE from 'three';
import { World, Body, Box, Sphere, Vec3, Plane } from 'cannon-es';
2️⃣ 建立 Three.js 場景與 Cannon‑es 世界
// ---- Three.js 基本設定 ----
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 1000
);
camera.position.set(0, 5, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 加入簡單的環境光
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);
// ---- Cannon‑es 世界設定 ----
const world = new World({
gravity: new Vec3(0, -9.82, 0) // 設定重力向下 9.82 m/s²
});
// 使用較高的迭代次數可提升穩定性(預設 10)
world.solver.iterations = 12;
world.broadphase = new CANNON.NaiveBroadphase(); // 小型場景可使用簡易廣義相位
3️⃣ 建立地板(Plane)與剛體(Box)
// ---------- Three.js 地板 ----------
const floorGeo = new THREE.PlaneGeometry(20, 20);
const floorMat = new THREE.MeshStandardMaterial({ color: 0x808080 });
const floorMesh = new THREE.Mesh(floorGeo, floorMat);
floorMesh.rotation.x = -Math.PI / 2; // 讓平面水平
scene.add(floorMesh);
// ---------- Cannon‑es 地板 ----------
const floorBody = new Body({
mass: 0, // mass = 0 代表靜止物件(不受重力影響)
shape: new Plane(),
position: new Vec3(0, 0, 0)
});
// 需要把平面旋轉到與 Three.js 相同的方向
floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(floorBody);
// ---------- Three.js 立方體 ----------
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const boxMat = new THREE.MeshStandardMaterial({ color: 0xff0000 });
const boxMesh = new THREE.Mesh(boxGeo, boxMat);
boxMesh.position.set(0, 5, 0); // 初始高度 5 單位
scene.add(boxMesh);
// ---------- Cannon‑es 立方體 ----------
const boxShape = new Box(new Vec3(0.5, 0.5, 0.5)); // 半徑向量
const boxBody = new Body({
mass: 1, // 有質量會受到重力
shape: boxShape,
position: new Vec3(0, 5, 0),
material: new CANNON.Material('boxMaterial')
});
world.addBody(boxBody);
4️⃣ 渲染迴圈與同步
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta(); // 取得兩幀間的時間差(秒)
const fixedTimeStep = 1 / 60; // 固定時間步長(建議 60 FPS)
// 1. 讓 Cannon‑es 計算物理
world.step(fixedTimeStep, delta, 3);
// 2. 同步 Three.js Mesh 與 Cannon‑es Body
boxMesh.position.copy(boxBody.position);
boxMesh.quaternion.copy(boxBody.quaternion);
// 3. 渲染畫面
renderer.render(scene, camera);
}
animate();
5️⃣ 進階範例:加入彈性球與衝擊聲
// 建立球體 Mesh
const sphereGeo = new THREE.SphereGeometry(0.5, 32, 32);
const sphereMat = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
sphereMesh.position.set(-2, 8, 0);
scene.add(sphereMesh);
// 對應的 Cannon‑es 球體 Body
const sphereShape = new Sphere(0.5);
const sphereBody = new Body({
mass: 0.5,
shape: sphereShape,
position: new Vec3(-2, 8, 0),
material: new CANNON.Material('bouncy')
});
world.addBody(sphereBody);
// 設定彈性(restitution)讓球彈跳
const contactMaterial = new CANNON.ContactMaterial(
sphereBody.material,
floorBody.material,
{ restitution: 0.9 } // 0~1 之間,越接近 1 彈性越高
);
world.addContactMaterial(contactMaterial);
// 同步更新(放在 animate 中)
function syncSphere() {
sphereMesh.position.copy(sphereBody.position);
sphereMesh.quaternion.copy(sphereBody.quaternion);
}
小技巧:若要在碰撞時播放音效,可監聽
world.addEventListener('postStep', ...),檢查contactMaterial的impactVelocity,超過門檻即觸發音效。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 時間步長不一致 | 使用 clock.getDelta() 直接作為 step 參數會導致不穩定的模擬(尤其在 FPS 波動大時)。 |
使用 固定時間步長(如 1/60)結合 maxSubSteps(上例的 3)確保每秒最多 60 次更新。 |
| 碰撞形狀過於複雜 | 把高多邊形模型直接轉成 ConvexPolyhedron 會大幅降低效能。 | 為視覺模型選擇 簡化的形狀(Box、Sphere、Capsule),必要時使用 Compound Shape 組合多個簡單形狀。 |
| 質量與慣性不匹配 | 設定 mass = 0 卻仍期待它受力移動。 |
靜止物件(地板、牆壁)使用 mass: 0;動態物件則給予正值質量,並根據實際需求調整 linearDamping 與 angularDamping。 |
| 座標系不一致 | Cannon‑es 使用右手座標系,Three.js 亦然,但旋轉四元數的順序容易出錯。 | 在建立 Plane、Box 等形狀時,明確使用 quaternion.setFromEuler 或 setFromAxisAngle,保持 相同的旋轉順序(XYZ)。 |
| 記憶體洩漏 | 每幀都重新建立 Body 或 Shape 會造成 GC 壓力。 | 一次建立,之後只調整 position、velocity 等屬性;若需要移除,呼叫 world.removeBody(body) 並手動 dispose Three.js 資源。 |
最佳實踐:
- 分離更新與渲染:把物理更新放在固定時間步長的 loop,渲染則以瀏覽器的
requestAnimationFrame為主。 - 使用 Compound Body:對於複雜模型,將其拆成多個 Box / Sphere 組合,可同時保留較好的碰撞精度與效能。
- 調整阻尼(Damping):適當的
linearDamping(0~1)可防止物體在無摩擦的環境下永遠滑動。 - 預先測試:在正式上線前,用大量物件測試 FPS 與物理穩定性,必要時降低碰撞形狀的細節或使用 Broadphase(如 SAP)提升效能。
實際應用場景
| 場景 | 為何需要 Cannon‑es | 實作要點 |
|---|---|---|
| 平台遊戲(2.5D) | 角色跳躍、平台移動、彈跳箱子等需要即時碰撞回饋。 | 使用 Box 與 Plane 組合;把角色設為 Capsule(較自然的碰撞)。 |
| VR/AR 互動 | 手部或控制器的物理抓取、投擲需要真實的慣性。 | 使用 Sphere 或 ConvexPolyhedron 作為抓取對象;結合 Constraint(如 LockConstraint)實作抓取。 |
| 產品展示(物理模擬) | 模擬零件組裝、碰撞測試或重力下的擺放。 | 用 Compound Body 把每個零件的碰撞形狀組合;在 UI 中提供「重置」功能,重新呼叫 world.clearForces()。 |
| 教育與科學可視化 | 模擬天體運動、彈性碰撞或流體的簡易近似。 | 調整 重力、質量、彈性(restitution)參數;可視化 velocity 向量以說明物理概念。 |
| 即時策略/塔防 | 建築物與投射物的碰撞判斷。 | 只為投射物與地形建立 Sphere / Box;建築物使用 mass: 0 的靜止 Body。 |
總結
Three.js 提供了強大的渲染能力,而 Cannon‑es 則以輕量、ESM‑friendly 的特性填補了物理模擬的空白。透過本文的步驟,你已經能:
- 安裝與設定:在現代前端專案中快速導入 Cannon‑es。
- 建立剛體與形狀:選擇適合的碰撞形狀並與 Three.js Mesh 做同步。
- 掌握同步機制:使用固定時間步長保證模擬穩定,並把結果寫回渲染層。
- 避免常見陷阱:從時間步長、形狀簡化、阻尼設定等角度提升效能與穩定性。
- 應用於實務:從平台遊戲到科學可視化,都能靈活運用 Cannon‑es 完成真實感的互動體驗。
在未來的專案中,持續測試、根據需求調整碰撞形狀,以及 善用 Constraint,將讓你的 Three.js 應用更具可玩性與專業度。祝你玩得開心、寫得順利! 🚀