本文 AI 產出,尚未審核

Three.js – 互動性與事件

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


簡介

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

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


核心概念

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

事件類型 來源 常見屬性
mousemove / mousedown / mouseup 滑鼠 clientXclientY(螢幕座標)
wheel 滑鼠滾輪 deltaY(滾動距離)
keydown / keyup 鍵盤 codekeykeyCode
touchstart / touchmove / touchend 手指觸控 toucheschangedTouches

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.addEventListenerrenderer.domElement.addEventListener 綁定事件。

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

3.2 使用控制器(Controls)

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

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) 會遍歷全部子物件 使用 layersgroup 只對需要可點擊的物件建立 Raycaster
鍵盤事件在不同平台的 code 差異 某些鍵在不同瀏覽器或語系會產生不同 keyCode 建議以 event.code(不受鍵盤佈局影響)作為判斷依據
PointerLock 被瀏覽器阻擋 沒有使用者互動就呼叫 lock() 會失敗 必須在 clicktouchstart 等事件回呼內呼叫 controls.lock()

最佳實踐小結

  1. 統一座標正規化:封裝成 getNormalizedMouse(event) 函式,所有事件共用。
  2. 節流(throttle)或防抖(debounce):對 mousemovewheel 使用 lodash.throttle 或自行實作,減少不必要的 Raycaster 計算。
  3. 分層(Layers):將可互動物件放在特定 layer,Raycaster 只檢測該 layer,提升效能。
  4. 使用 userData:將自訂狀態(如 isSelectedoriginalColor)存於 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:用於精確的物件偵測,配合 layersgroup 提升效能。
  3. 事件管理:使用 addEventListenerremoveEventListener、節流與 userData,保持程式碼乾淨且可維護。

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