Three.js 與 WebXR:控制器互動與姿態追蹤
簡介
在沉浸式的 VR/AR 體驗中,控制器(Controller) 是使用者與虛擬世界最直接的橋樑。透過控制器的按鈕、觸控板與姿態(Pose)資訊,我們可以讓使用者「抓取」物件、指向互動介面,甚至模擬手部的自然動作。
WebXR 作為瀏覽器原生的 XR API,已經把這些硬體能力抽象化成統一的介面。配合 Three.js 的渲染管線,我們可以在幾行程式碼內完成控制器的偵測、姿態追蹤與互動邏輯,快速原型出完整的 VR/AR 應用。
本篇文章將從 WebXR 基礎、控制器取得與事件、姿態解析 三個核心概念切入,搭配 4 個實作範例,說明如何在 Three.js 中建立可靠且易維護的控制器互動系統,並提供常見陷阱與最佳實踐,讓你能在實務專案中得心應手。
核心概念
1. 啟用 Three.js 的 XR 支援
在使用 WebXR 前,必須先把 Three.js 的 WebGLRenderer 設為 XR 模式,並請求相容的 XR Session(immersive-vr 或 immersive-ar)。
import * as THREE from 'three';
// 建立基本場景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true; // ★ 開啟 XR 功能
document.body.appendChild(renderer.domElement);
// 加入簡單的地板
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10),
new THREE.MeshStandardMaterial({ color: 0x808080 })
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
// 請求 XR Session
const btn = document.createElement('button');
btn.textContent = 'Enter VR';
btn.style.position = 'absolute';
btn.style.top = '20px';
btn.style.left = '20px';
document.body.appendChild(btn);
btn.addEventListener('click', async () => {
const session = await navigator.xr.requestSession('immersive-vr');
renderer.xr.setSession(session);
});
重點:
renderer.xr.enabled = true必須在建立渲染迴圈之前設定,否則後續的 XR 相關 API 會失效。
2. 取得與管理控制器
WebXR 把每支實體控制器映射為一個 XRInputSource,Three.js 透過 renderer.xr.getController( index ) 直接取得對應的 Object3D,並自動同步其 位置與姿態。
// 取得第一支(左手)控制器
const controller = renderer.xr.getController(0);
scene.add(controller);
// 為控制器加上可視化的光線(Ray)
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -1) // 向前 1 公尺
]);
const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xff0000 }));
line.scale.z = 5; // 拉長光線長度
controller.add(line);
技巧:將光線(Ray)作為子物件加到
controller,可以確保光線的座標系永遠跟隨控制器的姿態變化。
3. 監聽控制器事件
常見的事件包括 selectstart(按下觸發)、selectend(釋放)以及 squeezestart/squeezeend(握把)。這些事件的觸發時機與硬體按鍵對應,適合用來實作 抓取、點擊 或 切換模式。
// 設定事件監聽
controller.addEventListener('selectstart', onSelectStart);
controller.addEventListener('selectend', onSelectEnd);
function onSelectStart(event) {
// 以光線做射線測試,找出最近的交叉物件
const intersect = getIntersections(controller);
if (intersect) {
intersect.object.material.emissive.set(0x333333); // 簡單視覺回饋
controller.userData.selected = intersect.object; // 記錄被抓取的物件
}
}
function onSelectEnd(event) {
const selected = controller.userData.selected;
if (selected) {
selected.material.emissive.set(0x000000);
controller.userData.selected = null;
}
}
// 射線測試輔助函式
function getIntersections(controller) {
const tempMatrix = new THREE.Matrix4();
tempMatrix.identity().extractRotation(controller.matrixWorld);
const raycaster = new THREE.Raycaster();
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
const intersects = raycaster.intersectObjects(interactableObjects, true);
return intersects[0] || null;
}
說明:
controller.matrixWorld包含了位置、旋轉與縮放資訊,直接取出即為控制器在世界座標系的姿態。使用Raycaster進行射線測試是最常見的「指向」互動方式。
4. 姿態(Pose)資訊的深入使用
除了 Object3D 自動同步的位置與旋轉外,WebXR 亦提供 姿態空間(Reference Space) 以及 關節(Joint) 資訊(例如手部追蹤)。當硬體支援 Hand Tracking 時,我們可以取得每根手指的關節座標,進一步渲染逼真的手部模型。
// 取得手部(Hand)物件
const hand = renderer.xr.getHand(0); // 0 = 左手,1 = 右手
scene.add(hand);
// 載入手部模型(簡化版)
const loader = new THREE.GLTFLoader();
loader.load('hand.glb', gltf => {
const handModel = gltf.scene;
hand.add(handModel);
});
// 每幀更新關節姿態
renderer.setAnimationLoop(() => {
// 手部有 25 個關節(XRHand.JOINT_COUNT)
hand.joints.forEach((joint, i) => {
if (joint) {
// joint.position / joint.quaternion 已自動同步
// 可自行於此處加入自訂的姿態處理
}
});
renderer.render(scene, camera);
});
關鍵:
renderer.xr.getHand()只在支援 Hand Tracking 的裝置上返回有效物件;若不支援,會回傳null,因此建議在使用前先檢查navigator.xr.isSessionSupported('immersive-vr')並判斷session.inputSources中的hand屬性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 控制器座標不更新 | 有時 controller.matrixWorld 仍是原點,通常是因為未呼叫 renderer.setAnimationLoop 或忘記在渲染迴圈內更新場景。 |
確保渲染迴圈使用 renderer.setAnimationLoop,而不是自行的 requestAnimationFrame。 |
| 射線方向錯誤 | Raycaster 的方向若直接使用 controller.getWorldDirection(),在某些控制器(如 Oculus Touch)會出現反向。 |
建議使用 new THREE.Vector3(0, 0, -1).applyQuaternion(controller.quaternion),或如範例使用 tempMatrix.extractRotation(controller.matrixWorld)。 |
| 手部模型與關節不同步 | 手部模型的骨骼與 XRHand 關節不對應,導致拖曳時模型變形。 | 使用 SkeletonHelper 或自行將每個關節的 position / quaternion 直接套用到模型對應的骨骼。 |
| 資源釋放不完整 | 切換 XR Session 後舊的事件監聽器仍在,會造成記憶體洩漏。 | 在 session.end 時移除所有 select、squeeze 監聽,並呼叫 renderer.xr.setSession(null)。 |
| 不同參考空間的混用 | 有時同時使用 viewer、local、bounded-floor 參考空間,座標會產生偏移。 |
統一 使用 local(或 local-floor)作為全局參考空間,除非有特別需求才切換。 |
最佳實踐小結
- 一次性初始化:
renderer.xr.enabled、renderer.xr.setReferenceSpaceType('local-floor')應在程式最開始設定。 - 事件集中管理:將所有控制器事件封裝在單一類別(例如
XRControllerManager)內,方便在 Session 結束時一次解除。 - 使用
setAnimationLoop:此方法會自動在 XR Session 中使用正確的時間戳記(XRFrame),確保姿態同步。 - 視覺回饋:在
selectstart時改變被選物件的材質或顏色,讓使用者感受到「觸碰」的即時回饋。 - 適度抽象:若要支援多種控制器(Oculus、Valve Index、HTC Vive),盡量使用 WebXR 標準事件與屬性,避免硬編碼特定按鍵編號。
實際應用場景
| 場景 | 需求 | 實作要點 |
|---|---|---|
| VR 交互式展覽 | 觀眾使用控制器點選展品資訊、旋轉模型。 | 利用 select 事件觸發 UI 面板,光線射線做模型選取,使用 controller.userData 暫存當前焦點。 |
| AR 手部繪圖 | 使用者在真實環境中以手勢畫線。 | 取得 hand.joints[XRHand.THUMB_TIP] 與 hand.joints[XRHand.INDEX_TIP] 的座標,根據距離判斷是否「寫字」;使用 THREE.Line 動態更新線段。 |
| VR 物理抓取 | 需要把物件從地面抓起、拋擲。 | 在 selectstart 時把物件的 parent 設為 controller,在 selectend 時恢復至原本的場景,並根據控制器的線速度加上 velocity 產生拋擲效果。 |
| 多人協作 | 多位使用者共享同一虛擬空間,彼此看到對方的手部與控制器。 | 透過 WebSocket 或 WebRTC 把每個使用者的控制器姿態(position、quaternion)廣播,其他端口使用 THREE.Object3D 重新渲染對方的手部模型。 |
總結
- Three.js + WebXR 為開發沉浸式體驗提供了完整且一致的抽象層,讓控制器與手部追蹤的實作變得相對簡單。
- 透過 renderer.xr.enabled、renderer.setAnimationLoop,以及
renderer.xr.getController()/renderer.xr.getHand(),我們可以即時取得硬體姿態,並在 Three.js 場景中以 Raycaster、事件監聽、關節同步 等方式完成互動邏輯。 - 面對不同硬體與平台時,統一參考空間、集中管理事件、提供即時視覺回饋 是避免常見陷阱的關鍵。
- 最後,將這些基礎技術應用於 展覽、繪圖、抓取、多人協作 等真實案例,能讓開發者快速驗證概念、迭代功能,進而打造出富有沉浸感且可擴充的 XR 產品。
掌握了控制器的互動與姿態追蹤,你就能在 WebXR 世界裡自由創造,讓使用者的手指在瀏覽器中觸碰到未來。祝開發順利!