0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Three.js超入門:イベント(クリック・ホバー・ドラッグ)の考え方と実装(図解付き)

Posted at

対象: 初心者 / JavaScript 基礎と DOM の addEventListener を触ったことがある人
ゴール: Three.js で「3Dオブジェクトに触る(ホバー・クリック・ドラッグ)」を、図とコードで理解する。
むずかしい言葉には都度(注: …)で注釈を入れています。文章が多くなる箇所にはMermaidの図ASCII図も用意しました。


0. Three.jsってなに?(まず全体像)

  • Three.jsWebGL(注: ブラウザの3D描画エンジン)をやさしいAPIで扱えるようにした 3Dライブラリ です。
  • 3Dの基本3点:
    1. Scene(注: 物体や光の置き場)
    2. Camera(注: 見る位置・視野)
    3. 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 のときと同じ発想:
    1. pointermove / pointerdown / pointerup などを renderer.domElement() で受け取る
    2. 2D座標を NDC(注: 正規化デバイス座標、-1〜+1の世界)に変換
    3. Raycaster(注: 画面から奥へ「光線」を飛ばす道具)で、どの3Dオブジェクトに当たったか を調べる(= ピッキング
    4. 結果に応じて「ホバー」「クリック」「ドラッグ」を実装

図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. ありがちなつまずき

  1. 「クリックしても当たらない」
    NDC への変換式がズレている、renderer.domElement 以外の要素を基準に計算している、カメラの aspect 更新を忘れている…を確認。
  2. 「ドラッグが途切れる/暴れる」
    setPointerCapture を使う、dragPlane の作り方(法線ベクトル)を見直す、毎フレーム intersectPlane の結果をチェック。
  3. 「OrbitControls とケンカする」
    → ドラッグ中は controls.enabled=false。または DragControls の dragstart/dragend で切替。
  4. 「透明な部分でも当たる」
    → マテリアルや形状によっては当たり判定が形状ベースになります(注: 透明テクスチャの透過はヒットに反映されません)。必要なら 独自の粗判定→精判定 を実装。
  5. 「描画が更新されない」
    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=falsechange で都度 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標準のイベント仕様。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?