WebGL & Three.js

Three.js Raycasterでクリック・ホバー判定|canvas座標とドラッグ除外

Three.jsの物体をクリック・タップで選ぶ方法。canvas内の座標変換、入れ子Meshの判定、ホバー色の復元、ドラッグを選択から除外する処理を、動作確認済みコードで解説します。

この記事の目次
  1. 座標は、canvasの左上を引いてから正規化する
  2. 判定処理をpicker.jsへまとめる
  3. 箱ごとに色を保存し、ホバーを外したら戻す
  4. ドラッグは「離した場所」だけで判断しない
  5. 反応しない・位置がずれるときの確認順

Three.jsでクリックした物体を調べるには、canvas内のポインター位置を−1〜1の座標へ変換し、Raycasterでカメラからその方向へ判定します。ヒットした物体のうち、いちばん手前のMeshを選択対象にできます。

ただし、ウィンドウ全体の幅で座標を割ると、ページの途中に置いたcanvasでは位置がずれます。また、ドラッグでカメラを回した後のpointerupまで選択扱いにすると、意図しない物体が選ばれます。この記事では、その2点を含めて、ホバー・クリック・タップを実装します。

座標は、canvasの左上を引いてから正規化する

clientX・clientYと、getBoundingClientRect()の位置を使います。どちらも画面内の位置なので、スクロール量を別に足す必要はありません。

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

pointer.set(x, y);
raycaster.setFromCamera(pointer, camera);

xは左端が−1、右端が1です。yはブラウザ座標と上下の向きが逆になるため、上端を1、下端を−1にします。Three.jsのRaycaster公式リファレンスでも、setFromCamera()にはこの正規化された座標を渡すと説明されています。

ここで使うのは表示上の幅・高さです。renderer.setPixelRatio(2)を指定したときの描画バッファ幅や、canvas.widthで割りません。以下のサンプルのcanvasには、border・padding・回転変形を付けていません。装飾の枠は外側の要素へ付けると、座標の基準を揃えやすくなります。

スポンサーリンク

判定処理をpicker.jsへまとめる

空のフォルダーで次を実行します。動作確認はThree.js 0.186.0、Vite 7.0.0、Node.js 24.13.0で行いました。

npm init -y
npm pkg set type=module
npm install three@0.186.0
npm install --save-dev vite@7.0.0

picker.jsを作成します。ホバー対象が変わったらonHover、クリック・タップが確定したらonSelectを呼ぶ形です。色変更は次のmain.js側で行います。

import * as THREE from 'three';

export function createPicker({ canvas, camera, roots, onHover, onSelect }) {
  const raycaster = new THREE.Raycaster();
  const pointer = new THREE.Vector2();
  raycaster.layers.set(1);
  let down = null;
  let hovered = null;
  const listeners = new AbortController();

  function pick(clientX, clientY) {
    const rect = canvas.getBoundingClientRect();
    if (!rect.width || !rect.height || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) return null;
    pointer.set(
      ((clientX - rect.left) / rect.width) * 2 - 1,
      -((clientY - rect.top) / rect.height) * 2 + 1,
    );
    camera.updateWorldMatrix(true, false);
    roots.forEach((root) => root.updateWorldMatrix(true, true));
    raycaster.setFromCamera(pointer, camera);
    const hits = raycaster.intersectObjects(roots, true);
    return hits.find(({ object }) => {
      for (let parent = object; parent; parent = parent.parent) if (!parent.visible) return false;
      return true;
    })?.object ?? null;
  }

  function setHover(mesh) {
    if (mesh === hovered) return;
    onHover(mesh, hovered);
    hovered = mesh;
    canvas.style.cursor = mesh ? 'pointer' : '';
  }
  const on = (name, handler) => canvas.addEventListener(name, handler, { signal: listeners.signal });
  on('pointerdown', (event) => {
    if (!event.isPrimary || event.button !== 0) { down = null; return; }
    down = { id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false, mesh: pick(event.clientX, event.clientY) };
    canvas.setPointerCapture(event.pointerId);
  });
  on('pointermove', (event) => {
    if (down?.id === event.pointerId) {
      if (Math.hypot(event.clientX - down.x, event.clientY - down.y) > 6) down.dragged = true;
      setHover(null);
      return;
    }
    if (event.pointerType !== 'touch' && !event.buttons) setHover(pick(event.clientX, event.clientY));
  });
  on('pointerup', (event) => {
    const started = down;
    down = null;
    if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
    if (!started || started.id !== event.pointerId || started.dragged || Math.hypot(event.clientX - started.x, event.clientY - started.y) > 6) return;
    const mesh = pick(event.clientX, event.clientY);
    if (mesh && mesh === started.mesh) onSelect(mesh);
    if (event.pointerType !== 'touch') setHover(mesh);
  });
  on('pointerleave', () => setHover(null));
  on('pointercancel', () => { down = null; setHover(null); });
  on('lostpointercapture', () => { down = null; });
  return {
    pick,
    clearHover: () => setHover(null),
    dispose() { listeners.abort(); down = null; setHover(null); },
  };
}

