本文 AI 產出,尚未審核

Three.js – 互動性與事件:拖曳、點擊、Hover


簡介

在 3D 網頁應用中,互動性是使用者體驗的核心。僅有靜態模型或鏡頭移動的場景往往難以留下深刻印象,加入拖曳、點擊與 Hover(滑鼠懸停)等操作,能讓使用者感受到「可操作」的真實感,進而提升參與度與資訊傳達效率。

Three.js 內建的 Raycaster 與瀏覽器的 Pointer Events 結合,提供了相當彈性的事件偵測機制。即使是剛入門的開發者,只要掌握基礎概念與常見寫法,就能快速實作出直覺的 3D 互動介面,從簡易的點擊選取,到可拖曳調整位置的模型,都能在短時間內完成。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐層帶領你在 Three.js 中打造 拖曳、點擊、Hover 三種常見互動,並介紹實務上的應用情境,讓你在專案中即學即用。


核心概念

1. Raycaster 與座標轉換

Raycaster 是 Three.js 用來從螢幕座標投射射線 (ray) 到 3D 場景的工具。它會根據滑鼠或觸控點的 螢幕座標 (clientX/Y),轉換成 正規化設備座標 (NDC),再與場景中的物件做相交測試,返回相交的 Mesh、面索引等資訊。

// 建立 Raycaster 與滑鼠座標向量
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

// 監聽滑鼠移動或點擊事件
function onPointerMove(event) {
  // 轉換為 -1 ~ +1 的 NDC 座標
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
window.addEventListener('pointermove', onPointerMove);

重點所有的 Raycaster 判斷都必須在渲染循環 (render loop) 中執行,否則畫面更新與事件偵測會不同步。


2. 點擊(Click)事件

點擊最常見的需求是「選取」或「觸發」某個 3D 物件。基本流程:

  1. pointerdown 時記錄滑鼠位置(防止拖曳時誤觸發)。
  2. pointerup 時再次取得滑鼠位置,若兩次座標差距在容忍範圍內則視為點擊。
  3. 使用 raycaster.intersectObjects 取得相交的物件,執行對應的回呼。
let downPos = new THREE.Vector2();

function onPointerDown(event) {
  downPos.set(event.clientX, event.clientY);
}
function onPointerUp(event) {
  const upPos = new THREE.Vector2(event.clientX, event.clientY);
  if (downPos.distanceTo(upPos) < 5) { // 判斷為點擊
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
    raycaster.setFromCamera(mouse, camera);
    const intersects = raycaster.intersectObjects(interactiveObjects, true);
    if (intersects.length > 0) {
      const obj = intersects[0].object;
      console.log('Clicked:', obj.name);
      // 例如改變顏色
      obj.material.emissive.set(0x333333);
    }
  }
}
window.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerUp);

3. Hover(滑鼠懸停)效果

Hover 常用於即時提示(如高亮、顯示資訊框)或視覺回饋(改變材質、放大)。實作上與點擊類似,只是使用 pointermove 持續偵測,並記錄上一次懸停的物件以避免重複觸發。

let hovered = null;

function onPointerMove(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(interactiveObjects, true);

  if (intersects.length > 0) {
    const first = intersects[0].object;
    if (hovered !== first) {
      // 解除先前的 hover
      if (hovered) hovered.material.emissive.set(0x000000);
      // 設定新的 hover
      hovered = first;
      hovered.material.emissive.set(0x555555);
      // 顯示簡易資訊框
      tooltip.style.display = 'block';
      tooltip.style.left = `${event.clientX + 10}px`;
      tooltip.style.top = `${event.clientY + 10}px`;
      tooltip.textContent = hovered.name;
    }
  } else {
    // 離開所有物件
    if (hovered) hovered.material.emissive.set(0x000000);
    hovered = null;
    tooltip.style.display = 'none';
  }
}
window.addEventListener('pointermove', onPointerMove);

4. 拖曳(Drag)實作

拖曳最常見的需求是調整模型位置在平面上繪製路徑。核心概念是:

  1. pointerdown 時使用 Raycaster 取得點擊的物件與交點 (intersection point)。
  2. 建立一個 平面 (Plane),其法向量與相機視線垂直,作為拖曳的參考平面。
  3. pointermove 時把滑鼠座標投射到這個平面上,取得新的交點,將物件位置設為此點的偏移量。
let dragObject = null;
let dragOffset = new THREE.Vector3();
const dragPlane = new THREE.Plane();
const planeIntersect = new THREE.Vector3();

function onPointerDown(event) {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(interactiveObjects, true);
  if (intersects.length > 0) {
    dragObject = intersects[0].object;
    // 計算拖曳平面:與相機方向垂直,並通過交點
    dragPlane.setFromNormalAndCoplanarPoint(
      camera.getWorldDirection(new THREE.Vector3()).clone().negate(),
      intersects[0].point
    );
    // 記錄物件相對於交點的偏移量
    dragOffset.copy(intersects[0].point).sub(dragObject.position);
  }
}
function onPointerMove(event) {
  if (!dragObject) return;
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);
  // 求射線與拖曳平面的交點
  raycaster.ray.intersectPlane(dragPlane, planeIntersect);
  if (planeIntersect) {
    // 更新物件位置(扣除偏移量)
    dragObject.position.copy(planeIntersect.sub(dragOffset));
  }
}
function onPointerUp() {
  dragObject = null;
}
window.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);

