Three.jsに物理エンジンを導入する【Rapier入門】落下・衝突・デバッグ表示

Three.jsだけでも物を動かすことはできますが、「落ちる・ぶつかる・転がる・積み上がる」を自前で書くのは無理があります。重力や衝突はゲームの根幹なのに、真面目に実装しようとすると数学の沼です。そこで物理エンジンの出番です。

今回導入する Rapier はRust製・WebAssembly動作の物理エンジンで、私はThree.jsで物理を使うならこれを推しています。理由は後述しますが、まずデモを触ってみてください。床をクリックするとその位置にオブジェクトが降ってきて、跳ねて転がって積み重なります。「コライダーのデバッグ表示」にチェックを入れると、物理エンジンが見ている当たり判定の形(ワイヤーフレーム)が可視化されます。

デモを別タブで開く

なぜRapierなのか

Three.jsと組み合わせる物理エンジンの候補はいくつかあります。私は一通り試した結果Rapierに落ち着きました。

  • cannon-es — 純JavaScript製で導入が一番楽。ただし本家cannon.jsの更新が止まって久しく、フォーク版のcannon-esも開発は緩やか。性能も控えめ
  • Ammo.js — 老舗Bullet物理の移植で機能は豊富だが、APIがC++の移植そのままで書き味がつらい
  • Jolt — 新興で性能は良いが、情報がまだ少ない
  • Rapier — Rust製でWASM動作。高速で、公式ドキュメントが充実していて、開発も活発。APIも現代的

性能と将来性のバランスで、今から始めるならRapier一択だと思っています。

導入: compat版をCDNで読み込む

Rapierには通常版(@dimforge/rapier3d)とcompat版(@dimforge/rapier3d-compat)があります。違いはWASMファイルの扱いで、通常版は.wasmファイルを別途配信する必要がある(バンドラー側の設定が要る)のに対し、compat版はWASMがJSに埋め込まれていて読み込むだけで動きます。CDN利用でもViteでも設定不要なので、compat版が楽です。

シリーズ第1回と同じimportmap方式なら、1行足すだけです。

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.172.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.172.0/examples/jsm/",
      "@dimforge/rapier3d-compat": "https://esm.sh/@dimforge/rapier3d-compat@0.14.0"
    }
  }
</script>

使う前にWASMの初期化を待つ必要があります。これを忘れると world is not defined 系のエラーで落ちます。

import RAPIER from '@dimforge/rapier3d-compat';

await RAPIER.init(); // 初期化完了を待ってから使う

const world = new RAPIER.World({ x: 0, y: -9.8, z: 0 }); // 重力を渡す

World が物理シミュレーションの本体です。重力の -9.8 は地球の重力加速度で、Rapierは1ユニット=1メートル想定で調整されています。シーンを極端に大きいスケールで作っていると挙動が不自然になるので、物理を使うなら「1ユニット=1m」でシーンを作るのがおすすめです。

大前提: 物理の世界と描画の世界は別物

ここがRapier(に限らず物理エンジン全般)の一番大事な考え方です。

Three.jsのシーンとRapierのワールドは、まったく別の世界として並行して存在します。 Rapierは見た目を一切知らないまま「この形の物体がここにあって、こう動く」を計算するだけ。Three.jsは物理を一切知らないまま、言われた位置にメッシュを描くだけ。この2つを毎フレーム同期させるのが実装者の仕事です。

だから1つの「物体」を作るには、3点セットが必要になります。

// 1. リジッドボディ(物理的な存在。位置・速度を持つ)
const body = world.createRigidBody(
  RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 8, 0)
);

// 2. コライダー(当たり判定の形。ボディに取り付ける)
world.createCollider(
  RAPIER.ColliderDesc.ball(0.5)
    .setRestitution(0.6)  // 反発係数: 跳ね具合
    .setFriction(0.8),    // 摩擦
  body
);

// 3. メッシュ(見た目。Three.js側)
const mesh = new THREE.Mesh(
  new THREE.SphereGeometry(0.5, 24, 24),
  new THREE.MeshStandardMaterial({ color: 0x44cc88 })
);
scene.add(mesh);

リジッドボディには3タイプあって、用途がはっきり分かれています。

  • dynamic — 重力と衝突で動く。落ちる箱、転がるボールはこれ
  • fixed — 絶対に動かない。床、壁、地形
  • kinematic — 物理の影響は受けないが、他の物を押せる。プレイヤーキャラや動く床に使う(キャラ操作はこれが本命ですが、長くなるので別記事にします)

