Three.js – Camera 與 Controls
主題:PointerLockControls(第一人稱)
簡介
在 3D 網頁互動中,第一人稱視角是最能帶給使用者沉浸感的體驗之一。無論是第一人稱射擊遊戲、虛擬實境導覽,或是需要自由探索的 3D 場景,都離不開 PointerLockControls 這個控制器。它結合了 Pointer Lock API(滑鼠指標鎖定)與 Three.js 的相機移動,讓使用者可以像在真實世界中一樣,用滑鼠控制視角、用鍵盤控制移動。
本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,完整帶你掌握 PointerLockControls 的使用方式,讓你的 Three.js 專案快速升級為 第一人稱體驗。
核心概念
1. Pointer Lock API 與 Three.js 的結合
- Pointer Lock API:當滑鼠指標被「鎖定」在畫面上時,瀏覽器會不再顯示游標,而是持續回傳相對於上一次的位置變化(
movementX、movementY)。 - Three.js 的 PointerLockControls:此類別封裝了 API,將滑鼠的相對位移套用到相機的旋轉,同時提供簡易的鍵盤移動邏輯(WASD、空白鍵跳躍等)。
⚠️ 注意:Pointer Lock 必須在使用者互動(點擊或觸碰)之後才能請求,否則會被瀏覽器阻擋。
2. 基本使用流程
- 建立相機與場景
- 載入
PointerLockControls - 在使用者點擊畫面時呼叫
requestPointerLock() - 在
pointerlockchange事件中啟用或停用控制器 - 在動畫迴圈中更新相機位置與碰撞檢測
3. 重要屬性與方法
| 屬性 / 方法 | 說明 |
|---|---|
controls.enabled |
控制器是否啟用,true 時會接受滑鼠與鍵盤輸入。 |
controls.getObject() |
取得包含相機的 Object3D,可直接加入場景或做碰撞偵測。 |
controls.moveForward( distance )、controls.moveRight( distance ) |
依照相機方向前進或側移。 |
controls.lock()、controls.unlock() |
手動鎖定或解除鎖定(通常由使用者點擊觸發)。 |
controls.isLocked |
是否已鎖定滑鼠指標。 |
程式碼範例
以下提供 五個 常見且實用的範例,從最簡單的「顯示相機」到完整的「第一人稱射擊」基礎框架,皆附上詳細註解。
範例 1️⃣ 基本設定與鎖定
import * as THREE from 'three';
import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js';
// 建立 renderer、scene、camera
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202020);
const camera = new THREE.PerspectiveCamera(
75, // 視野角
window.innerWidth / window.innerHeight,
0.1, 1000 // 近遠裁剪面
);
camera.position.set(0, 1.6, 5); // 預設身高 1.6m
// 初始化 PointerLockControls
const controls = new PointerLockControls(camera, renderer.domElement);
scene.add(controls.getObject());
// 點擊畫面請求鎖定
document.addEventListener('click', () => {
controls.lock(); // 會觸發瀏覽器的 pointerlock
});
重點:
controls.getObject()包含相機本身與一個虛擬的「玩家身體」物件,之後的移動都會作用在這個物件上。
範例 2️⃣ 鎖定與解除的事件處理
// 當指標鎖定狀態改變時
controls.addEventListener('lock', () => {
console.log('Pointer locked');
// 可在此顯示 HUD、隱藏 UI
});
controls.addEventListener('unlock', () => {
console.log('Pointer unlocked');
// 可在此顯示選單、重新顯示滑鼠
});
技巧:在
unlock時,通常會暫停遊戲迴圈或顯示「暫停」畫面,避免使用者在未鎖定時仍能控制相機。
範例 3️⃣ 鍵盤移動(WASD)與跳躍
const move = { forward: false, backward: false, left: false, right: false };
let canJump = false;
let velocity = new THREE.Vector3();
let direction = new THREE.Vector3();
document.addEventListener('keydown', (event) => {
switch (event.code) {
case 'KeyW': move.forward = true; break;
case 'KeyS': move.backward = true; break;
case 'KeyA': move.left = true; break;
case 'KeyD': move.right = true; break;
case 'Space':
if (canJump) velocity.y += 5; // 跳躍力度
canJump = false;
break;
}
});
document.addEventListener('keyup', (event) => {
switch (event.code) {
case 'KeyW': move.forward = false; break;
case 'KeyS': move.backward = false; break;
case 'KeyA': move.left = false; break;
case 'KeyD': move.right = false; break;
}
});
// 在動畫迴圈中更新位置
function animate() {
requestAnimationFrame(animate);
if (controls.isLocked) {
const delta = clock.getDelta();
velocity.x -= velocity.x * 10.0 * delta;
velocity.z -= velocity.z * 10.0 * delta;
velocity.y -= 9.8 * 30.0 * delta; // 重力
direction.z = Number(move.forward) - Number(move.backward);
direction.x = Number(move.right) - Number(move.left);
direction.normalize(); // 防止斜向加速
if (move.forward || move.backward) velocity.z -= direction.z * 400.0 * delta;
if (move.left || move.right) velocity.x -= direction.x * 400.0 * delta;
controls.moveRight(-velocity.x * delta);
controls.moveForward(-velocity.z * delta);
controls.getObject().position.y += velocity.y * delta; // 垂直
// 簡易地面碰撞
if (controls.getObject().position.y < 1.6) {
velocity.y = 0;
controls.getObject().position.y = 1.6;
canJump = true;
}
}
renderer.render(scene, camera);
}
animate();
說明:此範例示範了 加速度、阻尼、重力 的基本概念,足以支撐大多數第一人稱移動需求。若需更精細的碰撞,請改用
cannon-es或ammo.js。
範例 4️⃣ 加入簡易地形與碰撞偵測
// 建立平面地面
const floorGeometry = new THREE.PlaneGeometry(200, 200);
const floorMaterial = new THREE.MeshPhongMaterial({ color: 0x808080 });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2; // 轉成水平面
floor.receiveShadow = true;
scene.add(floor);
// 使用 Raycaster 判斷是否站在地面上
const raycaster = new THREE.Raycaster();
const down = new THREE.Vector3(0, -1, 0);
function checkGround() {
raycaster.set(controls.getObject().position, down);
const intersects = raycaster.intersectObject(floor);
if (intersects.length > 0 && intersects[0].distance < 1.7) {
// 接觸到地面
canJump = true;
controls.getObject().position.y = intersects[0].point.y + 1.6;
velocity.y = Math.max(0, velocity.y);
} else {
canJump = false;
}
}
// 在 animate 中呼叫
function animate() {
// ... 前面的程式碼
checkGround();
renderer.render(scene, camera);
}
提示:
Raycaster的使用可以避免「穿牆」或「懸空」的情況,對於不平坦地形尤為重要。
範例 5️⃣ 整合射擊(射彈)功能
// 建立子彈幾何與材質
const bulletGeometry = new THREE.SphereGeometry(0.02, 8, 8);
const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 });
function shoot() {
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);
const cam = controls.getObject();
// 設定子彈起始位置與方向
bullet.position.copy(cam.position);
const direction = new THREE.Vector3();
cam.getWorldDirection(direction);
bullet.userData.velocity = direction.multiplyScalar(50); // 速度
scene.add(bullet);
bullets.push(bullet);
}
// 監聽滑鼠左鍵點擊
document.addEventListener('mousedown', (e) => {
if (e.button === 0 && controls.isLocked) shoot();
});
// 子彈更新(在 animate 中)
const bullets = [];
function updateBullets(delta) {
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.position.addScaledVector(b.userData.velocity, delta);
// 超出範圍自動移除
if (b.position.length() > 200) {
scene.remove(b);
bullets.splice(i, 1);
}
}
}
// 在主動畫迴圈中
function animate() {
const delta = clock.getDelta();
// ... 先前的移動與碰撞
updateBullets(delta);
renderer.render(scene, camera);
}
實務:射擊系統只要把 相機的前方向量 作為子彈的發射方向,就能達到「第一人稱射擊」的感覺。若要加入命中偵測,可使用
Raycaster或物理引擎。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 指標未鎖定仍接受輸入 | 有時使用者在未點擊畫面時仍能觸發鍵盤事件,導致角色漂移。 | 僅在 controls.isLocked 為 true 時處理鍵盤/滑鼠事件。 |
| 相機穿牆 | 直接改變 camera.position 會忽略碰撞。 |
使用 controls.getObject() 搭配 Raycaster 或 物理引擎(cannon-es)做碰撞檢測。 |
| 跳躍重複觸發 | 按住空白鍵會不斷給予向上速度。 | 在 jump 前檢查 canJump,且在落地時才重設。 |
| 不同瀏覽器的指標鎖定行為差異 | 某些行動裝置不支援 Pointer Lock。 | 為行動裝置提供 TouchControls(如 OrbitControls)作為備案。 |
| 性能瓶頸 | 每幀都做大量 Raycast 會降低 FPS。 | 只在需要檢測時(如每 0.1 秒)或使用 八叉樹 優化場景查詢。 |
最佳實踐
- 分離控制與渲染:將
PointerLockControls的更新與渲染分開,確保即使控制暫停(如 UI 顯示),渲染仍能持續。 - 使用
clock.getDelta():所有位移、重力、子彈等都應以時間差 (delta) 為基礎,避免不同裝置間速度不一致。 - 預載資源:在正式鎖定前先載入模型、音效,避免在遊戲中卡頓。
- 提供退出機制:如「Esc」鍵解除鎖定,並顯示選單,提升使用者體驗。
- 模組化:將控制、碰撞、射擊等功能拆成獨立類別或函式,方便維護與測試。
實際應用場景
| 場景 | 為何使用 PointerLockControls |
|---|---|
| 第一人稱射擊 (FPS) 遊戲 | 需要快速、精準的視角轉動與即時移動。 |
| 虛擬實境導覽 (WebVR) | 在桌面模式下提供類似 VR 的沉浸感,讓使用者自由探索。 |
| 建築或博物館的 3D 虛擬漫遊 | 讓觀眾以「身歷其境」的方式走進展廳或展品。 |
| 教育訓練模擬(如消防、醫療) | 操作者必須在第一人稱視角下完成任務,提升實務感受。 |
| 互動藝術裝置 | 觀眾透過滑鼠/鍵盤直接控制視角,體驗藝術家的創作意圖。 |
總結
PointerLockControls 為 Three.js 提供了 第一人稱視角 的完整解決方案,只要掌握 指標鎖定、相機更新與基本物理,就能快速構建沉浸式的 3D 互動體驗。本文從概念、實作、常見問題到最佳實踐,提供了 五個完整範例,希望讀者在閱讀後能:
- 在任意 Three.js 專案中即時加入第一人稱控制。
- 避免常見的穿牆、跳躍失效等陷阱。
- 依需求擴展射擊、碰撞或物理模擬,打造更完整的遊戲或模擬系統。
只要不斷練習、調整參數,並配合適當的 物理引擎或碰撞檢測,你的 Three.js 項目將能提供 媲美桌面遊戲 的第一人稱體驗。祝開發順利,玩得開心! 🎮🚀