31 · 第三人称相机跟随与环绕

返回系列目录

需求与空间约定

第三人称相机要跟随角色,并允许鼠标调整水平环绕角和俯仰角。本例把环绕偏移定义在角色局部坐标中,再用角色世界四元数转换为世界方向,最后加到世界跟随中心。相机直接挂到 scene,不继承角色抖动。

Three.js 球坐标使用半径 r、从 +Y 向下量的极角 φ、绕 Y 的方位角 θ:

x=rsin⁡φsin⁡θ,y=rcos⁡φ,z=rsin⁡φcos⁡θ. x=r\sin\varphi\sin\theta,\quad y=r\cos\varphi,\quad z=r\sin\varphi\cos\theta.

φ 接近 0 或 π 时,水平朝向退化,故限制在小于完整范围的区间。角色朝 +Z,初始 θ = π 让相机位于角色后方。

完整核心代码

import { createDemo } from './runtime.js';

const { THREE, scene, camera, renderer, onFrame, onDispose } = createDemo();
const actor = new THREE.Mesh(new THREE.BoxGeometry(0.6, 1, 1), new THREE.MeshNormalMaterial());
scene.add(actor, new THREE.GridHelper(30, 30));
const center = new THREE.Vector3();
const smoothedCenter = new THREE.Vector3(0, 1, 0);
const desiredCamera = new THREE.Vector3();
const offset = new THREE.Vector3();
const worldRotation = new THREE.Quaternion();
let theta = Math.PI;
let phi = Math.PI / 3;
let pointerId = null;
let lastX = 0;
let lastY = 0;
const canvas = renderer.domElement;
canvas.style.touchAction = 'none';

function down(event) {
  if (pointerId !== null || event.button !== 0) return;
  pointerId = event.pointerId;
  lastX = event.clientX;
  lastY = event.clientY;
  canvas.setPointerCapture(pointerId);
}
function move(event) {
  if (event.pointerId !== pointerId) return;
  theta -= (event.clientX - lastX) * 0.005;
  phi = THREE.MathUtils.clamp(phi + (event.clientY - lastY) * 0.005, 0.15, Math.PI / 2 - 0.05);
  lastX = event.clientX;
  lastY = event.clientY;
}
function end(event) {
  if (event.pointerId !== pointerId) return;
  const id = pointerId;
  pointerId = null;
  if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
}
canvas.addEventListener('pointerdown', down);
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointercancel', end);
canvas.addEventListener('lostpointercapture', end);
onDispose(() => {
  canvas.removeEventListener('pointerdown', down);
  canvas.removeEventListener('pointermove', move);
  canvas.removeEventListener('pointerup', end);
  canvas.removeEventListener('pointercancel', end);
  canvas.removeEventListener('lostpointercapture', end);
});

onFrame((dt, elapsed) => {
  actor.position.set(Math.sin(elapsed * 0.3) * 3, 0.5, elapsed * 0.15);
  actor.rotation.y = Math.cos(elapsed * 0.3) * 0.4;
  actor.getWorldPosition(center);
  center.y += 0.8;
  actor.getWorldQuaternion(worldRotation);
  offset.setFromSphericalCoords(5, phi, theta).applyQuaternion(worldRotation);
  desiredCamera.copy(center).add(offset);
  const alpha = 1 - Math.exp(-6 * dt);
  camera.position.lerp(desiredCamera, alpha);
  smoothedCenter.lerp(center, alpha);
  camera.lookAt(smoothedCenter);
});

验证与取舍

角色移动和转向时观察相机是否平滑;不同刷新率下滞后程度应近似一致。平滑世界相机位置会让环绕轨迹暂时穿过圆弧内侧,所以它不保证相机与角色严格保持 5 单位距离。若需要恒定距离,可平滑中心和球坐标参数,再重建位置。

本例角色保持竖直。角色翻滚时直接应用完整世界四元数会让相机偏移跟着倾斜;地面游戏可只提取水平朝向。复杂父级非均匀缩放可能带来剪切,应将运动和视觉缩放分离。

墙体遮挡需增加从跟随中心到期望相机位置的碰撞查询,命中时缩短距离并留安全间隔。实际相机有体积,单射线仍可能擦入墙角,可使用球体扫掠。普通模型查看器优先使用 OrbitControls,它更适合围绕固定目标观察。

练习(暂不提供答案)

  1. 增加滚轮缩放并限制最近、最远距离。
  2. 将相机改为始终世界北向,忽略角色旋转。
  3. 平滑球坐标参数,比较与位置平滑的轨迹差异。
  4. 添加一面墙,实现防止相机穿墙的距离缩短。