本文 AI 產出,尚未審核

Three.js – 互動性與事件

主題:滑鼠與鍵盤事件綁定


簡介

在 3D 網頁應用中,互動性 是讓使用者感受到沉浸感的關鍵。即使是最炫麗的模型、光影與材質,如果無法透過滑鼠或鍵盤與之互動,就很難傳達資訊或引導使用者探索。Three.js 本身提供了渲染與場景管理的基礎,但事件處理(mouse、keyboard、touch)則需要開發者自行綁定與管理。

本篇文章將從 基本概念、實作範例、常見陷阱 以及 最佳實踐,一步一步說明如何在 Three.js 中正確且高效地綁定滑鼠與鍵盤事件,讓你的 3D 場景活起來。無論是剛接觸 Three.js 的新手,或是想提升互動技巧的中級開發者,都能在本文找到可直接套用的程式碼與思考方式。


核心概念

1. 事件的來源與座標系統

事件類型 來源 常見屬性
mousemove / mousedown / mouseup 滑鼠 clientX、clientY(螢幕座標)
wheel 滑鼠滾輪 deltaY(滾動距離)
keydown / keyup 鍵盤 code、key、keyCode
touchstart / touchmove / touchend 手指觸控 touches、changedTouches

Three.js 渲染器(WebGLRenderer)會把 螢幕座標 轉換成 正規化設備座標(NDC),範圍為 [-1, 1](左/下為 -1,右/上為 +1),之後才能用於射線投射(raycasting)或相機控制。

重點:

  • 螢幕座標 ≠ Three.js 內部座標。
  • 必須先將 clientX/Y 正規化,才能正確取得與 3D 物件的交叉資訊。

2. Raycaster:從螢幕點到 3D 物件的橋樑

THREE.Raycaster 是 Three.js 用來 偵測滑鼠指向哪個物件 的核心工具。它接受一條射線(origin + direction),並回傳與之相交的物件陣列。

const raycaster = new THREE.Raycaster();   // 建立 Raycaster
const mouse = new THREE.Vector2();          // 用來存放正規化後的滑鼠座標

在每次滑鼠移動或點擊時,我們會:

  1. 更新 mouse 座標:mouse.x = (event.clientX / width) * 2 - 1;
  2. 設定射線:raycaster.setFromCamera(mouse, camera);
  3. 取得交叉物件:const intersects = raycaster.intersectObjects(scene.children, true);

3. 事件綁定的方式

3.1 原生 DOM 事件

最直接的方式是使用 window.addEventListener 或 renderer.domElement.addEventListener 綁定事件。

renderer.domElement.addEventListener('mousemove', onMouseMove, false);
renderer.domElement.addEventListener('mousedown', onMouseDown, false);
window.addEventListener('keydown', onKeyDown, false);

3.2 使用控制器(Controls)

Three.js 官方提供的控制器(如 OrbitControls、PointerLockControls)內部已經封裝了部分滑鼠與鍵盤的處理。若你只需要相機的旋轉、平移或第一人稱視角,直接使用這些控制器會更簡潔。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);

提醒:即便使用控制器,仍然需要自行監聽自訂的點擊或鍵盤觸發事件,兩者可以同時共存。


程式碼範例

以下示範 5 個常見且實用的事件綁定情境,皆以完整的註解說明每一步驟。

範例 1️⃣:滑鼠懸停(Hover)改變物件顏色

// 基本場景設定(略)
// -------------------------------------------------
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoveredObject = null;

// 1. 監聽滑鼠移動
renderer.domElement.addEventListener('mousemove', (event) => {
  // 2. 正規化座標
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // 3. 設定射線
  raycaster.setFromCamera(mouse, camera);

  // 4. 取得交叉物件(只檢查可點選的 mesh)
  const intersects = raycaster.intersectObjects(scene.children, true);

  // 5. 若有交叉,改變顏色,否則還原
  if (intersects.length > 0) {
    const first = intersects[0].object;
    if (hoveredObject !== first) {
      // 還原先前的物件
      if (hoveredObject) hoveredObject.material.emissive.setHex(0x000000);
      // 設定新物件
      hoveredObject = first;
      hoveredObject.material.emissive.setHex(0x3333ff);
    }
  } else {
    if (hoveredObject) hoveredObject.material.emissive.setHex(0x000000);
    hoveredObject = null;
  }
});

