本文 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. 物理更新頻率過低 若 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 參數,篩選碰撞對象。

其他最佳實踐

  1. 分層管理

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

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

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

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

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

實際應用場景

場景 為什麼需要 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 體驗。本文從 核心概念、實作範例、常見陷阱 到 最佳實踐,提供了完整的入門與開發指引:

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

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