Three.jsでオブジェクトのクリック判定・ホバー判定を実装する【Raycaster】

Three.jsを触り始めて最初に「あれ?」となるのがこれだと思います。3Dオブジェクトには addEventListener('click') が使えません。canvasに描かれたただの絵なので、DOM要素のようなイベントは飛んでこないんですね。

代わりに使うのが Raycaster です。仕組みは名前の通りで、カメラからマウスカーソルの方向に見えない光線(レイ)を飛ばして、「その光線が何に刺さったか」を調べます。刺さったオブジェクトの一覧が返ってくるので、それを使ってクリックやホバーを自前で判定します。

まずデモです。箱にマウスを乗せると光ってカーソルが指マークになり、クリックするとランダムな色に変わってぽよんと弾みます。

デモを別タブで開く

手順1: マウス座標を正規化する

Raycasterに渡すマウス座標は、ピクセルではなく**正規化デバイス座標(NDC)**です。画面の中心が(0, 0)、左下が(-1, -1)、右上が(+1, +1)という座標系で、変換式は定型文です。

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

function setPointer(event) {
  pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
  pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

Yに-1を掛けているのがポイントです。ブラウザの画面座標は下がプラスですが、Three.jsの世界は上がプラスなので反転させます。ここを忘れると「上をクリックしたのに下の物が反応する」という不思議な挙動になります。

なお、この式はcanvasが画面いっぱいに広がっている前提です。ページの一部にcanvasを埋め込んでいる場合は、canvasの位置とサイズを基準に計算します。

const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

「デモをコピーして自分のページに組み込んだら判定がズレる」ときは、ほぼ100%これが原因です。

手順2: クリック判定

光線を飛ばして交差を調べるのは2行です。

window.addEventListener('click', (event) => {
  setPointer(event);
  raycaster.setFromCamera(pointer, camera); // カメラからカーソル方向に光線をセット

  const intersects = raycaster.intersectObjects(boxes); // 判定したい対象の配列
  if (intersects.length === 0) return;

  const clicked = intersects[0].object; // [0]が一番手前
  clicked.material.color.set(0xff4444);
});

intersectObjects() の戻り値はカメラに近い順にソートされた配列です。光線は貫通するので、奥に並んだオブジェクトも全部入ってきます。普通のクリック判定なら intersects[0](一番手前)だけ使えばOKです。

戻り値には object 以外にも便利な情報が入っています。

  • intersects[0].point — 光線が当たった3D空間上の座標
  • intersects[0].distance — カメラからの距離
  • intersects[0].face — 当たった面

point は「クリックした床の位置に物を置く」みたいな処理でそのまま使えます。実際、Rapier物理エンジンの記事のデモでは、床をクリックした位置にオブジェクトを降らせるのにこれを使っています。

手順3: ホバーで光らせる

ホバーは pointermove で同じことをするだけです。ただし「前にホバーしていたものを元に戻す」処理が必要になります。

let hovered = null;

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

  const intersects = raycaster.intersectObjects(boxes);

  // 前回のホバーを解除
  if (hovered) {
    hovered.material.emissive.set(0x000000);
    hovered = null;
  }

  if (intersects.length > 0) {
    hovered = intersects[0].object;
    hovered.material.emissive.set(0x333300); // 発光色で目立たせる
  }

  document.body.style.cursor = hovered ? 'pointer' : 'default';
});

ハイライトに color ではなく emissive(発光色)を使っているのは、元の色を上書きせずに済むからです。color を変えてしまうと元に戻すときに「元の色はなんだったか」を覚えておく必要がありますが、emissive なら黒に戻すだけです。

最後の1行のカーソル変更は地味ですが効果絶大で、これがあるだけで「クリックできる感」が段違いになります。

glTFモデルで判定するときの注意

GLTFLoaderで読み込んだモデルにクリック判定を付けようとすると、最初は多分うまくいきません。理由は2つあります。

1. モデルはグループ構造になっている。 gltf.scene はメッシュを子孫に持つObject3Dのツリーです。デフォルトの intersectObject は直下しか調べないので、第2引数に true を渡して子孫まで再帰的に調べます。

const intersects = raycaster.intersectObject(gltf.scene, true);

2. 当たったのは「子メッシュ」。 intersects[0].object に入っているのは末端のメッシュで、モデル本体ではありません。「どのモデルがクリックされたか」を知りたいときは、親を辿るのが確実です。

let target = intersects[0].object;
while (target.parent && !target.userData.isPlayer) {
  target = target.parent;
}

モデルのルートに userData で目印を付けておいて、それが見つかるまで parent を遡る、というのが私の定番パターンです。

パフォーマンスの注意

Raycasterの判定は「対象のポリゴン数 × 頻度」で重くなります。気をつけるのは2点だけです。

  • 毎フレーム飛ばさない。 アニメーションループ内ではなく、clickpointermove イベントの中だけで判定する
  • 対象を絞る。 intersectObjects(scene.children) と全部に飛ばすのではなく、判定が必要なオブジェクトだけの配列を渡す。ハイポリのモデルに対しては、モデル自体ではなく透明な箱(バウンディングボックス)を判定用に置くという手もあります

デモ程度の物量なら何も気にしなくて大丈夫ですが、モデルが増えてきたときに思い出してください。

まとめ

  • 3Dオブジェクトにclickイベントはないので、Raycasterで「カメラから光線を飛ばして」判定する
  • マウス座標は正規化(-1〜+1、Yは反転)してから渡す。canvasが全画面でないなら getBoundingClientRect() 基準で
  • 戻り値は近い順の配列。通常は intersects[0] だけ使う
  • ホバーは emissive でハイライト+カーソル変更が定番
  • glTFモデルは recursive: true と親辿りが必要