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(); // 用來存放正規化後的滑鼠座標
在每次滑鼠移動或點擊時,我們會:
- 更新
mouse座標:mouse.x = (event.clientX / width) * 2 - 1; - 設定射線:
raycaster.setFromCamera(mouse, camera); - 取得交叉物件:
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() |
最佳實踐小結
- 統一座標正規化:封裝成
getNormalizedMouse(event)函式,所有事件共用。 - 節流(throttle)或防抖(debounce):對
mousemove、wheel使用lodash.throttle或自行實作,減少不必要的 Raycaster 計算。 - 分層(Layers):將可互動物件放在特定 layer,Raycaster 只檢測該 layer,提升效能。
- 使用
userData:將自訂狀態(如isSelected、originalColor)存於object.userData,保持物件本身的乾淨。 - 釋放資源:在切換場景或銷毀物件時,必須
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 的完整流程,同時列出常見的陷阱與最佳實踐,幫助開發者在開發過程中避免效能與相容性問題。只要掌握以下三個關鍵:
- 座標正規化:所有滑鼠相關事件必須先轉換為 NDC。
- Raycaster:用於精確的物件偵測,配合
layers或group提升效能。 - 事件管理:使用
addEventListener、removeEventListener、節流與userData,保持程式碼乾淨且可維護。
結合本文的概念與範例,你即可在 Three.js 中快速構建出 流暢、直覺且功能完整 的 3D 互動體驗。祝你開發順利,玩得開心!