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