Three.js 教學 – 物理引擎整合:Ammo.js 入門與實作
簡介
在 3D 網頁開發中,視覺效果固然重要,但若要讓場景中的物件表現出真實的動態行為,物理引擎就是不可或缺的工具。
Three.js 本身只負責渲染與場景管理,對於碰撞偵測、剛體模擬、彈性運動等需求,它並未提供內建解決方案。這時,我們可以把 Ammo.js(由 Bullet Physics 移植而來的 WebAssembly 版)與 Three.js 結合,讓瀏覽器也能執行高效能的剛體物理模擬。
本文將從 概念說明、實作範例、常見陷阱 到 最佳實踐,一步步帶你完成 Ammo.js 與 Three.js 的整合,並提供可直接套用的程式碼片段,適合剛入門的開發者以及想要在專案中加入物理效果的中階工程師。
核心概念
1. Ammo.js 基本架構
Ammo.js 是 Bullet Physics 的 JavaScript 包裝,它以 WebAssembly(或 asm.js)形式載入,提供與 C++ 原始 API 相同的類別與方法。最常用的幾個類別如下:
| 類別 | 功能說明 |
|---|---|
Ammo.btCollisionConfiguration |
設定碰撞演算的細節(如碰撞演算法、記憶體分配) |
Ammo.btDispatcher |
處理碰撞對象之間的碰撞檢測 |
Ammo.btBroadphaseInterface |
粗略的碰撞篩選(快速排除不可能碰撞的物件) |
Ammo.btConstraintSolver |
解算剛體間的約束(如關節、彈簧) |
Ammo.btDiscreteDynamicsWorld |
核心的物理世界,負責步進模擬與管理剛體 |
Tip:在 Three.js 中,我們通常會把每個
THREE.Mesh與一個Ammo.btRigidBody互相綁定,讓渲染與物理保持同步。
2. 剛體(Rigid Body)與形狀(Collision Shape)
- Collision Shape:定義物件的碰撞體積,例如
btBoxShape(盒子)、btSphereShape(球體)或btConvexHullShape(凸殼)。形狀只負責碰撞偵測,不會直接影響渲染。 - Rigid Body:把形狀、質量、慣性矩與物理屬性(如阻尼、摩擦)結合起來,成為可以受力、移動、旋轉的實體。
注意:形狀的大小必須與 Three.js Mesh 的縮放比例相匹配,否則會出現「穿模」或「漂浮」的情況。
3. 步進模擬(Simulation Step)
物理模擬需要在每個渲染迴圈中 步進(stepSimulation),告訴 Ammo.js 前進多少時間(秒)。典型的寫法是:
const deltaTime = clock.getDelta(); // 取得上一幀與本幀的時間差
physicsWorld.stepSimulation(deltaTime, 10); // 第二個參數是最大子步數
stepSimulation 會自動更新所有剛體的變換矩陣,我們只需要把這些矩陣套用回 Three.js Mesh 即可。
程式碼範例
以下提供 五個 常見且實用的範例,從載入 Ammo.js 到完整的「球體掉落」示範,均已加入詳細註解。
範例 1️⃣ 載入 Ammo.js(非同步)
// index.html
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r152/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ammo.js@0.0.10/builds/ammo.wasm.js"></script>
<script type="module">
// 等待 Ammo 完全初始化
Ammo().then(Ammo => {
initScene(Ammo);
});
</script>
說明:Ammo.js 採用 Promise 方式返回模組,確保 WebAssembly 已載入完成後才開始建立物理世界。
範例 2️⃣ 建立物理世界(Physics World)
function createPhysicsWorld(Ammo) {
// 1. 碰撞設定與分派器
const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
// 2. 粗相位(Broadphase): 常用的動態 AABB tree
const overlappingPairCache = new Ammo.btDbvtBroadphase();
// 3. 約束解算器
const solver = new Ammo.btSequentialImpulseConstraintSolver();
// 4. 建立離散動力學世界
const physicsWorld = new Ammo.btDiscreteDynamicsWorld(
dispatcher,
overlappingPairCache,
solver,
collisionConfiguration
);
// 5. 設定重力(向下 Y 軸)
physicsWorld.setGravity(new Ammo.btVector3(0, -9.81, 0));
return physicsWorld;
}
關鍵:
btDiscreteDynamicsWorld是最常用的物理世界類別,適合大多數即時渲染需求。
範例 3️⃣ 創建剛體(Rigid Body)與對應的 Three.js Mesh
function createRigidBody(Ammo, mesh, shape, mass, pos, quat) {
// 1. 計算慣性(若質量 > 0)
const localInertia = new Ammo.btVector3(0, 0, 0);
if (mass > 0) shape.calculateLocalInertia(mass, localInertia);
// 2. 轉換 Three.js 的位置與四元數為 Ammo 格式
const transform = new Ammo.btTransform();
transform.setIdentity();
transform.setOrigin(new Ammo.btVector3(pos.x, pos.y, pos.z));
transform.setRotation(new Ammo.btQuaternion(quat.x, quat.y, quat.z, quat.w));
// 3. 建立 motion state(用於同步渲染與模擬)
const motionState = new Ammo.btDefaultMotionState(transform);
// 4. 組裝剛體建構資訊
const rbInfo = new Ammo.btRigidBodyConstructionInfo(mass, motionState, shape, localInertia);
const body = new Ammo.btRigidBody(rbInfo);
// 5. 讓 Three.js Mesh 保存對應的物理體
mesh.userData.physicsBody = body;
// 6. 若需要可自行設定阻尼、摩擦等屬性
body.setFriction(0.5);
body.setRestitution(0.2); // 彈性
return body;
}
小技巧:把
physicsBody存在mesh.userData中,之後在渲染迴圈直接取用,保持資料結構清晰。
範例 4️⃣ 「球體掉落」完整示例
let scene, camera, renderer, clock;
let physicsWorld, rigidBodies = [];
function initScene(Ammo) {
// -------------------- Three.js 基本設定 --------------------
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.2, 2000);
camera.position.set(0, 5, 15);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
clock = new THREE.Clock();
// -------------------- 照明 --------------------
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1);
scene.add(hemiLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 10, 7);
scene.add(dirLight);
// -------------------- 物理世界 --------------------
physicsWorld = createPhysicsWorld(Ammo);
// -------------------- 地板 --------------------
const groundSize = 40;
const groundGeo = new THREE.BoxGeometry(groundSize, 1, groundSize);
const groundMat = new THREE.MeshPhongMaterial({ color: 0x808080 });
const groundMesh = new THREE.Mesh(groundGeo, groundMat);
groundMesh.position.set(0, -0.5, 0);
groundMesh.receiveShadow = true;
scene.add(groundMesh);
// 物理形狀(Box)與剛體
const groundShape = new Ammo.btBoxShape(new Ammo.btVector3(groundSize / 2, 0.5, groundSize / 2));
const groundBody = createRigidBody(
Ammo,
groundMesh,
groundShape,
0, // 靜止物體質量 = 0
groundMesh.position,
groundMesh.quaternion
);
physicsWorld.addRigidBody(groundBody);
// -------------------- 球體 --------------------
const radius = 1;
const sphereGeo = new THREE.SphereGeometry(radius, 32, 32);
const sphereMat = new THREE.MeshPhongMaterial({ color: 0xff0000 });
const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
sphereMesh.position.set(0, 10, 0);
sphereMesh.castShadow = true;
scene.add(sphereMesh);
const sphereShape = new Ammo.btSphereShape(radius);
const sphereBody = createRigidBody(
Ammo,
sphereMesh,
sphereShape,
1, // 質量 1kg
sphereMesh.position,
sphereMesh.quaternion
);
physicsWorld.addRigidBody(sphereBody);
rigidBodies.push(sphereMesh); // 記錄需要同步的物件
// -------------------- 開始渲染迴圈 --------------------
animate();
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// 1. 讓物理世界前進
physicsWorld.stepSimulation(delta, 10);
// 2. 同步剛體位置回 Three.js Mesh
for (let i = 0; i < rigidBodies.length; i++) {
const mesh = rigidBodies[i];
const body = mesh.userData.physicsBody;
const ms = body.getMotionState();
if (ms) {
const tmpTransform = new Ammo.btTransform();
ms.getWorldTransform(tmpTransform);
const p = tmpTransform.getOrigin();
const q = tmpTransform.getRotation();
mesh.position.set(p.x(), p.y(), p.z());
mesh.quaternion.set(q.x(), q.y(), q.z(), q.w());
}
}
renderer.render(scene, camera);
}
說明:此範例展示了 建立地面、建立球體、步進模擬、同步渲染 四個關鍵步驟。只要把
Ammo物件傳入initScene,就能在瀏覽器即時看到球體因重力自由落體的效果。
範例 5️⃣ 加入簡易約束(Hinge Joint)
function addHingeJoint(Ammo, bodyA, bodyB, pivotA, pivotB, axisA, axisB) {
// 轉換為 Ammo 向量
const pivotVecA = new Ammo.btVector3(pivotA.x, pivotA.y, pivotA.z);
const pivotVecB = new Ammo.btVector3(pivotB.x, pivotB.y, pivotB.z);
const axisVecA = new Ammo.btVector3(axisA.x, axisA.y, axisA.z);
const axisVecB = new Ammo.btVector3(axisB.x, axisB.y, axisB.z);
// 建立 hinge 約束
const hinge = new Ammo.btHingeConstraint(
bodyA,
bodyB,
pivotVecA,
pivotVecB,
axisVecA,
axisVecB,
true // 使用參考框架(參考座標系)?
);
// 讓 hinge 受限於 -90°~90°
hinge.setLimit(-Math.PI / 2, Math.PI / 2);
physicsWorld.addConstraint(hinge, true);
}
實務應用:用於門、搖擺橋、機械手臂等需要旋轉限制的物件。只要把兩個剛體傳入,即可在 Ammo 中自動處理角度限制與摩擦。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / Best Practice |
|---|---|---|
| 1. 單位不一致 | Three.js 使用 米(m) 為單位,而 Ammo.js 預設也是米,但有時模型匯入時會以 公分 或 英吋 為基準。 | 在匯入模型或建立形狀時,統一 縮放(mesh.scale.set(0.01,0.01,0.01))或在 btVector3 乘上比例因子。 |
| 2. 形狀大小與 Mesh 不對齊 | 形狀半徑或盒子尺寸若忘記除以 2,會導致碰撞體積比視覺大一倍。 | 使用 new Ammo.btBoxShape(new Ammo.btVector3(width/2, height/2, depth/2)),或 btSphereShape(radius) 時確認半徑正確。 |
| 3. 物理更新頻率過低 | 若 stepSimulation 的 deltaTime 太大,會出現穿透或震盪。 |
固定時間步長(如 1/60s)或使用 maxSubSteps(第二參數)限制子步數,確保穩定性。 |
| 4. 記憶體泄漏 | 每幀都新建 btTransform、btVector3 等物件,未釋放會佔用大量 WASM 記憶體。 |
重用 物件或在不需要時呼叫 Ammo.destroy(obj),尤其在大量剛體建立/銷毀時。 |
| 5. 忘記將剛體加入物理世界 | 建立 btRigidBody 後未呼叫 physicsWorld.addRigidBody(body),物體不會受到模擬。 |
建立完立即加入,並在需要時使用 removeRigidBody 移除。 |
| 6. 碰撞過濾(Collision Groups)未設定 | 所有物件都會彼此碰撞,導致不必要的計算。 | 使用 btCollisionObject::setCollisionFlags 或在 addRigidBody 時傳入 group / mask 參數,篩選碰撞對象。 |
其他最佳實踐
分層管理
- 物理世界 (
physicsWorld) 與渲染場景 (scene) 分別管理,僅在渲染迴圈同步位置。 - 為可互動的物件建一個
rigidBodies陣列,方便遍歷與更新。
- 物理世界 (
使用
Clock- 透過
THREE.Clock取得精確的時間差,避免performance.now()帶來的微小誤差。
- 透過
剛體類型選擇
- 動態剛體(mass > 0)適用於受力、移動的物件。
- 靜態剛體(mass = 0)適合地面、牆壁,計算成本最低。
- 運動剛體(mass = 0 但設定
kinematicflag)用於手動控制的物件(如玩家角色),不受重力影響但仍能與其他物體碰撞。
適當的摩擦與彈性
setFriction與setRestitution影響滑動與彈跳。對於「冰面」可設定低摩擦,對於「彈跳球」則提高彈性。
除錯可視化
- Ammo.js 提供
AmmoDebugDrawer(需自行實作)或使用 Three.js 的BoxHelper、AxesHelper來顯示碰撞形狀,幫助調校尺寸與位置。
- Ammo.js 提供
實際應用場景
| 場景 | 為什麼需要 Ammo.js | 可能的實作方式 |
|---|---|---|
| 1. 互動式遊戲 | 需要即時物理回饋(跳躍、碰撞、投擲) | 使用 btRigidBody 配合 btConeTwistConstraint 實作角色骨架、彈道模擬 |
| 2. 虛擬實境(VR) | 手部或控制器與環境的碰撞判定 | 把控制器的座標傳入 btKinematicCharacterController,讓玩家能「踩」在平台上 |
| 3. 建築可視化 | 測試結構在重力或風力下的行為 | 以 btSoftBody(柔體)模擬布料、懸掛的吊橋或彈性牆體 |
| 4. 物理教育平台 | 示範牛頓運動、彈性碰撞等概念 | 結合 btCollisionWorld 的射線測試,讓使用者點擊產生力的向量 |
| 5. 產品展示 | 讓商品在場景中自然滾動、翻轉 | 為每個商品建立 btBoxShape 或 btConvexHullShape,配合滑鼠拖曳施加瞬時力 |
實務提示:在大型專案中,建議把 物理層 抽離成獨立的模組(例如
physics.js),只暴露addObject(mesh, options)、applyForce(id, vec)等 API,降低與渲染層的耦合度,方便日後切換到 Cannon.js、Oimo.js 等其他引擎。
總結
Ammo.js 為 Web 平台帶來了成熟且效能卓越的剛體物理模擬能力,與 Three.js 的結合能讓開發者在瀏覽器中快速構建 互動式 3D 體驗。本文從 核心概念、實作範例、常見陷阱 到 最佳實踐,提供了完整的入門與開發指引:
- 建立物理世界:設定碰撞配置、分派器、粗相位與解算器。
- 產生剛體與形狀:確保尺寸與單位一致,並把剛體與 Three.js Mesh 綁定。
- 步進模擬:在渲染迴圈中呼叫
stepSimulation,同步位置與旋轉。 - 避免常見錯誤:單位、尺寸、記憶體管理與更新頻率是最易出錯的環節。
- 擴展應用:透過約束、柔體或自訂碰撞過濾,可滿足從遊戲到工程模擬的多元需求。
只要掌握上述要點,你就能在 Three.js 專案中自如地加入真實的物理行為,打造出更具沉浸感與互動性的網頁應用。祝開發順利,玩得開心! 🚀