intersectObjects(roots, true)のtrueは、Groupなどの子孫まで調べる指定です。結果は近い順に並ぶため、このコードでは表示対象として扱う最初のMeshを返します。面の境界では一つのMeshに複数ヒットすることもありますが、ここでは物体を一つ選べればよいので、ヒット件数をクリック回数には使いません。

さらに、visible = falseの親を持つ物体も除外しています。画面に見えることと、Raycasterの対象へ入っていることは同じ条件とは限らないので、アプリ側で選択対象を揃えます。

箱ごとに色を保存し、ホバーを外したら戻す

続いてmain.jsを作ります。箱はGroupの中へ入れ、判定対象としてはGroupを渡しています。各箱のMaterialは別々に作り、変更前のColorをclone()して保持します。

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { createPicker } from './picker.js';

const host = document.querySelector('#scene');
const scene = new THREE.Scene();
scene.background = new THREE.Color('#e2e8f0');
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 1, 7);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
host.append(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = false;
const group = new THREE.Group();
scene.add(group);
const geometry = new THREE.BoxGeometry(1.3, 1.3, 1.3);
const materials = [];
const baseColors = new Map();
for (const [name, x, color] of [['青い箱', -1.1, '#2563eb'], ['赤い箱', 1.1, '#e11d48']]) {
  const material = new THREE.MeshBasicMaterial({ color });
  materials.push(material);
  const mesh = new THREE.Mesh(geometry, material);
  mesh.name = name;
  mesh.position.x = x;
  mesh.layers.enable(1); // Keep visible layer 0; add picking layer 1.
  baseColors.set(mesh, material.color.clone());
  group.add(mesh);
}
let selections = 0;
const picker = createPicker({
  canvas: renderer.domElement, camera, roots: [group],
  onHover(next, previous) {
    if (previous) previous.material.color.copy(baseColors.get(previous));
    if (next) next.material.color.set('#fbbf24');
  },
  onSelect(mesh) {
    selections++;
    document.querySelector('#selection').textContent = `${mesh.name}を選択(${selections}回)`;
  },
});
controls.addEventListener('start', picker.clearHover);
function resize() {
  const { width, height } = host.getBoundingClientRect();
  if (!width || !height) return;
  renderer.setSize(width, height);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  picker.clearHover();
}
const observer = new ResizeObserver(resize);
observer.observe(host);
resize();
renderer.setAnimationLoop(() => renderer.render(scene, camera));

export function dispose() {
  observer.disconnect();
  picker.dispose();
  controls.dispose();
  renderer.setAnimationLoop(null);
  geometry.dispose();
  materials.forEach((material) => material.dispose());
  renderer.dispose();
  renderer.domElement.remove();
}
// Exports let the local test check real scene coordinates and material restoration.
export { scene, camera, group, renderer, picker, controls, baseColors };

onHover(next, previous)では、前の箱の色を戻してから、次の箱を黄色にします。何もない場所へ移ったときやcanvasを出たときは、nextがnullになるので、前の色を戻すだけです。タッチでは、タップ後にホバー色が残らないようにしています。

同じMaterialを複数の物体で共有すると、一つの色を変えたつもりでも一緒に変わります。既存モデルへ組み込むときは、Materialを共有しているか確認してください。Colorの扱いと共有時の変更は、Three.jsのMaterial色を設定する方法で詳しく解説しています。

この例では元の色を初期化時に保存しています。別の機能から色を変更するアプリでは、ホバーを外したときに古い初期色へ戻さないよう、通常時の色の管理方法も揃える必要があります。

最後にindex.htmlを保存します。

<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Raycasterのクリック・ホバーデモ</title>
<style>
body{max-width:900px;margin:60px auto;padding:24px;font:17px/1.7 system-ui}
#scene{width:100%;height:380px}canvas{display:block;touch-action:none}
</style>
<h1>箱へカーソルを重ね、クリックする</h1>
<p>タッチではタップで選択します。ドラッグはカメラ操作で、選択しません。</p>
<div id="scene"></div>
<p id="selection" role="status">未選択</p>
<script type="module" src="/main.js"></script>
</html>

次を実行し、ターミナルに表示されたURLを開きます。青と赤の箱へカーソルを重ねると黄色になり、クリックすると下に選択した箱の名前が出ます。タッチではタップで選びます。

npx vite --host 127.0.0.1

サンプルではドラッグで視点を回せます。カメラ操作そのものは、OrbitControlsで視点を操作する方法へまとめています。今回の選択処理とは役割を分けてください。

ドラッグは「離した場所」だけで判断しない

pointerdownとpointerupの距離だけを見ると、大きく動かして元の場所へ戻したドラッグが、クリックとして扱われてしまいます。サンプルでは、途中で一度でも6 CSSピクセルを超えて動いたら、その操作をドラッグとして記録しています。

6ピクセルはこのデモの調整値で、Three.jsが定める基準ではありません。入力機器や操作対象の大きさに合わせて変えます。長押しを別操作にしたい場合は、移動量だけでなく、押している時間の条件も追加します。

操作 このサンプルの扱い
同じ箱の上で小さな移動の後に離す クリック・タップとして選択する
大きく動かして元の場所へ戻る 途中の移動を記録しているため、選択しない
canvasの外で離す 座標の範囲外なので、選択しない
ブラウザからpointercancelが届く 開始状態を破棄し、選択しない
押した箱と、離した位置の箱が異なる 選択しない

pointer captureは、押した後にポインターがcanvasを出ても、操作の終わりを受け取るために使っています。キャンセルと通常のpointerupは別に処理し、古い開始位置を次の操作へ持ち越しません。

反応しない・位置がずれるときの確認順

症状 確認する箇所
canvasの中央以外でずれる windowの幅ではなくrect.width・heightを使い、left・topを引いているか
高DPI画面だけずれる 表示サイズとcanvas.widthなどの描画バッファサイズを混ぜていないか
Group内の物体を選べない 再帰探索を有効にし、Meshが判定対象のレイヤーに入っているか
カメラや物体を動かした直後にずれる ワールド行列が更新されているか。画面サイズ変更時にprojection matrixも更新したか
片面の平面を裏から選べない 面の向きとMaterial.sideを確認する。両面を選ぶ必要があるならDoubleSideを検討する
一つの箱を触ると全部同じ色になる Materialを共有していないか

レイヤー設定では、サンプルのmesh.layers.enable(1)に注目してください。表示に使うレイヤー0を残したまま、判定用の1を追加しています。set(1)に置き換えると0が外れ、カメラ側が0だけを描画する設定では物体が見えなくなります。また、Groupに1を追加するだけでなく、判定されるMeshにも設定します。

手元のブラウザ検証では、DPR 1・2、余白付きcanvas、幅1,100から390ピクセルへの変更、手前に重なるMesh、非表示の親、ドラッグして元へ戻る操作、pointercancel、タップを確認しました。ホバーを外した後の色も、保存した元のColorと一致しています。

この実装は、単一Materialを持つ通常のMeshを一つ選ぶ例です。読み込んだモデルをパーツ単位でなく「製品全体」として選ぶなら、ヒットしたMeshから選択用の親へたどる処理を加えます。InstancedMeshではinstanceIdも使うなど、選択対象の単位に応じて戻り値を広げてください。

まずは余白のあるcanvasで、選びたい物体の上だけに反応するか確認します。位置が合ったら、ホバーの色戻しと、ドラッグ後に選択が増えないことを確認すれば、カメラ操作と選択操作を両立させられます。

スポンサーリンク