Three.jsで3Dモデルの地形の上を歩く方法【Raycasterで接地判定】

平らな床の上を動かすところまでは簡単なんですが、「Blenderで作った起伏のある地形の上を歩かせる」となった途端に難易度が上がったように感じます。物理エンジンが必要?トライメッシュコライダー?…と調べ始めると大掛かりな話ばかり出てきますが、「歩く」だけなら物理エンジンは不要です。

使うのはクリック判定の回でも登場したRaycaster。キャラクターの頭上から真下に光線を飛ばして、地形に当たった高さに立たせるだけです。原始的ですが軽くて確実で、ゲームでも昔から使われている手法です。

デモはWASDで地形の上を歩けます。坂や谷に沿ってキャラクターが上下するのを確認してください。

デモを別タブで開く

地形モデルの読み込みと正規化

地形はGLTFLoaderで読み込みます。Blenderで作ったモデルはスケールがまちまちなので、バウンディングボックスからシーンに合う大きさへ正規化しておくと扱いやすいです。

let terrain = null;

new GLTFLoader().load('./model/terrain.glb', (gltf) => {
  gltf.scene.traverse((child) => {
    if (child.isMesh && !terrain) terrain = child; // 判定用にメッシュを取っておく
  });

  // 幅40ユニットに正規化して、一番低い場所をy=0に合わせる
  const box = new THREE.Box3().setFromObject(gltf.scene);
  const size = box.getSize(new THREE.Vector3());
  const scale = 40 / Math.max(size.x, size.z);
  gltf.scene.scale.setScalar(scale);
  gltf.scene.position.y = -box.min.y * scale;

  scene.add(gltf.scene);
});

ポイントは、あとでRaycasterに渡すために地形のメッシュそのものを変数に取っておくことです。gltf.scene ごと判定に使うこともできますが(recursive: true)、対象を1メッシュに絞った方が速くて確実です。

核心: 真下へのレイで地面の高さを取る

この関数がこの記事のすべてです。

const downRay = new THREE.Raycaster();
const DOWN = new THREE.Vector3(0, -1, 0);

function groundHeightAt(x, z) {
  if (!terrain) return null; // まだ読み込み中

  // その地点の上空100から真下に光線を飛ばす
  downRay.set(new THREE.Vector3(x, 100, z), DOWN);
  const hit = downRay.intersectObject(terrain, false)[0];

  return hit ? hit.point.y : null; // 当たらなければ地形の外
}

「上空100」は地形の最高地点より確実に高い位置なら何でもOKです。戻り値の hit.point.y がその(x, z)地点における地面の高さになります。

移動処理に組み込む

移動はキー入力の回と同じkeyStatesパターンです。違いは、移動する前に移動先の地面の高さを調べること。

function updatePlayer(deltaTime) {
  const move = new THREE.Vector3();
  if (keyStates['KeyW']) move.z -= 1;
  if (keyStates['KeyS']) move.z += 1;
  if (keyStates['KeyA']) move.x -= 1;
  if (keyStates['KeyD']) move.x += 1;

  if (move.lengthSq() === 0) return;
  move.normalize().multiplyScalar(speed * deltaTime);

  const nextX = player.position.x + move.x;
  const nextZ = player.position.z + move.z;
  const groundY = groundHeightAt(nextX, nextZ);

  if (groundY !== null) {
    player.position.set(nextX, groundY + PLAYER_HALF_HEIGHT, nextZ);
    player.rotation.y = Math.atan2(move.x, move.z);
  }
  // groundYがnull = 地形の外なので移動しない(=マップ端から落ちない)
}

この「先に調べてから動く」方式には副産物があって、地形の外に出ようとすると自動的に移動がキャンセルされるので、マップ境界の処理がタダで付いてきます。

PLAYER_HALF_HEIGHT はキャラクターの足元から中心までの高さです。これを足し忘れると、キャラクターが腰まで地面に埋まります。

カメラも遅れて追いかけさせる

地形で上下するぶん、カメラを直結すると画面がガクガクします。目標位置に lerp で少しずつ寄せると滑らかになります。

const cameraOffset = new THREE.Vector3(0, 10, 14);

function updateCamera() {
  const target = player.position.clone().add(cameraOffset);
  camera.position.lerp(target, 0.08);
  camera.lookAt(player.position);
}

この方式の限界と、物理エンジンとの使い分け

Raycaster接地は「地形の上を歩く」ことに特化した方法です。次のような要素が出てきたら、素直に物理エンジンを検討してください。

  • 崖からの落下や(重力のある)ジャンプ — 接地方式は常に地面に吸着するので、落下の概念がない
  • 壁への衝突 — 真下のレイでは横方向は判定できない(横向きのレイを足す手はあります)
  • 転がる物や積み上がる物との相互作用 — 完全に物理エンジンの領分

Rapierなら地形メッシュから trimesh コライダーを作って、キャラクターをkinematicボディで動かす構成になります。ただし実装量は段違いなので、「NPCが地面に沿って動けばいい」くらいの要件ならRaycasterで十分です。

パフォーマンス面の補足として、ハイポリ地形に毎フレームレイを飛ばすと重くなってきます。その場合はthree-mesh-bvhというライブラリでレイ判定を高速化できます(数十倍速くなります)。デモ程度のポリゴン数なら不要です。

まとめ

  • 「地形の上を歩く」だけなら物理エンジン不要。真下へのRaycasterで足りる
  • 手順は「移動先の地面の高さを調べる → あればその高さに立たせる、なければ動かない」
  • キャラの半分の高さを足すのを忘れない(埋まる)
  • 落下・壁・物との相互作用が必要になったら物理エンジン(Rapier)へ