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 物件。基本流程:
- 在
pointerdown時記錄滑鼠位置(防止拖曳時誤觸發)。 - 在
pointerup時再次取得滑鼠位置,若兩次座標差距在容忍範圍內則視為點擊。 - 使用
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)實作
拖曳最常見的需求是調整模型位置或在平面上繪製路徑。核心概念是:
- 在
pointerdown時使用 Raycaster 取得點擊的物件與交點 (intersection point)。 - 建立一個 平面 (Plane),其法向量與相機視線垂直,作為拖曳的參考平面。
- 在
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,若場景有大量小部件會影響效能。 |
使用 layers 或 group 把可互動物件分組,只對需要的陣列測試。 |
| 拖曳時相機視角改變 | 平面法向量若跟隨相機變化,拖曳感覺會「漂移」。 | 固定拖曳平面(如 XZ 平面)或在拖曳開始時就確定平面。 |
| 事件冒泡與 DOM 重疊 | 3D 互動與 UI 元件(如按鈕)同時存在,會互相干擾。 | 在 UI 元件上使用 event.stopPropagation(),或在 Three.js 事件前檢查 event.target。 |
| 材質共享導致全部變色 | 多個 Mesh 共享同一個 Material,改變 emissive 會同時影響全部。 |
為每個互動物件 clone 材質或使用 MeshStandardMaterial.clone()。 |
最佳實踐:
- 使用
requestAnimationFrame統一渲染與 Raycaster 更新,確保畫面與互動同步。 - 將可互動物件存於陣列(如
interactiveObjects),避免每次遍歷整個場景。 - 分層 (Layers) +
camera.layers.enable():在大型專案中,僅讓相機渲染需要交互的層,提升效能。 - 針對觸控設備:使用
pointer事件可同時支援 mouse、touch、pen,避免分別寫mousedown/touchstart。 - 記憶體釋放:在不再需要的時候,務必
dispose()Geometry、Material,防止 WebGL 記憶體泄漏。
實際應用場景
| 場景 | 互動需求 | 實作要點 |
|---|---|---|
| 產品展示平台 | 點擊切換顏色、Hover 顯示規格、拖曳旋轉模型 | 使用 Raycaster 判斷點擊、emissive 高亮、OrbitControls 結合自訂拖曳。 |
| 建築資訊模型 (BIM) | 以 Hover 顯示房間資訊、點擊進入房間、拖曳家具擺放 | 建立 Plane 限制拖曳在地板上、使用 layers 區分結構與家具。 |
| 資料視覺化(3D 散點圖) | Hover 顯示資料點詳細、點擊鎖定焦點、拖曳選取區域 | 利用 Raycaster 取得最近點、BoxHelper 畫選取框、OrbitControls 禁止旋轉時拖曳。 |
| 教育互動模擬 | 拖曳化學分子、點擊觸發反應、Hover 顯示原子屬性 | 為每個原子建立獨立 Mesh,使用 dragPlane 限制在特定平面,觸發事件時播放動畫。 |
| 遊戲 UI(選擇角色) | Hover 高亮角色、點擊選擇、拖曳調整站位 | 使用 Sprite 作為 UI 標籤,結合 Raycaster 與 OrbitControls,保持相機平滑。 |
總結
在 Three.js 中,拖曳、點擊、Hover 這三種互動是打造沉浸式 3D 網頁體驗的基礎。透過 Raycaster 與 Pointer Events 的結合,我們可以精準地從螢幕座標映射到 3D 世界,進而:
- 點擊 用於選取與觸發功能;
- Hover 提供即時視覺回饋與資訊提示;
- 拖曳 讓使用者直接操控模型位置或形狀。
本文從概念說明、完整範例、常見陷阱到實務應用,提供了一套 從零到可用 的操作流程。掌握這些技巧後,你可以在產品展示、建築 BIM、資料視覺化、教育模擬甚至小型遊戲中,快速加入直覺且效能良好的互動功能,提升使用者體驗與專案價值。
祝你在 Three.js 的世界裡玩得開心,創造出令人驚豔的 3D 互動作品! 🚀