Three.js 物理引擎整合:物體碰撞、重力與物理模擬
簡介
在 3D 網頁應用中,視覺效果固然重要,但若缺乏真實的物理回饋,使用者的沉浸感往往會大打折扣。Three.js 本身提供了強大的渲染能力,卻沒有內建完整的物理引擎;因此在需要「碰撞偵測」或「重力」等模擬時,我們必須額外整合外部的物理庫(如 Cannon.js、Ammo.js、Oimo.js 等),或自行實作簡易的物理系統。
本單元將帶你了解:
- 為什麼要在 Three.js 中加入物理模擬
- 常見的 碰撞偵測與回應 方法
- 重力、慣性與彈性 的基本概念與實作
- 如何 把物理引擎與 Three.js 場景同步,讓渲染與運算保持一致
即使你是剛接觸 Three.js 的新手,只要掌握以下核心概念,就能在自己的專案裡快速加入基本的物理行為,進而打造出更具互動性的 3D 體驗。
核心概念
1. 物理引擎的角色與選型
| 引擎 | 語言/類型 | 特色 | 适用情境 |
|---|---|---|---|
| Cannon.js | JavaScript | 輕量、易於上手、支援剛體、彈簧、車輛等 | 小型遊戲、教學示範 |
| Ammo.js | WebAssembly (C++ 移植) | 高精度、支援剛體、布料、軟體體 | 複雜模擬、需要真實感的應用 |
| Oimo.js | JavaScript | 速度快、支援簡易車輛、膠囊碰撞 | 簡易平台遊戲、跑酷類 |
選擇原則:如果專案對精度要求不高且希望快速開發,優先考慮 Cannon.js;若需要更真實的碰撞與布料模擬,則選擇 Ammo.js。
2. 基本的剛體 (Rigid Body) 結構
剛體是物理模擬的核心,它由 形狀 (Shape)、質量 (Mass)、位置與旋轉 (Transform) 組成。Three.js 中的 Mesh 只負責渲染,物理引擎則負責計算位置與速度。
// 以 Cannon.js 為例,建立一個球形剛體
const radius = 1;
const mass = 5;
// 1. Three.js Mesh(渲染用)
const geometry = new THREE.SphereGeometry(radius, 32, 32);
const material = new THREE.MeshStandardMaterial({ color: 0x0077ff });
const sphereMesh = new THREE.Mesh(geometry, material);
scene.add(sphereMesh);
// 2. Cannon.js Body(物理用)
const shape = new CANNON.Sphere(radius);
const body = new CANNON.Body({ mass, shape });
body.position.set(0, 10, 0); // 初始高度 10
world.addBody(body);
重點:
mesh.position不應直接修改,而是每幀從剛體的position同步過來,否則會導致物理與渲染不同步。
3. 重力與時間步進 (Time Step)
物理引擎會在每個時間步 (time step) 計算受力、速度與位置。Three.js 的渲染迴圈使用 requestAnimationFrame,但這個頻率不一定固定(60fps、30fps …),因此需要 固定時間步 以避免不穩定的模擬。
let lastTime;
const fixedTimeStep = 1 / 60; // 60 FPS 固定時間步
const maxSubSteps = 3; // 最大補算次數
function animate(time) {
requestAnimationFrame(animate);
const delta = (time - (lastTime || time)) / 1000; // 轉成秒
lastTime = time;
// 1. 物理模擬
world.step(fixedTimeStep, delta, maxSubSteps);
// 2. 同步 Mesh 與 Body
sphereMesh.position.copy(body.position);
sphereMesh.quaternion.copy(body.quaternion);
// 3. 渲染
renderer.render(scene, camera);
}
animate();
4. 碰撞偵測與回應
4.1 簡易的「地面」碰撞
最常見的需求是讓物體在落地後停止或彈跳。只要在物理世界中加入一個靜態平面(mass = 0)即可。
// 建立地面(靜態剛體)
const groundShape = new CANNON.Plane();
const groundBody = new CANNON.Body({ mass: 0, shape: groundShape });
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); // 讓平面水平
world.addBody(groundBody);
4.2 事件式碰撞回調
Cannon.js 允許監聽碰撞事件,從而在程式中加入音效、粒子或其他反應。
body.addEventListener('collide', function (event) {
const contact = event.contact; // 碰撞資訊
const collidedWith = event.body; // 被碰撞的剛體
// 簡單示範:碰撞時改變顏色
sphereMesh.material.color.setHex(0xff0000);
// 2 秒後恢復原色
setTimeout(() => {
sphereMesh.material.color.setHex(0x0077ff);
}, 2000);
});
4.3 多剛體碰撞群組 (Collision Filtering)
有時候希望某些物體互不碰撞(例如玩家與自己的子彈),可以利用 collisionFilterGroup 與 collisionFilterMask 來設定。
// group 1: 玩家
const playerBody = new CANNON.Body({
mass: 80,
shape: new CANNON.Sphere(1),
collisionFilterGroup: 1,
collisionFilterMask: 2 // 只會與 group 2 碰撞
});
world.addBody(playerBody);
// group 2: 障礙物
const obstacleBody = new CANNON.Body({
mass: 0,
shape: new CANNON.Box(new CANNON.Vec3(5, 5, 5)),
collisionFilterGroup: 2,
collisionFilterMask: 1 // 只會與 group 1 碰撞
});
world.addBody(obstacleBody);
5. 彈性與阻尼 (Restitution & Damping)
- 彈性 (restitution):決定碰撞後的彈跳高度。
0為完全不彈,1為完全彈回原高度。 - 阻尼 (linearDamping / angularDamping):模擬空氣阻力或內部摩擦,使物體逐漸減速。
// 設定彈性與阻尼
body.material = new CANNON.Material('bouncy');
body.material.restitution = 0.7; // 70% 彈性
body.linearDamping = 0.05; // 線性阻尼
body.angularDamping = 0.1; // 角向阻尼
程式碼範例
以下提供 五個實用範例,從最簡單的「球掉落」到「車輛模擬」皆有涵蓋,方便你直接套用或作為學習基礎。
範例 1:球體自由落體與地面彈跳
// 1. 初始化 Three.js 舞台
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 15);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 2. 初始化 Cannon.js 世界
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0); // 重力向下
// 3. 建立地面
const groundMat = new CANNON.Material('ground');
const groundShape = new CANNON.Plane();
const groundBody = new CANNON.Body({ mass: 0, shape: groundShape, material: groundMat });
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(groundBody);
// 4. 建立球體
const radius = 1;
const sphereGeo = new THREE.SphereGeometry(radius, 32, 32);
const sphereMat = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
scene.add(sphereMesh);
const sphereShape = new CANNON.Sphere(radius);
const sphereBody = new CANNON.Body({ mass: 5, shape: sphereShape });
sphereBody.position.set(0, 10, 0);
sphereBody.material = new CANNON.Material('ball');
sphereBody.material.restitution = 0.8; // 高彈性
world.addBody(sphereBody);
// 5. 照明
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);
// 6. 動畫迴圈(固定時間步)
let lastTime;
function animate(time) {
requestAnimationFrame(animate);
const delta = (time - (lastTime || time)) / 1000;
lastTime = time;
world.step(1/60, delta, 3);
// 同步 Mesh
sphereMesh.position.copy(sphereBody.position);
sphereMesh.quaternion.copy(sphereBody.quaternion);
renderer.render(scene, camera);
}
animate();
說明:此範例展示最基本的「重力 + 碰撞彈跳」流程,適合作為新手練習的起點。
範例 2:使用 Collision Filter 讓子彈不會碰到玩家本身
// 假設已經有 playerBody (group 1) 與 scene、world 初始化
function fireBullet(origin, direction) {
const bulletRadius = 0.2;
const bulletMass = 0.2;
// 3D 版
const bulletGeo = new THREE.SphereGeometry(bulletRadius, 12, 12);
const bulletMat = new THREE.MeshStandardMaterial({ color: 0xffff00 });
const bulletMesh = new THREE.Mesh(bulletGeo, bulletMat);
bulletMesh.position.copy(origin);
scene.add(bulletMesh);
// 物理版
const bulletShape = new CANNON.Sphere(bulletRadius);
const bulletBody = new CANNON.Body({
mass: bulletMass,
shape: bulletShape,
collisionFilterGroup: 4, // 子彈群組
collisionFilterMask: 2 // 只與障礙物(group 2)碰撞
});
bulletBody.position.copy(origin);
bulletBody.velocity.copy(direction.multiplyScalar(30)); // 初速
world.addBody(bulletBody);
// 每幀同步
const sync = () => {
bulletMesh.position.copy(bulletBody.position);
bulletMesh.quaternion.copy(bulletBody.quaternion);
};
// 讓外層動畫迴圈呼叫 sync()
return { body: bulletBody, mesh: bulletMesh, sync };
}
重點:
collisionFilterGroup與collisionFilterMask的組合讓子彈只會與指定的物體碰撞,避免自撞。
範例 3:簡易的剛體拖拽(Pick & Drag)— 讓使用者用滑鼠拖動物體
let mouse = new THREE.Vector2();
let raycaster = new THREE.Raycaster();
let dragConstraint = null; // Cannon.js 的拖拽約束
// 監聽滑鼠事件
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
function onMouseDown(event) {
// 轉換座標
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects([sphereMesh]); // 假設只拖球
if (intersects.length > 0) {
const point = intersects[0].point;
// 在物理世界中建立一個虛擬的點(pivot)
const pivot = new CANNON.Body({ mass: 0 });
pivot.position.copy(point);
world.addBody(pivot);
// 建立 PointToPointConstraint
dragConstraint = new CANNON.PointToPointConstraint(
sphereBody, // 被拖的剛體
new CANNON.Vec3().copy(sphereBody.pointToLocalFrame(point)),
pivot,
new CANNON.Vec3(0, 0, 0)
);
world.addConstraint(dragConstraint);
}
}
function onMouseMove(event) {
if (!dragConstraint) return;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const planeZ = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const intersect = new THREE.Vector3();
raycaster.ray.intersectPlane(planeZ, intersect);
// 更新虛擬 pivot 的位置
dragConstraint.bodyB.position.copy(intersect);
}
function onMouseUp() {
if (dragConstraint) {
world.removeConstraint(dragConstraint);
world.removeBody(dragConstraint.bodyB);
dragConstraint = null;
}
}
說明:透過
PointToPointConstraint,我們把 Three.js 產生的滑鼠座標轉成物理世界的約束點,實現自然的拖拽感受。
範例 4:簡易的車輛模擬(使用 Cannon.js RaycastVehicle)
// 1. 建立車身
const chassisShape = new CANNON.Box(new CANNON.Vec3(1, 0.5, 2));
const chassisBody = new CANNON.Body({ mass: 150 });
chassisBody.addShape(chassisShape);
chassisBody.position.set(0, 4, 0);
world.addBody(chassisBody);
// 2. 建立車輪資訊(半徑、寬度)
const wheelOptions = {
radius: 0.4,
directionLocal: new CANNON.Vec3(0, -1, 0),
axleLocal: new CANNON.Vec3(-1, 0, 0),
suspensionStiffness: 30,
suspensionRestLength: 0.3,
frictionSlip: 5,
dampingRelaxation: 2.3,
dampingCompression: 4.4,
maxSuspensionForce: 100000,
rollInfluence: 0.01,
maxSuspensionTravel: 0.3,
customSlidingRotationalSpeed: -30,
useCustomSlidingRotationalSpeed: true
};
// 3. 建立 RaycastVehicle
const vehicle = new CANNON.RaycastVehicle({
chassisBody,
indexRightAxis: 0, // x
indexUpAxis: 1, // y
indexForwardAxis: 2 // z
});
// 加入四個車輪
const wheelPositions = [
new CANNON.Vec3(-1, 0, 2),
new CANNON.Vec3( 1, 0, 2),
new CANNON.Vec3(-1, 0, -2),
new CANNON.Vec3( 1, 0, -2)
];
wheelPositions.forEach(pos => vehicle.addWheel({ ...wheelOptions, position: pos }));
world.addBody(chassisBody);
vehicle.addToWorld(world);
// 4. 同步 Three.js 車輪與車身(簡化版)
function updateVehicle() {
const chassisMesh = /* 你的車身 Mesh */;
chassisMesh.position.copy(chassisBody.position);
chassisMesh.quaternion.copy(chassisBody.quaternion);
vehicle.wheelInfos.forEach((wheel, i) => {
const wheelTransform = new CANNON.Transform();
vehicle.updateWheelTransform(i);
vehicle.wheelInfos[i].worldTransform.getWorldPosition(wheelTransform.position);
vehicle.wheelInfos[i].worldTransform.getWorldQuaternion(wheelTransform.quaternion);
const wheelMesh = /* 對應的 Three.js 車輪 Mesh */;
wheelMesh.position.copy(wheelTransform.position);
wheelMesh.quaternion.copy(wheelTransform.quaternion);
});
}
提示:車輛模擬相對複雜,建議先把 車身 與 車輪 分別做成獨立的 Mesh,然後在每幀呼叫
updateVehicle()進行同步。
範例 5:使用 Ammo.js 做布料模擬(簡易旗幟)
備註:Ammo.js 為 WebAssembly 版的 Bullet Physics,使用方式與 Cannon.js 有所不同。以下示範如何建立一個掛在兩端的布料旗幟。
// 1. 載入 Ammo.js(已透過 <script> 引入或 npm import)
Ammo().then(Ammo => {
// 2. 初始化物理世界
const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
const broadphase = new Ammo.btDbvtBroadphase();
const solver = new Ammo.btSequentialImpulseConstraintSolver();
const physicsWorld = new Ammo.btDiscreteDynamicsWorld(dispatcher, broadphase, solver, collisionConfiguration);
physicsWorld.setGravity(new Ammo.btVector3(0, -9.81, 0));
// 3. 建立布料剛體(軟體體)
const clothWidth = 10, clothHeight = 6;
const clothMass = 1;
const clothGeometry = new THREE.PlaneGeometry(clothWidth, clothHeight, 20, 12);
const clothMaterial = new THREE.MeshStandardMaterial({ color: 0xff5555, side: THREE.DoubleSide });
const clothMesh = new THREE.Mesh(clothGeometry, clothMaterial);
scene.add(clothMesh);
// Ammo.js SoftBody helpers
const softBodyHelpers = new Ammo.btSoftBodyHelpers();
const clothSoftBody = softBodyHelpers.CreatePatch(
physicsWorld.getWorldInfo(),
new Ammo.btVector3(-clothWidth/2, 5, 0),
new Ammo.btVector3(clothWidth/2, 5, 0),
new Ammo.btVector3(-clothWidth/2, 5, clothHeight),
new Ammo.btVector3(clothWidth/2, 5, clothHeight),
20, 12,
0, true
);
const sbConfig = clothSoftBody.get_m_cfg();
sbConfig.set_viterations(10);
sbConfig.set_piterations(10);
sbConfig.set_collisions(0x11);
clothSoftBody.setTotalMass(clothMass, false);
Ammo.castObject(clothSoftBody, Ammo.btCollisionObject).getCollisionShape().setMargin(0.05);
physicsWorld.addSoftBody(clothSoftBody, 1, -1);
// 4. 鎖定兩側的頂點(固定點)
const nodes = clothSoftBody.get_m_nodes();
const anchorIndices = [0, 20]; // 左上、右上兩個節點
anchorIndices.forEach(idx => {
clothSoftBody.appendAnchor(idx, null, false, 0.5);
});
// 5. 每幀更新
function animate(time) {
requestAnimationFrame(animate);
physicsWorld.stepSimulation(1/60, 10);
// 把軟體體的頂點座標寫回 Three.js geometry
const verts = clothGeometry.attributes.position.array;
const numVerts = verts.length / 3;
for (let i = 0; i < numVerts; i++) {
const node = nodes.at(i);
const pos = node.get_m_x();
verts[i * 3] = pos.x();
verts[i * 3 + 1] = pos.y();
verts[i * 3 + 2] = pos.z();
}
clothGeometry.attributes.position.needsUpdate = true;
clothGeometry.computeVertexNormals();
renderer.render(scene, camera);
}
animate();
});
重點:
- 使用
btSoftBodyHelpers.CreatePatch建立布料;- 透過
appendAnchor鎖定節點,使旗幟兩端不會隨風飄走;- 每幀手動把 Ammo.js 的節點座標寫回 Three.js 的
BufferGeometry。
常見陷阱與最佳實踐
| 陷阱 | 可能的症狀 | 解決方案 / 最佳實踐 |
|---|---|---|
| 時間步不一致 | 物體穿牆、彈跳不自然 | 使用 固定時間步 (world.step(fixedDelta, delta, maxSubSteps));避免直接以 deltaTime 作為唯一步長 |
| Mesh 同步遺漏 | 渲染位置與物理位置不吻合 | 每幀必須 copy body.position & body.quaternion 到對應的 Mesh,且不要在其他地方自行改變 Mesh 的位置 |
| 質量設為 0 但仍想移動 | 物體無法受力移動 | 0 質量的剛體是 靜態,若需要手動移動,改用 mass > 0 並使用 applyForce 或 velocity |
| 碰撞形狀與模型不匹配 | 物體看似穿過其他物件 | 為每個 Mesh 選擇最貼近的 Collision Shape(Box、Sphere、Cylinder、ConvexPolyhedron),若模型複雜可使用 簡化網格 |
| 過多剛體 | FPS 大幅下降 | 只為需要互動的物體建立剛體,靜態環境(牆、地面)使用 mass = 0;若數量仍過多,可考慮 分層碰撞檢測 或 四叉樹 |
| 阻尼或彈性過大 | 物體永遠不會停下或彈跳過度 | 合理設定 linearDamping、angularDamping、restitution;測試不同值後再決定最自然的參數 |
| 使用 WebAssembly 物理引擎時忘記等待初始化 | undefined 錯誤、程式崩潰 |
使用 Ammo().then() 或 await 確保 Ammo 模組已載入完成才建立世界 |
最佳實踐小結:
- 先規劃剛體:先決定哪些物件需要物理、哪些只渲染。
- 保持單一來源真相:所有位置與旋轉資訊皆由物理引擎產生,Three.js 僅負責顯示。
- 使用
requestAnimationFrame與固定時間步 結合,保證模擬穩定。 - 妥善釋放資源:在切換場景或銷毀物件時,務必
world.removeBody、world.removeConstraint,避免記憶體洩漏。 - 調校參數:利用 debug visualizer(如
CANNON.BodyHelper)觀察碰撞形狀與實際模型的差距,調整mass、restitution、damping。
實際應用場景
| 場景 | 需求 | 推薦實作方式 |
|---|---|---|
| 平台跳躍遊戲 | 重力、平台碰撞、彈跳、移動平台 | 使用 Cannon.js,設定 restitution 為 0.2~0.4,搭配 RaycastVehicle(或自行實作角色控制) |
| 3D 產品展示(可拖拉) | 物件拖拽、碰撞避免、滑鼠互動 | 使用 PointToPointConstraint 實作拖拽,利用 collisionFilter 防止物件相互穿透 |
| 虛擬實境 (VR) 互動 | 手部模型與環境的即時碰撞、物理回饋 | 結合 Ammo.js(因為 WebAssembly 效能更佳)與 WebXR,使用 applyForce 讓手部觸碰產生回彈 |
| 模擬車輛或機械 | 車輪懸吊、懸掛、剛體連接 | 使用 Cannon.js RaycastVehicle 或 Ammo.js btRaycastVehicle,加入 hinge、slider 約束 |
| 布料、旗幟、髮絲 | 軟體體、彈性、風力 | Ammo.js 的 SoftBody 系統非常適合;配合 Three.js 的 ShaderMaterial 可實作更真實的光影效果 |
| 多人線上遊戲 | 同步物理狀態、網路延遲補償 | 在伺服器端使用 Node.js + Cannon.js 進行權威物理模擬,客戶端僅做渲染與簡易預測 |
總結
Three.js 為渲染提供了強大的工具,而 物理引擎 則賦予 3D 場景生命力與互動深度。透過本文的 概念說明、五個完整範例、以及 常見陷阱與最佳實踐,你應該已經能夠:
- 選擇合適的物理庫(Cannon.js、Ammo.js、Oimo.js)
- 建立剛體、設定重力與時間步,確保模擬穩定
- 實作碰撞偵測、彈性與阻尼,讓物體行為更自然
- 把物理狀態與 Three.js Mesh 同步,避免視覺與運算脫節
- 根據不同應用場景,選擇最適合的模擬方式(平台遊戲、VR 互動、車輛、布料等)
只要把 「渲染」 與 「物理」 兩條主線恰當地結合,你的 Three.js 專案就能從單純的視覺展示,躍升為 充滿真實感與互動性的沉浸式體驗。祝你玩得開心,開發順利! 🚀