說明:

  • 使用 material.emissive 讓物件在懸停時發光,避免改變原本的 color。
  • intersectObjects(..., true) 會遞迴搜尋子階層,適合有群組的情況。

範例 2️⃣:滑鼠點擊(Click)選取與取消選取

renderer.domElement.addEventListener('mousedown', (event) => {
  // 正規化座標(同範例 1)
  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 (intersects.length > 0) {
    const selected = intersects[0].object;
    // 若已被選取則取消
    if (selected.userData.isSelected) {
      selected.material.color.setHex(selected.userData.originalColor);
      selected.userData.isSelected = false;
    } else {
      // 記錄原始顏色,方便之後還原
      selected.userData.originalColor = selected.material.color.getHex();
      selected.material.color.setHex(0xff0000); // 選取顏色為紅
      selected.userData.isSelected = true;
    }
  }
});

說明:

  • 透過 userData 儲存自訂屬性(原始顏色、選取狀態),不會污染物件本身的結構。
  • 點擊時使用 mousedown 而非 click,可以在拖曳開始前即捕捉事件,避免與控制器衝突。

範例 3️⃣:滾輪縮放(Zoom)相機

function onWheel(event) {
  // 以相機的遠近距離作為縮放基礎
  const delta = event.deltaY * 0.05; // 調整靈敏度
  camera.position.z += delta;

  // 防止相機過近或過遠
  camera.position.z = THREE.MathUtils.clamp(camera.position.z, 5, 100);
}
renderer.domElement.addEventListener('wheel', onWheel, { passive: true });

說明:

  • passive: true 告訴瀏覽器此監聽器不會呼叫 preventDefault(),提升滾動效能。
  • 使用 THREE.MathUtils.clamp 限制相機距離,避免穿透模型或看不見場景。

範例 4️⃣:鍵盤控制相機平移(WASD)

const moveSpeed = 0.2;
const keysPressed = {};

window.addEventListener('keydown', (e) => (keysPressed[e.code] = true));
window.addEventListener('keyup', (e) => (keysPressed[e.code] = false));

function updateCameraPosition() {
  // 前後
  if (keysPressed['KeyW']) camera.translateZ(-moveSpeed);
  if (keysPressed['KeyS']) camera.translateZ(moveSpeed);
  // 左右
  if (keysPressed['KeyA']) camera.translateX(-moveSpeed);
  if (keysPressed['KeyD']) camera.translateX(moveSpeed);
  // 上下
  if (keysPressed['Space']) camera.translateY(moveSpeed);
  if (keysPressed['ShiftLeft']) camera.translateY(-moveSpeed);
}

// 在渲染迴圈中呼叫
function animate() {
  requestAnimationFrame(animate);
  updateCameraPosition();
  renderer.render(scene, camera);
}
animate();

說明:

  • 使用 camera.translateX/Y/Z 可讓相機沿自身座標軸移動,避免自行計算向量。
  • keysPressed 物件以 code 為鍵,可同時偵測多鍵同時按下,實現平滑移動。

範例 5️⃣:PointerLock(第一人稱視角)結合滑鼠視角

import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js';

const controls = new PointerLockControls(camera, renderer.domElement);
scene.add(controls.getObject());

// 點擊畫面讓瀏覽器進入 pointer lock
document.addEventListener('click', () => {
  controls.lock();
});

// 鍵盤控制前後移動(同上)
const velocity = new THREE.Vector3();
const direction = new THREE.Vector3();

function updatePointerLockControls() {
  // 鍵盤方向向量
  direction.z = Number(keysPressed['KeyW']) - Number(keysPressed['KeyS']);
  direction.x = Number(keysPressed['KeyD']) - Number(keysPressed['KeyA']);
  direction.normalize();

  // 加速度
  const speed = 0.1;
  velocity.x -= velocity.x * 0.1;
  velocity.z -= velocity.z * 0.1;
  velocity.x += direction.x * speed;
  velocity.z += direction.z * speed;

  controls.moveRight(-velocity.x);
  controls.moveForward(-velocity.z);
}

// 在 animate 中呼叫
function animate() {
  requestAnimationFrame(animate);
  updatePointerLockControls();
  renderer.render(scene, camera);
}
animate();

