31 · 第三人称相机跟随与环绕
需求与空间约定
第三人称相机要跟随角色,并允许鼠标调整水平环绕角和俯仰角。本例把环绕偏移定义在角色局部坐标中,再用角色世界四元数转换为世界方向,最后加到世界跟随中心。相机直接挂到 scene,不继承角色抖动。
Three.js 球坐标使用半径 r、从 +Y 向下量的极角 φ、绕 Y 的方位角 θ:
φ 接近 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,它更适合围绕固定目标观察。
练习(暂不提供答案)
- 增加滚轮缩放并限制最近、最远距离。
- 将相机改为始终世界北向,忽略角色旋转。
- 平滑球坐标参数,比较与位置平滑的轨迹差异。
- 添加一面墙,实现防止相机穿墙的距离缩短。