本文 AI 產出,尚未審核

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:當滑鼠指標被「鎖定」在畫面上時,瀏覽器會不再顯示游標,而是持續回傳相對於上一次的位置變化(movementXmovementY)。
  • Three.js 的 PointerLockControls:此類別封裝了 API,將滑鼠的相對位移套用到相機的旋轉,同時提供簡易的鍵盤移動邏輯(WASD、空白鍵跳躍等)。

⚠️ 注意:Pointer Lock 必須在使用者互動(點擊或觸碰)之後才能請求,否則會被瀏覽器阻擋。

2. 基本使用流程

  1. 建立相機與場景
  2. 載入 PointerLockControls
  3. 在使用者點擊畫面時呼叫 requestPointerLock()
  4. pointerlockchange 事件中啟用或停用控制器
  5. 在動畫迴圈中更新相機位置與碰撞檢測

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-esammo.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.isLockedtrue 時處理鍵盤/滑鼠事件。
相機穿牆 直接改變 camera.position 會忽略碰撞。 使用 controls.getObject() 搭配 Raycaster物理引擎(cannon-es)做碰撞檢測。
跳躍重複觸發 按住空白鍵會不斷給予向上速度。 jump 前檢查 canJump,且在落地時才重設。
不同瀏覽器的指標鎖定行為差異 某些行動裝置不支援 Pointer Lock。 為行動裝置提供 TouchControls(如 OrbitControls)作為備案。
性能瓶頸 每幀都做大量 Raycast 會降低 FPS。 只在需要檢測時(如每 0.1 秒)或使用 八叉樹 優化場景查詢。

最佳實踐

  1. 分離控制與渲染:將 PointerLockControls 的更新與渲染分開,確保即使控制暫停(如 UI 顯示),渲染仍能持續。
  2. 使用 clock.getDelta():所有位移、重力、子彈等都應以時間差 (delta) 為基礎,避免不同裝置間速度不一致。
  3. 預載資源:在正式鎖定前先載入模型、音效,避免在遊戲中卡頓。
  4. 提供退出機制:如「Esc」鍵解除鎖定,並顯示選單,提升使用者體驗。
  5. 模組化:將控制、碰撞、射擊等功能拆成獨立類別或函式,方便維護與測試。

實際應用場景

場景 為何使用 PointerLockControls
第一人稱射擊 (FPS) 遊戲 需要快速、精準的視角轉動與即時移動。
虛擬實境導覽 (WebVR) 在桌面模式下提供類似 VR 的沉浸感,讓使用者自由探索。
建築或博物館的 3D 虛擬漫遊 讓觀眾以「身歷其境」的方式走進展廳或展品。
教育訓練模擬(如消防、醫療) 操作者必須在第一人稱視角下完成任務,提升實務感受。
互動藝術裝置 觀眾透過滑鼠/鍵盤直接控制視角,體驗藝術家的創作意圖。

總結

PointerLockControls 為 Three.js 提供了 第一人稱視角 的完整解決方案,只要掌握 指標鎖定、相機更新與基本物理,就能快速構建沉浸式的 3D 互動體驗。本文從概念、實作、常見問題到最佳實踐,提供了 五個完整範例,希望讀者在閱讀後能:

  1. 在任意 Three.js 專案中即時加入第一人稱控制
  2. 避免常見的穿牆、跳躍失效等陷阱
  3. 依需求擴展射擊、碰撞或物理模擬,打造更完整的遊戲或模擬系統。

只要不斷練習、調整參數,並配合適當的 物理引擎或碰撞檢測,你的 Three.js 項目將能提供 媲美桌面遊戲 的第一人稱體驗。祝開發順利,玩得開心! 🎮🚀