Three.jsをはじめよう。画面上のオブジェクトに触れる【第6回】

前回、ついに外部の3Dモデルを画面に登場させることに成功しました。でも、モデルがただそこにあるだけでは、少し寂しいですよね。「これ、クリックしたらどうなるんだろう?」「マウスを重ねても反応しない」……。そう思うのは、サイトを訪れるユーザーも同じです。

しかし、ここで一つ大きな壁にぶつかります。私たちが普段使っているブラウザの画面は「2次元(縦と横)」ですが、Three.jsの世界は「3次元(縦・横・奥行き)」です。2Dの画面上のマウス位置を、どうやって3D空間の中の「あのオブジェクト」と結びつければいいのでしょうか?

そこで登場するのが「Raycaster(レイキャスター)」です。今回は、この少し不思議で、でも一度覚えれば最高に楽しい「クリック判定」の仕組みを、皆さんと一緒に攻略していきましょう。

Raycasterでクリックを判定する

「Raycaster」という名前を直訳すると「光線を投げるもの」になります。その名の通り、マウスをクリックした場所から、画面の奥に向かって「見えないレーザー光線」をピッピッと発射するようなイメージです。

レイキャスターの仕組み

  1. マウスがクリックされた位置を特定する。
  2. その地点から、画面の奥(カメラの向き)に向かって光線を飛ばす。
  3. その光線が、3D空間にあるオブジェクトに「当たったか」を判定する。
  4. 当たったものがあれば、その情報を取得する。

私たちがやることは、この「光線の発射」と「当たったかどうかの確認」をプログラムで記述することだけです。

「座標の変換」を一緒に乗り越えよう

ここが、Raycasterを学ぶ上でもっとも「うっ……」となりやすいポイントですが、大丈夫です。私と一緒に、ゆっくり紐解いていきましょう。

実は、マウスの座標(px単位)をそのままThree.jsでは使えません。画面の左上を (0, 0)とするのではなく、画面の中心を(0, 0)とし、範囲を-1から1の間に変換(正規化)する必要があります。

なぜ変換が必要なの?

画面のサイズは人それぞれ(スマホだったり、4Kモニターだったり)ですが、Three.jsの数学的な計算には、共通の -1 〜 1 という尺度が必要だからです。

変換の計算式

以下のコードをコピーして使えば、いつでも変換できます。

const mouse = new THREE.Vector2();

window.addEventListener('mousemove', (event) => {
// マウスのX座標を -1 〜 +1 に変換
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
// マウスのY座標を -1 〜 +1 に変換(上下が逆なのでマイナスをつける)
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});

一見複雑に見えますが、「端から端までを2等分して、中心を0にするための数式」だと考えれば、複雑なことは何も行っていません。

実践:当たったものを「検知」する

座標の準備ができたら、いよいよ光線を飛ばしてみましょう。

// レイキャスターの準備
const raycaster = new THREE.Raycaster();

function animate() {
    requestAnimationFrame(animate);

    // 1. レイキャスターを「マウスの位置」と「カメラ」にセット
    raycaster.setFromCamera(mouse, camera);

    // 2. 光線が当たったオブジェクトを配列で取得
    const intersects = raycaster.intersectObjects(scene.children);

    // 3. 当たったものがあれば処理をする
    if (intersects.length > 0) {
        // 一番手前にある当たったオブジェクトの色を変えてみる
        intersects[0].object.material.color.set(0xff0000);
    }

    renderer.render(scene, camera);
}

この数行で、マウスの下にあるオブジェクトを検知できるようになります。配列 intersects の一番目([0])に、最も手前で当たったオブジェクトの情報が入っているのがポイントです。

より自然な動きを実現する活用方法

ただ色が変わるだけでなく、もっと「サイトとしての完成度」を高めるための工夫を、一緒に見ていきましょう。

マウスホバーで「カーソルの形」を変える

3Dオブジェクトがクリックできることをユーザーに伝えるには、マウスカーソルの形を pointer(指マーク)に変えるのが親切です。

if (intersects.length > 0) {
    document.body.style.cursor = 'pointer';
} else {
    document.body.style.cursor = 'default';
}

特定のオブジェクトだけを判定する

scene.children(全部)を判定対象にすると、背景やライトまで判定してしまい、動作が重くなることがあります。

実務では、判定したいオブジェクトだけを一つの「グループ」にまとめておき、そのグループ内だけをスキャンするようにしてパフォーマンスを稼ぎます。

今回のステップアップ:クリックで「情報を出す」

では、学んだことを組み合わせて、「クリックした瞬間に選んだ要素名と適応カラーが表示される」仕組みを作ってみましょう。

今回追加したコード