說明:

  • PointerLockControls 會自動隱藏滑鼠指標並捕捉相對移動,適合第一人稱射擊或探索類型。
  • 必須在使用者互動(如 click)後才能呼叫 lock(),否則瀏覽器會阻擋。

常見陷阱與最佳實踐

陷阱 說明 解決方式
座標未正規化 直接使用 clientX/Y 會導致 Raycaster 無法正確命中物件 必須先做 mouse.x = (clientX / width) * 2 - 1 之類的正規化
事件冒泡與控制器衝突 同時使用 OrbitControls 與自訂 mousedown,可能會因控制器先攔截事件而失效 在綁定時使用 event.stopPropagation() 或將自訂監聽器掛在 renderer.domElement(而非 window)
頻繁呼叫 Raycaster 每個 mousemove 都做 intersectObjects 會造成效能瓶頸 可使用節流(throttle)或在 requestAnimationFrame 中統一處理
物件太多時的搜尋成本 intersectObjects(scene.children, true) 會遍歷全部子物件 使用 layers 或 group 只對需要可點擊的物件建立 Raycaster
鍵盤事件在不同平台的 code 差異 某些鍵在不同瀏覽器或語系會產生不同 keyCode 建議以 event.code(不受鍵盤佈局影響)作為判斷依據
PointerLock 被瀏覽器阻擋 沒有使用者互動就呼叫 lock() 會失敗 必須在 click、touchstart 等事件回呼內呼叫 controls.lock()

最佳實踐小結

  1. 統一座標正規化:封裝成 getNormalizedMouse(event) 函式,所有事件共用。
  2. 節流(throttle)或防抖(debounce):對 mousemove、wheel 使用 lodash.throttle 或自行實作,減少不必要的 Raycaster 計算。
  3. 分層(Layers):將可互動物件放在特定 layer,Raycaster 只檢測該 layer,提升效能。
  4. 使用 userData:將自訂狀態(如 isSelected、originalColor)存於 object.userData,保持物件本身的乾淨。
  5. 釋放資源:在切換場景或銷毀物件時,必須 removeEventListener,避免記憶體泄漏。

實際應用場景

場景 需要的事件 實作要點
產品展示(3D 互動購物) 滑鼠懸停顯示資訊、點擊切換顏色、滾輪縮放 使用 Raycaster 懸停顯示 Tooltip,點擊改變材質,限制縮放範圍以免跑出視窗
教育模擬(分子結構、天文) 多點選取、鍵盤切換層級、滑鼠拖曳旋轉 結合 OrbitControls 與自訂 mousedown,使用 Shift+Click 多選
第一人稱遊戲 PointerLock、WASD 移動、滑鼠左右鍵開火 PointerLockControls 處理視角,鍵盤控制移動,滑鼠 mousedown 發射子彈
資料可視化(3D 圖表) 滑鼠點擊顯示詳細資料、鍵盤切換圖表類型 Raycaster 點擊取得資料點,keydown 切換不同的圖表材質或顏色
虛擬導覽(建築漫遊) 滾輪縮放、鍵盤切換樓層、滑鼠點擊開門 使用 OrbitControls + wheel 調整相機高度,keydown 改變樓層透明度,點擊門模型觸發動畫

總結

滑鼠與鍵盤事件是 Three.js 互動性的核心。透過正確的座標正規化、Raycaster 的運用,以及適當的事件綁定方式(原生 DOM、官方 Controls),我們可以讓 3D 場景從靜態模型變成 可探索、可操作 的應用。

本文提供了 5 個實用範例,說明從懸停、點擊、縮放、鍵盤移動到 PointerLock 的完整流程,同時列出常見的陷阱與最佳實踐,幫助開發者在開發過程中避免效能與相容性問題。只要掌握以下三個關鍵:

  1. 座標正規化:所有滑鼠相關事件必須先轉換為 NDC。
  2. Raycaster:用於精確的物件偵測,配合 layers 或 group 提升效能。
  3. 事件管理:使用 addEventListener、removeEventListener、節流與 userData,保持程式碼乾淨且可維護。

結合本文的概念與範例,你即可在 Three.js 中快速構建出 流暢、直覺且功能完整 的 3D 互動體驗。祝你開發順利,玩得開心!