対象: 初心者 / JavaScript 基礎と DOM の addEventListener を触ったことがある人
ゴール: Three.js で「3Dオブジェクトに触る(ホバー・クリック・ドラッグ)」を、図とコードで理解する。
むずかしい言葉には都度(注: …)で注釈を入れています。文章が多くなる箇所にはMermaidの図とASCII図も用意しました。
0. Three.jsってなに?(まず全体像)
- Three.js は WebGL(注: ブラウザの3D描画エンジン)をやさしいAPIで扱えるようにした 3Dライブラリ です。
- 3Dの基本3点:
- Scene(注: 物体や光の置き場)
- Camera(注: 見る位置・視野)
- Renderer(注: 画面に描く人)
図1: Three.js の基本構成
ASCII図(Mermaidが使えない場合)
[Scene] + [Camera] --(Renderer)--> <canvas> に描画
1. まずは最小サンプル(動く土台)
ポイント:Three.js は ES Modules(注:
importで読み込む仕組み)で使うのが基本です。入門では CDN を使ってOK(本番はバージョン固定推奨)。
<!doctype html>
<meta charset="utf-8" />
<title>Three.js Minimal</title>
<style> body{ margin:0; overflow:hidden } canvas{ display:block } </style>
<script type="module">
import * as THREE from 'https://unpkg.com/three@latest/build/three.module.js';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
// 1) Renderer
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 2) Scene & Camera
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202124);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(3, 2, 5);
// 3) Controls(注: マウスで視点を回す便利道具)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 慣性
// 4) Light
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.0));
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
dir.position.set(5,10,7);
scene.add(dir);
// 5) Objects
const box = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({ color: 0x4fc3f7 }));
box.position.set(-1.2, 0.5, 0);
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.6, 32, 16), new THREE.MeshStandardMaterial({ color: 0xffb74d }));
sphere.position.set(1.2, 0.6, 0);
scene.add(box, sphere);
// 6) Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// 7) Loop
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
</script>
2. Three.js で「イベント」はどう扱う?(考え方)
- DOMのボタンのように、3Dオブジェクトそのものに
clickを直接つけることは できません。
3Dオブジェクトは DOM 要素ではないためです。 -
やり方は Canvas のときと同じ発想:
-
pointermove / pointerdown / pointerupなどをrenderer.domElement() で受け取る - 2D座標を NDC(注: 正規化デバイス座標、-1〜+1の世界)に変換
- Raycaster(注: 画面から奥へ「光線」を飛ばす道具)で、どの3Dオブジェクトに当たったか を調べる(= ピッキング)
- 結果に応じて「ホバー」「クリック」「ドラッグ」を実装
-
図2: イベントの流れ(DOM→NDC→Ray→ヒット)
ASCII図
pointermove/down → NDC(-1..+1) → Raycaster.setFromCamera → intersectObjects → ロジック実行
3. ホバー(カーソルを当てたら反応)
import * as THREE from 'https://unpkg.com/three@latest/build/three.module.js';
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const pickables = []; // クリック対象リスト(Meshをpush)
function setPointerFromEvent(event, renderer) {
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;
}
let hover = null;
renderer.domElement.addEventListener('pointermove', (e) => {
setPointerFromEvent(e, renderer);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, true); // 再帰 true で子まで探す
const hit = hits[0]?.object || null;
if (hover !== hit) {
// 以前のhoverを元に戻す
if (hover && hover.material?.emissive) hover.material.emissive.setHex(0x000000);
hover = hit;
if (hover && hover.material?.emissive) hover.material.emissive.setHex(0x222222);
renderer.domElement.style.cursor = hover ? 'pointer' : 'default';
}
});
(注)NDC:スクリーン座標を -1〜+1 に正規化した座標。Three.js の Raycaster は NDC を入力に使います。
4. クリック(選択 / トグル)
renderer.domElement.addEventListener('pointerdown', (e) => {
setPointerFromEvent(e, renderer);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, true);
if (hits.length) {
const obj = hits[0].object;
// 例:色を少し変える
if (obj.material?.color) obj.material.color.offsetHSL(0.08, 0, 0);
}
});
図3: ピッキングのイメージ
5. ドラッグ(平面に投影して動かす)
ドラッグは 「マウスが指す点(レイ)を、ある平面に当てる」→その点にオブジェクトの位置を合わせる で実装できます。
平面の決め方はいくつかあります:
- カメラに正対する平面(注: ユーザーが見ている方向に垂直な平面)
- 床(Y=0 などの固定平面) に沿って動かす
図4: ドラッグの考え方(レイ×平面の交点)
ASCII図
Raycasterのray ──⊥── Plane(交点)→ その点にobject.positionを置く
コード(カメラ正対の平面でドラッグ)
import * as THREE from 'https://unpkg.com/three@latest/build/three.module.js';
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const dragPlane = new THREE.Plane();
const dragOffset = new THREE.Vector3();
const dragPoint = new THREE.Vector3();
const cameraDir = new THREE.Vector3();
let dragging = null; // { object, pointerId }
function setPointerFromEvent(event, renderer) {
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;
}
renderer.domElement.addEventListener('pointerdown', (e) => {
setPointerFromEvent(e, renderer);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, true);
if (!hits.length) return;
const hit = hits[0];
dragging = { object: hit.object, pointerId: e.pointerId };
renderer.domElement.setPointerCapture(e.pointerId);
// いま見ている方向で平面を作る(object位置を通る)
camera.getWorldDirection(cameraDir);
dragPlane.setFromNormalAndCoplanarPoint(cameraDir, dragging.object.position);
// 交点を出して、オブジェクト中心との差分を覚える(滑らかなドラッグのため)
raycaster.ray.intersectPlane(dragPlane, dragPoint);
dragOffset.copy(dragPoint).sub(dragging.object.position);
controls.enabled = false; // OrbitControls を一時無効化
});
renderer.domElement.addEventListener('pointermove', (e) => {
if (!dragging || e.pointerId !== dragging.pointerId) return;
setPointerFromEvent(e, renderer);
raycaster.setFromCamera(pointer, camera);
if (raycaster.ray.intersectPlane(dragPlane, dragPoint)) {
dragging.object.position.copy(dragPoint.sub(dragOffset));
}
});
function endDrag(e) {
if (!dragging || e.pointerId !== dragging.pointerId) return;
renderer.domElement.releasePointerCapture(e.pointerId);
dragging = null;
controls.enabled = true; // OrbitControls を戻す
}
renderer.domElement.addEventListener('pointerup', endDrag);
renderer.domElement.addEventListener('pointercancel', endDrag);
(注)
setPointerCapture/releasePointerCapture:ドラッグ中、ポインタが外へ出てもイベントを取りこぼさないための仕組み。
6. OrbitControls とイベントの相性(競合の回避)
-
OrbitControls はドラッグでカメラを動かします。一方でオブジェクトのドラッグもポインタ操作を使います。
→ ドラッグ開始〜終了のあいだだけcontrols.enabled=falseにして、競合を避けるのが定番。
図5: カメラ操作とドラッグの切り替え
コード: OrbitControls のイベント
controls.addEventListener('start', () => { /* 回転/パン開始 */ });
controls.addEventListener('change', () => { /* 毎フレームの更新時に呼ばれる(描画) */ });
controls.addEventListener('end', () => { /* 終了 */ });
7. 便利ツール:DragControls(お手軽ドラッグ)
Three.js の 例(examples) には DragControls という簡易ドラッグユーティリティがあります。
カメラ正対の平面ドラッグを自動でやってくれます。
import { DragControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/DragControls.js';
const objects = [box, sphere]; // ドラッグ対象
const dragControls = new DragControls(objects, camera, renderer.domElement);
dragControls.addEventListener('dragstart', () => {
controls.enabled = false; // OrbitControls と競合回避
});
dragControls.addEventListener('drag', (e) => {
// e.object が動いているMesh。必要なら制約(Y固定など)をかける
});
dragControls.addEventListener('dragend', () => {
controls.enabled = true;
});
(注)DragControls は手軽ですが、軸制限やスナップなど高度な制御をしたくなると限界が出ます。応用では Raycaster + Plane の手組みが柔軟。
8. モバイル/タブレット対応(Pointer Events)
-
Pointer Events(注: マウス/タッチ/ペンをひとまとめに扱う仕様)を使えば、
pointerdown/move/upだけで多くのケースをカバーできます。 -
ピンチズームしたい場合は OrbitControls が対応済み(2本指でズーム)。ドラッグ中は
controls.enabled=falseにするルールは同じ。
9. 性能のコツ(入門版)
-
intersectObjects の対象は最小限に:
pickables配列で管理(不要な背景や床は除外)。 - ヒット判定は 近いものから(※Three.js は自動で近距離順に返すので先頭だけ使うのが早い)。
- すごく数が多い場合は、当面は「粗い当たり判定→当たった候補だけ精密判定」の二段階が簡単。
10. ありがちなつまずき
-
「クリックしても当たらない」
→NDCへの変換式がズレている、renderer.domElement以外の要素を基準に計算している、カメラのaspect更新を忘れている…を確認。 -
「ドラッグが途切れる/暴れる」
→setPointerCaptureを使う、dragPlaneの作り方(法線ベクトル)を見直す、毎フレームintersectPlaneの結果をチェック。 -
「OrbitControls とケンカする」
→ ドラッグ中はcontrols.enabled=false。または DragControls のdragstart/dragendで切替。 -
「透明な部分でも当たる」
→ マテリアルや形状によっては当たり判定が形状ベースになります(注: 透明テクスチャの透過はヒットに反映されません)。必要なら 独自の粗判定→精判定 を実装。 -
「描画が更新されない」
→renderer.setAnimationLoop(またはrequestAnimationFrame)でループし続ける。OrbitControls だけで描画するならcontrols.addEventListener('change', render)型も可。
11. チートシート
-
NDC変換:
const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; -
Raycaster:
raycaster.setFromCamera(pointer, camera); const hit = raycaster.intersectObjects(pickables, true)[0]; -
ドラッグ(平面):
camera.getWorldDirection(dir); plane.setFromNormalAndCoplanarPoint(dir, object.position); raycaster.ray.intersectPlane(plane, point); object.position.copy(point.sub(offset)); -
OrbitControls:ドラッグ中は
controls.enabled=false。changeで都度render()もOK。 - DragControls:お手軽だが自由度は低め。
12. まとめ
- Three.js の「イベント」は DOM → NDC → Raycaster → ヒット の流れで考える。
- ホバー:見た目変化+カーソル変更。クリック:選択/アクション。ドラッグ:レイ×平面の交点で位置決め。
- OrbitControls と競合しないように、ドラッグ中だけ 無効化 するのがコツ。
- 最初は 最小の対象でピッキング、慣れてきたら制約・スナップ・複数選択などへ拡張。
付録A:最小の「ホバー&クリック」デモ(コピペで動く)
ブラウザでファイルを開くだけでOK(CDN依存)。
<!doctype html>
<meta charset="utf-8" />
<title>Three.js Picking Minimal</title>
<style> body{ margin:0; overflow:hidden } canvas{ display:block } </style>
<script type="module">
import * as THREE from 'https://unpkg.com/three@latest/build/three.module.js';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202124);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(3, 2, 5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.AxesHelper(3));
const light = new THREE.DirectionalLight(0xffffff, 1.0); light.position.set(5,8,5); scene.add(light);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
const box = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({ color: 0x4fc3f7 }));
box.position.set(-1.2, 0.5, 0);
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.6, 32, 16), new THREE.MeshStandardMaterial({ color: 0xffb74d }));
sphere.position.set(1.2, 0.6, 0);
scene.add(box, sphere);
const pickables = [box, sphere];
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hover = null;
function setPointer(e) {
const r = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
}
renderer.domElement.addEventListener('pointermove', (e) => {
setPointer(e);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(pickables, true)[0]?.object || null;
if (hit !== hover) {
if (hover?.material?.emissive) hover.material.emissive.setHex(0x000000);
hover = hit;
if (hover?.material?.emissive) hover.material.emissive.setHex(0x333333);
renderer.domElement.style.cursor = hover ? 'pointer' : 'default';
}
});
renderer.domElement.addEventListener('pointerdown', (e) => {
setPointer(e);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(pickables, true)[0];
if (hit?.object?.material?.color) hit.object.material.color.offsetHSL(0.08, 0, 0);
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
</script>
付録B:ドラッグできるデモ(平面投影)
<!doctype html>
<meta charset="utf-8" />
<title>Three.js Dragging</title>
<style> body{ margin:0; overflow:hidden } canvas{ display:block } </style>
<script type="module">
import * as THREE from 'https://unpkg.com/three@latest/build/three.module.js';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x202124);
const camera = new THREE.PerspectiveCamera(60, innerWidth/innerHeight, 0.1, 100); camera.position.set(3,2,5);
const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true;
// 簡単な床
const grid = new THREE.GridHelper(20, 20, 0x444444, 0x222222); scene.add(grid);
const light = new THREE.DirectionalLight(0xffffff, 1.0); light.position.set(4,8,6); scene.add(light);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
const box = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({ color: 0x4fc3f7 }));
box.position.set(0, 0.5, 0);
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.6, 32, 16), new THREE.MeshStandardMaterial({ color: 0xffb74d }));
sphere.position.set(2, 0.6, 0);
scene.add(box, sphere);
const pickables = [box, sphere];
// レイとドラッグ用
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const plane = new THREE.Plane();
const cameraDir = new THREE.Vector3();
const planePoint = new THREE.Vector3();
const offset = new THREE.Vector3();
let dragging = null;
function setPointer(e) {
const r = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
}
renderer.domElement.addEventListener('pointerdown', (e) => {
setPointer(e);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(pickables, true)[0];
if (!hit) return;
dragging = { object: hit.object, id: e.pointerId };
renderer.domElement.setPointerCapture(e.pointerId);
camera.getWorldDirection(cameraDir);
plane.setFromNormalAndCoplanarPoint(cameraDir, dragging.object.position);
raycaster.ray.intersectPlane(plane, planePoint);
offset.copy(planePoint).sub(dragging.object.position);
controls.enabled = false;
});
renderer.domElement.addEventListener('pointermove', (e) => {
if (!dragging || e.pointerId !== dragging.id) return;
setPointer(e);
raycaster.setFromCamera(pointer, camera);
if (raycaster.ray.intersectPlane(plane, planePoint)) {
dragging.object.position.copy(planePoint.sub(offset));
}
});
function endDrag(e) {
if (!dragging || e.pointerId !== dragging.id) return;
renderer.domElement.releasePointerCapture(e.pointerId);
dragging = null;
controls.enabled = true;
}
renderer.domElement.addEventListener('pointerup', endDrag);
renderer.domElement.addEventListener('pointercancel', endDrag);
window.addEventListener('resize', () => {
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
</script>
参考メモ(用語)
- NDC: Normalized Device Coordinates。-1〜+1の正規化スクリーン座標。Raycasterの入力に使う。
- Raycaster: スクリーンから飛ばした仮想の「光線」で、3D空間内のオブジェクトと交わるか(ヒット)を調べる道具。
-
Plane: 平面。レイとの交点を計算できる(
ray.intersectPlane)。 - OrbitControls: マウス/タッチでカメラを回転・ズーム・パンできる補助ツール(examples 収録)。
- Pointer Events: マウス/タッチ/ペンをまとめて扱えるWeb標準のイベント仕様。