技巧:如需在 不同軸向 拖曳(例如只允許 XZ 平面),可在建立 dragPlane 時固定法向量為 new THREE.Vector3(0, 1, 0)


5. 結合多種互動

在實際專案中,往往需要同時支援 點擊選取 → 顯示資訊 → 拖曳調整。以下示範如何在同一套事件流程中切換狀態:

let mode = 'select'; // 'select' | 'drag'
function onPointerDown(event) {
  // 先判斷是否在拖曳模式
  if (mode === 'drag') { /* 直接呼叫 drag 的邏輯 */ return; }
  // 否則執行點擊選取
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);
  const hits = raycaster.intersectObjects(interactiveObjects, true);
  if (hits.length) {
    const obj = hits[0].object;
    console.log('Select:', obj.name);
    // 切換到拖曳模式
    mode = 'drag';
    dragObject = obj;
    // 建立平面等同前述程式碼
    // ...
  }
}
function onPointerUp() {
  if (mode === 'drag') {
    mode = 'select';
    dragObject = null;
  }
}

常見陷阱與最佳實踐

陷阱 說明 解決方案
Raycaster 每幀未更新 若只在事件觸發時呼叫 raycaster.setFromCamera,在動畫或相機移動時會導致偵測錯位。 render loop 或每次需要判斷時重新設定。
物件密集導致相交結果過多 intersectObjects 會返回全部相交的 Mesh,若場景有大量小部件會影響效能。 使用 layersgroup 把可互動物件分組,只對需要的陣列測試。
拖曳時相機視角改變 平面法向量若跟隨相機變化,拖曳感覺會「漂移」。 固定拖曳平面(如 XZ 平面)或在拖曳開始時就確定平面。
事件冒泡與 DOM 重疊 3D 互動與 UI 元件(如按鈕)同時存在,會互相干擾。 在 UI 元件上使用 event.stopPropagation(),或在 Three.js 事件前檢查 event.target
材質共享導致全部變色 多個 Mesh 共享同一個 Material,改變 emissive 會同時影響全部。 為每個互動物件 clone 材質或使用 MeshStandardMaterial.clone()

最佳實踐

  1. 使用 requestAnimationFrame 統一渲染與 Raycaster 更新,確保畫面與互動同步。
  2. 將可互動物件存於陣列(如 interactiveObjects),避免每次遍歷整個場景。
  3. 分層 (Layers) + camera.layers.enable():在大型專案中,僅讓相機渲染需要交互的層,提升效能。
  4. 針對觸控設備:使用 pointer 事件可同時支援 mouse、touch、pen,避免分別寫 mousedown/touchstart
  5. 記憶體釋放:在不再需要的時候,務必 dispose() Geometry、Material,防止 WebGL 記憶體泄漏。

實際應用場景

場景 互動需求 實作要點
產品展示平台 點擊切換顏色、Hover 顯示規格、拖曳旋轉模型 使用 Raycaster 判斷點擊、emissive 高亮、OrbitControls 結合自訂拖曳。
建築資訊模型 (BIM) 以 Hover 顯示房間資訊、點擊進入房間、拖曳家具擺放 建立 Plane 限制拖曳在地板上、使用 layers 區分結構與家具。
資料視覺化(3D 散點圖) Hover 顯示資料點詳細、點擊鎖定焦點、拖曳選取區域 利用 Raycaster 取得最近點、BoxHelper 畫選取框、OrbitControls 禁止旋轉時拖曳。
教育互動模擬 拖曳化學分子、點擊觸發反應、Hover 顯示原子屬性 為每個原子建立獨立 Mesh,使用 dragPlane 限制在特定平面,觸發事件時播放動畫。
遊戲 UI(選擇角色) Hover 高亮角色、點擊選擇、拖曳調整站位 使用 Sprite 作為 UI 標籤,結合 RaycasterOrbitControls,保持相機平滑。

總結

在 Three.js 中,拖曳、點擊、Hover 這三種互動是打造沉浸式 3D 網頁體驗的基礎。透過 RaycasterPointer Events 的結合,我們可以精準地從螢幕座標映射到 3D 世界,進而:

  • 點擊 用於選取與觸發功能;
  • Hover 提供即時視覺回饋與資訊提示;
  • 拖曳 讓使用者直接操控模型位置或形狀。

本文從概念說明、完整範例、常見陷阱到實務應用,提供了一套 從零到可用 的操作流程。掌握這些技巧後,你可以在產品展示、建築 BIM、資料視覺化、教育模擬甚至小型遊戲中,快速加入直覺且效能良好的互動功能,提升使用者體驗與專案價值。

祝你在 Three.js 的世界裡玩得開心,創造出令人驚豔的 3D 互動作品! 🚀