本文 AI 產出,尚未審核

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. 物理更新頻率過低 stepSimulationdeltaTime 太大,會出現穿透或震盪。 固定時間步長(如 1/60s)或使用 maxSubSteps(第二參數)限制子步數,確保穩定性。
4. 記憶體泄漏 每幀都新建 btTransformbtVector3 等物件,未釋放會佔用大量 WASM 記憶體。 重用 物件或在不需要時呼叫 Ammo.destroy(obj),尤其在大量剛體建立/銷毀時。
5. 忘記將剛體加入物理世界 建立 btRigidBody 後未呼叫 physicsWorld.addRigidBody(body),物體不會受到模擬。 建立完立即加入,並在需要時使用 removeRigidBody 移除。
6. 碰撞過濾(Collision Groups)未設定 所有物件都會彼此碰撞,導致不必要的計算。 使用 btCollisionObject::setCollisionFlags 或在 addRigidBody 時傳入 group / mask 參數,篩選碰撞對象。

其他最佳實踐

  1. 分層管理

    • 物理世界 (physicsWorld) 與渲染場景 (scene) 分別管理,僅在渲染迴圈同步位置。
    • 為可互動的物件建一個 rigidBodies 陣列,方便遍歷與更新。
  2. 使用 Clock

    • 透過 THREE.Clock 取得精確的時間差,避免 performance.now() 帶來的微小誤差。
  3. 剛體類型選擇

    • 動態剛體(mass > 0)適用於受力、移動的物件。
    • 靜態剛體(mass = 0)適合地面、牆壁,計算成本最低。
    • 運動剛體(mass = 0 但設定 kinematic flag)用於手動控制的物件(如玩家角色),不受重力影響但仍能與其他物體碰撞。
  4. 適當的摩擦與彈性

    • setFrictionsetRestitution 影響滑動與彈跳。對於「冰面」可設定低摩擦,對於「彈跳球」則提高彈性。
  5. 除錯可視化

    • Ammo.js 提供 AmmoDebugDrawer(需自行實作)或使用 Three.js 的 BoxHelperAxesHelper 來顯示碰撞形狀,幫助調校尺寸與位置。

實際應用場景

場景 為什麼需要 Ammo.js 可能的實作方式
1. 互動式遊戲 需要即時物理回饋(跳躍、碰撞、投擲) 使用 btRigidBody 配合 btConeTwistConstraint 實作角色骨架、彈道模擬
2. 虛擬實境(VR) 手部或控制器與環境的碰撞判定 把控制器的座標傳入 btKinematicCharacterController,讓玩家能「踩」在平台上
3. 建築可視化 測試結構在重力或風力下的行為 btSoftBody(柔體)模擬布料、懸掛的吊橋或彈性牆體
4. 物理教育平台 示範牛頓運動、彈性碰撞等概念 結合 btCollisionWorld 的射線測試,讓使用者點擊產生力的向量
5. 產品展示 讓商品在場景中自然滾動、翻轉 為每個商品建立 btBoxShapebtConvexHullShape,配合滑鼠拖曳施加瞬時力

實務提示:在大型專案中,建議把 物理層 抽離成獨立的模組(例如 physics.js),只暴露 addObject(mesh, options)applyForce(id, vec) 等 API,降低與渲染層的耦合度,方便日後切換到 Cannon.js、Oimo.js 等其他引擎。


總結

Ammo.js 為 Web 平台帶來了成熟且效能卓越的剛體物理模擬能力,與 Three.js 的結合能讓開發者在瀏覽器中快速構建 互動式 3D 體驗。本文從 核心概念實作範例常見陷阱最佳實踐,提供了完整的入門與開發指引:

  1. 建立物理世界:設定碰撞配置、分派器、粗相位與解算器。
  2. 產生剛體與形狀:確保尺寸與單位一致,並把剛體與 Three.js Mesh 綁定。
  3. 步進模擬:在渲染迴圈中呼叫 stepSimulation,同步位置與旋轉。
  4. 避免常見錯誤:單位、尺寸、記憶體管理與更新頻率是最易出錯的環節。
  5. 擴展應用:透過約束、柔體或自訂碰撞過濾,可滿足從遊戲到工程模擬的多元需求。

只要掌握上述要點,你就能在 Three.js 專案中自如地加入真實的物理行為,打造出更具沉浸感與互動性的網頁應用。祝開發順利,玩得開心! 🚀