// ===== Raycaster 追加分 =====
      // Raycasterとマウス座標ベクトルを作成
      // Vector2はx,y成分のみのベクトル(画面上の2D座標用)
      const raycaster = new THREE.Raycaster();
      const mouse = new THREE.Vector2();

      // 選択中のオブジェクトとハイライト用エミッション色を管理
      let selectedObject = null;
      const highlightColor = new THREE.Color(0xffff00); // ハイライト色(黄色)
      const originalEmissive = new THREE.Color(0x000000); // デフォルトの自己発光色(黒=なし)

      /**
       * クリックイベントハンドラ
       * ポイント: canvas要素に対してイベントを登録することで
       *           UIパネルのクリックと混在しない
       */
      renderer.domElement.addEventListener("click", onMouseClick);

      function onMouseClick(event) {
        // --- ② NDC変換 ---
        // ピクセル座標 (0〜width, 0〜height) を
        // NDC座標 (-1〜+1, -1〜+1) に変換する
        // Y軸はWebGLとブラウザで上下が逆なので符号を反転する
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

        // --- ③ Ray生成 ---
        // カメラとマウス座標からレイを計算して更新
        raycaster.setFromCamera(mouse, camera);

        // --- ④ 交差判定 ---
        // scene内の全メッシュと交差チェック
        // 第2引数trueで子オブジェクトも再帰的にチェックする
        const intersects = raycaster.intersectObjects(scene.children, true);

        if (intersects.length > 0) {
          // 最も手前にあるオブジェクト(距離が最短)を取得
          const hit = intersects[0].object;

          // MeshStandardMaterial / MeshPhysicalMaterial でないと
          // emissiveプロパティがないのでチェック
          if (!hit.isMesh || !hit.material?.emissive) return;

          // 前回の選択オブジェクトのハイライトを解除
          if (selectedObject && selectedObject !== hit) {
            selectedObject.material.emissive.set(originalEmissive);
          }

          // 同じオブジェクトを再クリックしたら選択解除(トグル)
          if (selectedObject === hit) {
            hit.material.emissive.set(originalEmissive);
            selectedObject = null;
            updateInfoPanel(null);
          } else {
            // 新しいオブジェクトを選択してハイライト
            hit.material.emissive.set(highlightColor);
            hit.material.emissiveIntensity = 0.3; // 強さ: 0〜1
            selectedObject = hit;
            updateInfoPanel(hit);
          }
        } else {
          // 何もないところをクリックしたら選択解除
          if (selectedObject) {
            selectedObject.material.emissive.set(originalEmissive);
            selectedObject = null;
            updateInfoPanel(null);
          }
        }
      }

      /**
       * 情報パネルの更新
       * クリックしたパーツ名をHUDに表示する
       */
      function updateInfoPanel(object) {
        let panel = document.getElementById("part-info");
        if (!panel) {
          panel = document.createElement("div");
          panel.id = "part-info";
          panel.style.cssText = `
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(0,0,0,0.75);
            color: white;
            padding: 12px 20px;
            border-radius: 6px;
            font-family: sans-serif;
            font-size: 14px;
            pointer-events: none;
            transition: opacity 0.3s;
            line-height: 1.8;
          `;
          document.body.appendChild(panel);
        }

        if (object) {
          // material.color を #rrggbb 形式の文字列に変換
          const hex = object.material?.color
            ? "#" + object.material.color.getHexString()
            : "不明";

          panel.innerHTML = `
            ${object.name || "不明"}の詳細<br>
            カラー:${hex}
          `;
          panel.style.opacity = "1";
        } else {
          panel.style.opacity = "0";
        }
      }

      // ===== ホバーハイライト(クリックとは別に、マウスオーバーで色を変える実装例) =====

      let hoveredObject = null;
      const hoverColor = new THREE.Color(0x88aaff); // ホバー色(水色)

      renderer.domElement.addEventListener("mousemove", onMouseMove);

      function onMouseMove(event) {
        // NDC変換(クリックと同じ計算)
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

        raycaster.setFromCamera(mouse, camera);
        const intersects = raycaster.intersectObjects(scene.children, true);

        // 前回ホバーしていたオブジェクトをリセット
        // ただし選択中のオブジェクトはリセットしない
        if (hoveredObject && hoveredObject !== selectedObject) {
          if (hoveredObject.material?.emissive) {
            hoveredObject.material.emissive.set(originalEmissive);
          }
          hoveredObject = null;
          renderer.domElement.style.cursor = "default";
        }

        if (intersects.length > 0) {
          const hit = intersects[0].object;
          if (hit.isMesh && hit.material?.emissive && hit !== selectedObject) {
            hit.material.emissive.set(hoverColor);
            hit.material.emissiveIntensity = 0.2;
            hoveredObject = hit;
            renderer.domElement.style.cursor = "pointer"; // カーソルを指マークに
          }
        }
      }

これができれば、特定のパーツをクリックした時に詳細説明をポップアップで出したり、別ページへ飛ばしたりといった、本物の「WEBサービス」としての機能が実装できるようになります。

3D空間との「対話」が始まる

お疲れ様でした!今回のステップで、あなたの3Dサイトは「見るだけの展示品」から「ユーザーと会話できるインターフェース」へと劇的に進化しました。

今回の学びのポイント

Raycaster
マウス位置から飛ばす「見えないレーザー」。
座標の正規化
ブラウザのpxを「 -1 〜 1 」の世界に変換する。
交差判定
intersectObjects で、当たったものの情報を掴み取る。
UXの配慮
カーソルの変化などで、ユーザーに「触れる」ことを伝える。

さて、インタラクションができるようになったら、次は空間をもっと華やかに…。

次回、第7回では「パーティクル(粒子)で空間を彩る」方法を学びます。星空や雪、キラキラと舞う光の粒……。様々な使い道が思いつきそうです。サイトの背景を幻想的に演出するためのテクニックを、一緒にマスターしましょう。

それでは、また次回!