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の地形モデルに沿った当たり判定(トライメッシュコライダー)があります。需要がありそうなら書きます。