床はfixedボディで作ります。

world.createCollider(
  RAPIER.ColliderDesc.cuboid(10, 0.1, 10),
  world.createRigidBody(RAPIER.RigidBodyDesc.fixed())
);

1つ罠があって、cuboid の引数は「半分の寸法」ですcuboid(10, 0.1, 10) は20×0.2×20の箱になります。Three.jsの BoxGeometry(20, 0.2, 20) は全体の寸法なので、同じ数字を渡すと当たり判定が半分のサイズになってズレます。見た目と判定が合わないときは、まずここを疑ってください。

ループ: 物理を進めて結果をコピーする

同期はアニメーションループでやります。流れは「物理を1歩進める → 結果の位置と回転をメッシュに書き写す」だけです。

const clock = new THREE.Clock();

function animate() {
  const deltaTime = clock.getDelta();

  if (deltaTime < 1) {           // タブ復帰時などの巨大なdeltaを無視
    world.timestep = deltaTime;  // 実時間に合わせて進める
    world.step();                // 物理シミュレーションを1歩進める

    for (const { mesh, body } of objects) {
      mesh.position.copy(body.translation());
      mesh.quaternion.copy(body.rotation());
    }
  }

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

body.translation()body.rotation() が物理計算後の位置と回転で、これをそのまま copy() するだけで同期完了です。回転は rotation ではなく quaternion にコピーする点だけ注意してください(Rapierの回転はクォータニオンで返ってきます)。

if (deltaTime < 1) のガードは地味に重要です。別タブに切り替えて戻ってくると getDelta() が数十秒を返すことがあり、そのまま step() すると物体が吹っ飛びます。

デバッグ表示は最初に仕込む

「見た目は正しいのに変な位置で跳ねる」「すり抜ける」——物理のバグは目に見えないコライダーが原因なので、当てずっぽうで直すのは不毛です。Rapierには全コライダーをワイヤーフレームで出力する debugRender() があり、Three.jsのLineSegmentsに流し込むだけで可視化できます。

const debugMesh = new THREE.LineSegments(
  new THREE.BufferGeometry(),
  new THREE.LineBasicMaterial({ vertexColors: true })
);
debugMesh.frustumCulled = false;
scene.add(debugMesh);

function updateDebug() {
  const { vertices, colors } = world.debugRender();
  debugMesh.geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
  debugMesh.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
}
// ループ内で updateDebug() を呼ぶ

デモのチェックボックスで実際に見られます。「当たり判定のサイズが見た目の半分だった」みたいなミスはこれで一瞬で見つかるので、物理を入れたら最初に仕込むことを強くおすすめします。

クリックした場所に降らせる

デモの「床をクリックすると降ってくる」は、前回のRaycasterとの合わせ技です。光線が床に当たった3D座標(intersects[0].point)を取って、その真上にdynamicボディを生成しているだけです。

window.addEventListener('click', (event) => {
  setPointer(event);
  raycaster.setFromCamera(pointer, camera);

  const hit = raycaster.intersectObject(floorMesh)[0];
  if (hit) spawn(hit.point.x, hit.point.z); // 当たった位置の上空に生成
});

なお、オブジェクトを消すときはメッシュとボディの両方を消します。scene.remove(mesh) だけだと見えない物体が物理世界に残り続けて、新しく落とした物が「何もない空中」で跳ねる怪奇現象が起きます。

scene.remove(old.mesh);
world.removeRigidBody(old.body); // こちらを忘れがち

まとめ

  • 物理エンジンは今から選ぶならRapier。CDNなら設定不要のcompat版を使い、await RAPIER.init() を忘れずに
  • 物理世界と描画世界は別物。「ボディ+コライダー+メッシュ」の3点セットで作り、ループで位置と回転を同期する
  • cuboid は半分の寸法指定。Three.js側の寸法と2倍ズレに注意
  • deltaTime のガードを入れてから world.step()
  • debugRender() の可視化は最初に仕込む。物理のデバッグ効率が桁違いになる

次のステップとしては、kinematicボディを使ったキャラクター操作や、glTFの地形モデルに沿った当たり判定(トライメッシュコライダー)があります。需要がありそうなら書きます。