本文 AI 產出,尚未審核

Three.js 物理引擎整合:物體碰撞、重力與物理模擬

簡介

在 3D 網頁應用中,視覺效果固然重要,但若缺乏真實的物理回饋,使用者的沉浸感往往會大打折扣。Three.js 本身提供了強大的渲染能力,卻沒有內建完整的物理引擎;因此在需要「碰撞偵測」或「重力」等模擬時,我們必須額外整合外部的物理庫(如 Cannon.js、Ammo.js、Oimo.js 等),或自行實作簡易的物理系統。

本單元將帶你了解:

  1. 為什麼要在 Three.js 中加入物理模擬
  2. 常見的 碰撞偵測與回應 方法
  3. 重力、慣性與彈性 的基本概念與實作
  4. 如何 把物理引擎與 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)

有時候希望某些物體互不碰撞(例如玩家與自己的子彈),可以利用 collisionFilterGroupcollisionFilterMask 來設定。

// 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 };
}

重點collisionFilterGroupcollisionFilterMask 的組合讓子彈只會與指定的物體碰撞,避免自撞。


範例 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.jsBufferGeometry

常見陷阱與最佳實踐

陷阱 可能的症狀 解決方案 / 最佳實踐
時間步不一致 物體穿牆、彈跳不自然 使用 固定時間步 (world.step(fixedDelta, delta, maxSubSteps));避免直接以 deltaTime 作為唯一步長
Mesh 同步遺漏 渲染位置與物理位置不吻合 每幀必須 copy body.position & body.quaternion 到對應的 Mesh,且不要在其他地方自行改變 Mesh 的位置
質量設為 0 但仍想移動 物體無法受力移動 0 質量的剛體是 靜態,若需要手動移動,改用 mass > 0 並使用 applyForcevelocity
碰撞形狀與模型不匹配 物體看似穿過其他物件 為每個 Mesh 選擇最貼近的 Collision Shape(Box、Sphere、Cylinder、ConvexPolyhedron),若模型複雜可使用 簡化網格
過多剛體 FPS 大幅下降 只為需要互動的物體建立剛體,靜態環境(牆、地面)使用 mass = 0;若數量仍過多,可考慮 分層碰撞檢測四叉樹
阻尼或彈性過大 物體永遠不會停下或彈跳過度 合理設定 linearDampingangularDampingrestitution;測試不同值後再決定最自然的參數
使用 WebAssembly 物理引擎時忘記等待初始化 undefined 錯誤、程式崩潰 使用 Ammo().then()await 確保 Ammo 模組已載入完成才建立世界

最佳實踐小結

  1. 先規劃剛體:先決定哪些物件需要物理、哪些只渲染。
  2. 保持單一來源真相:所有位置與旋轉資訊皆由物理引擎產生,Three.js 僅負責顯示。
  3. 使用 requestAnimationFrame 與固定時間步 結合,保證模擬穩定。
  4. 妥善釋放資源:在切換場景或銷毀物件時,務必 world.removeBodyworld.removeConstraint,避免記憶體洩漏。
  5. 調校參數:利用 debug visualizer(如 CANNON.BodyHelper)觀察碰撞形狀與實際模型的差距,調整 massrestitutiondamping

實際應用場景

場景 需求 推薦實作方式
平台跳躍遊戲 重力、平台碰撞、彈跳、移動平台 使用 Cannon.js,設定 restitution 為 0.2~0.4,搭配 RaycastVehicle(或自行實作角色控制)
3D 產品展示(可拖拉) 物件拖拽、碰撞避免、滑鼠互動 使用 PointToPointConstraint 實作拖拽,利用 collisionFilter 防止物件相互穿透
虛擬實境 (VR) 互動 手部模型與環境的即時碰撞、物理回饋 結合 Ammo.js(因為 WebAssembly 效能更佳)與 WebXR,使用 applyForce 讓手部觸碰產生回彈
模擬車輛或機械 車輪懸吊、懸掛、剛體連接 使用 Cannon.js RaycastVehicleAmmo.js btRaycastVehicle,加入 hingeslider 約束
布料、旗幟、髮絲 軟體體、彈性、風力 Ammo.js 的 SoftBody 系統非常適合;配合 Three.jsShaderMaterial 可實作更真實的光影效果
多人線上遊戲 同步物理狀態、網路延遲補償 在伺服器端使用 Node.js + Cannon.js 進行權威物理模擬,客戶端僅做渲染與簡易預測

總結

Three.js 為渲染提供了強大的工具,而 物理引擎 則賦予 3D 場景生命力與互動深度。透過本文的 概念說明五個完整範例、以及 常見陷阱與最佳實踐,你應該已經能夠:

  1. 選擇合適的物理庫(Cannon.js、Ammo.js、Oimo.js)
  2. 建立剛體、設定重力與時間步,確保模擬穩定
  3. 實作碰撞偵測、彈性與阻尼,讓物體行為更自然
  4. 把物理狀態與 Three.js Mesh 同步,避免視覺與運算脫節
  5. 根據不同應用場景,選擇最適合的模擬方式(平台遊戲、VR 互動、車輛、布料等)

只要把 「渲染」「物理」 兩條主線恰當地結合,你的 Three.js 專案就能從單純的視覺展示,躍升為 充滿真實感與互動性的沉浸式體驗。祝你玩得開心,開發順利! 🚀