Three.js FPS 镜头控制反了?一文解决反转与侧翻
2025-04-29 20:02:13
好的,这是你要的博客文章内容:
修正 Three.js 第一人称游戏中镜头方向导致的控制反转与侧翻
搞 Three.js 开发,特别是做第一人称视角(FPS)效果时,镜头控制是个绕不开的话题。你可能遇到过这么个怪事:角色朝一个方向看时,鼠标控制镜头上下左右转动,一切正常;但当角色转个 180 度或者看向侧面时,鼠标控制就好像失灵了一样,上下控制变成左右,左右控制可能变成上下,甚至镜头直接侧翻(Roll)。代码在某个编辑器里跑得好好的,换个地方或者在某些特定角度下就出问题。这确实让人头疼。
咱直接来看你遇到的具体问题代码。从代码结构看,你创建了四个分别朝向北、南、东、西的固定相机 (cameras.north
, cameras.south
, cameras.east
, cameras.west
),并通过按键 1-4 来切换当前的 currentCamera
。问题就出在这个设计和 onMouseMove
函数里的旋转逻辑上。
为啥会这样? (问题分析)
简单说,问题根源在于 鼠标输入的处理方式 与 多个预设旋转相机 之间的冲突。
看下 onMouseMove
函数:
function onMouseMove(event) {
if (document.pointerLockElement) {
const sensitivity = 0.002;
// 问题区域: 全局 yaw 和 pitch
yaw -= event.movementX * sensitivity; // 鼠标左右移动 -> 总是改变 yaw
pitch -= event.movementY * sensitivity; // 鼠标上下移动 -> 总是改变 pitch
pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, pitch)); // 限制俯仰角
// 直接应用到当前相机
currentCamera.rotation.y = yaw; // 设置 Y 轴旋转 (偏航)
currentCamera.rotation.x = pitch; // 设置 X 轴旋转 (俯仰)
currentCamera.rotation.z = 0; // 强制 Z 轴旋转 (侧滚) 为 0
}
}
这段代码做了几件事:
- 它使用两个全局变量
yaw
和pitch
来累积鼠标的移动量。movementX
(左右移动) 总是减少yaw
,movementY
(上下移动) 总是减少pitch
。 - 它把计算出的
yaw
和pitch
直接 赋值给currentCamera.rotation.y
和currentCamera.rotation.x
。
现在,想象一下场景:
- 初始状态 (currentCamera = cameras.north): 北向相机初始
rotation
是(0, 0, 0)
。这时,yaw
控制左右看(Y 轴旋转),pitch
控制上下看(X 轴旋转),一切正常。 - 切换到南向相机 (currentCamera = cameras.south): 南向相机初始
rotation
是(0, Math.PI, 0)
,它已经转了 180 度。现在鼠标向右移动,yaw
减少。当你把这个减少后的yaw
值 直接 应用到cameras.south.rotation.y
时,因为这个相机本身就是反向的,所以视觉上它会向左转!同理,上下移动也会出现反向效果。 - 切换到东向或西向相机 (currentCamera = cameras.east/west): 这俩相机初始分别绕 Y 轴旋转了
Math.PI / 2
和-Math.PI / 2
。当你尝试用pitch
(原本控制上下看的) 去改变currentCamera.rotation.x
时,对于一个已经侧转 90 度的相机,改变它的局部 X 轴旋转,看起来就像是在做“侧滚”(Roll)动作。
核心矛盾: 你试图用一套基于世界坐标系(或者说,基于初始北向相机)的 yaw
和 pitch
逻辑,去控制一堆具有不同初始旋转状态的相机。这就像你蒙着眼睛开车,只知道方向盘向左打是左转,向右打是右转,但如果有人偷偷把你的车原地调个头,你再向左打方向盘,车实际上是向右走的。
你的移动计算(animate
函数里的 getWorldDirection
)倒是用了 currentCamera
的当前方向,这没问题。问题出在旋转控制上。
咋解决? (How to Fix It?)
最地道、最符合 FPS 游戏开发习惯的做法是:放弃多相机切换的思路,只用一个相机,并且让这个相机根据鼠标输入进行相对旋转。
单一相机才是王道 (Single Camera is the Way to Go)
FPS 游戏里,玩家的视角就是那个唯一的相机。玩家移动,相机跟着移动;玩家转头,相机跟着旋转。不需要为东南西北各准备一个相机。
原理 (The Gist):
不再维护全局的 yaw
和 pitch
状态。每次鼠标移动时,计算出移动了多少 (movementX
, movementY
),然后让 当前 相机在它 自己 的坐标系下,或者基于世界坐标系进行 相对 旋转。关键在于“相对”二字,而不是设置绝对的旋转值。
改动步骤 (Steps to Modify):
-
移除多余相机:
- 删除
cameras
对象及其north
,south
,east
,west
属性。 - 删除
init
函数中对这些相机的初始化代码。 - 删除
onKeyDown
中处理Digit1
到Digit4
的相机切换逻辑。
- 删除
-
创建单一相机:
- 在全局声明一个相机变量,比如
let camera;
- 在
init
函数中,创建这个相机实例:camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 1.6, 5); // 初始位置,可以自己定 scene.add(camera); // 别忘了加到场景里 (或者作为某个对象的子对象)
- 在全局声明一个相机变量,比如
-
重写
onMouseMove
旋转逻辑: 这是核心改动。我们需要用 Euler angles (欧拉角) 来处理旋转,并且注意旋转顺序以避免万向锁(Gimbal Lock)问题。对于 FPS 控制,'YXZ'
是一个常用的旋转顺序,意味着先绕 Y 轴旋转(左右看),再绕自身 X 轴旋转(上下看),最后绕 Z 轴(侧滚,一般 FPS 里不用)。// 可能需要将 camera 也传入 onMouseMove,或者让 camera 成为全局可访问 // (在你的原始代码结构中,camera 可以是全局变量) // 移除全局的 yaw 和 pitch 变量声明 function onMouseMove(event) { if (document.pointerLockElement === document.body && !isPaused) { // 确保指针锁定且游戏未暂停 const sensitivity = 0.002; const movementX = event.movementX || 0; const movementY = event.movementY || 0; // 重要:设置旋转顺序为 YXZ // 这样能保证上下看(Pitch) 不会影响左右看(Yaw)的方向 camera.rotation.order = 'YXZ'; // 1. 计算 Y 轴旋转 (左右看 - Yaw) // 直接绕世界 Y 轴旋转相机。负号是因为 movementX 正值通常表示鼠标向右移动,我们希望相机向右看。 camera.rotation.y -= movementX * sensitivity; // 2. 计算 X 轴旋转 (上下看 - Pitch) // 在相机的本地 X 轴上旋转。同样需要累加,并限制范围。 camera.rotation.x -= movementY * sensitivity; // 3. 限制 Pitch 角度 (防止看向天空或地面时翻转) // 将 camera.rotation.x 限制在比如 -PI/2 到 PI/2 之间 camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x)); // Z 轴旋转 (Roll) 保持为 0,除非你有特殊需求 // camera.rotation.z = 0; (通常不需要,因为'YXZ'顺序下它不会被X、Y影响) } }
- 关键点:
- 使用
camera.rotation.order = 'YXZ';
- 直接修改
camera.rotation.y
和camera.rotation.x
来累积旋转量。 - 限制
camera.rotation.x
的范围防止过度旋转。
- 使用
- 关键点:
-
更新
animate
函数:- 所有之前用到
currentCamera
的地方,现在都直接用camera
。例如:// 获取移动方向 const frontDirection = new THREE.Vector3(); camera.getWorldDirection(frontDirection); // 用 camera 替换 currentCamera frontDirection.y = 0; frontDirection.normalize(); const rightDirection = new THREE.Vector3(); rightDirection.crossVectors(camera.up, frontDirection).normalize(); // 用 camera 替换 currentCamera // ... 其他计算 ... // 更新相机位置 camera.position.addScaledVector(velocity, delta); // 用 camera 替换 currentCamera // 边界检查 camera.position.x = Math.max(-24, Math.min(24, camera.position.x)); camera.position.z = Math.max(-24, Math.min(24, camera.position.z)); if (camera.position.y < 1.6) { velocity.y = 0; camera.position.y = 1.6; canJump = true; } // 更新持有物体的位置 if (heldCube) { // 需要获取相机世界方向 const direction = camera.getWorldDirection(new THREE.Vector3()); heldCube.position.copy(camera.position).add(direction.multiplyScalar(1.5)); // 用 camera 替换 currentCamera }
- 所有之前用到
-
更新渲染和窗口大小调整:
renderer.render(scene, camera);
// 使用单相机渲染onWindowResize
函数中更新camera.aspect
和camera.updateProjectionMatrix();
代码时间 (Code Snippets):
把上面步骤整合一下,关键部分的代码看起来会像这样:
// 全局变量区域
let scene, renderer, camera; // <<< 改动:单一相机
let moveForward = false, moveBackward = false, moveLeft = false, moveRight = false;
let velocity = new THREE.Vector3();
let direction = new THREE.Vector3(); // 这个 direction 变量似乎在 animate 里被重新计算了,确保逻辑正确
// ... 其他变量 ...
let isPaused = false;
// init 函数片段
function init() {
scene = new THREE.Scene();
// <<< 改动:创建单一相机 >>>
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 1.6, 0); // 设置一个初始位置,比如玩家高度
camera.rotation.order = 'YXZ'; // <<< 重要:设置旋转顺序 >>>
// 不再需要 scene.add(camera),如果相机代表玩家本身
// 如果相机是某个玩家对象的子对象,则 add 到玩家对象上
renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// ... (地面、立方体等其他初始化) ...
// <<< 改动:移除 1-4 按键监听器 >>>
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
// ... 其他事件监听 ...
document.body.addEventListener('click', () => {
document.body.requestPointerLock();
});
document.addEventListener('mousemove', onMouseMove); // <<< 这个函数需要重写 >>>
window.addEventListener('resize', onWindowResize); // <<< onWindowResize 也需要更新 >>>
}
// onMouseMove 函数 (如上文所示重写)
function onMouseMove(event) {
if (document.pointerLockElement === document.body && !isPaused) {
const sensitivity = 0.002;
const movementX = event.movementX || 0;
const movementY = event.movementY || 0;
// 使用 'YXZ' 欧拉顺序
camera.rotation.order = 'YXZ';
// 更新 Yaw (绕世界 Y 轴)
camera.rotation.y -= movementX * sensitivity;
// 更新 Pitch (绕本地 X 轴)
camera.rotation.x -= movementY * sensitivity;
// 限制 Pitch 角度
camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x));
}
}
// animate 函数片段
function animate() {
if (!isPaused) {
requestAnimationFrame(animate);
const time = performance.now();
const delta = (time - prevTime) / 1000;
// ... (日夜循环等) ...
// --- 移动计算部分 (使用 camera 替换 currentCamera) ---
direction.z = Number(moveForward) - Number(moveBackward);
direction.x = Number(moveRight) - Number(moveLeft);
// 注意:这里的 direction 是基于按键输入的意图,不是相机方向
// 它应该在相机的局部坐标系下转换成世界移动向量
velocity.x -= velocity.x * 10.0 * delta; // 阻尼
velocity.z -= velocity.z * 10.0 * delta; // 阻尼
velocity.y -= gravity * delta; // 重力
if (moveForward || moveBackward || moveLeft || moveRight) {
// 创建一个临时向量来计算基于相机方向的移动
const moveDirection = new THREE.Vector3(direction.x, 0, direction.z);
moveDirection.normalize();
// 将移动方向从相机局部坐标系转换到世界坐标系
// camera.localToWorld(moveDirection); // 这方法不是直接这样用的
// 更常用的方法是:获取相机的世界方向,然后叉乘得到右方向
const worldDirection = new THREE.Vector3();
camera.getWorldDirection(worldDirection);
worldDirection.y = 0; // 我们只关心水平移动
worldDirection.normalize();
const rightDirection = new THREE.Vector3();
rightDirection.crossVectors(camera.up, worldDirection).normalize(); // 右方向
// 计算最终速度变化量
const moveX = rightDirection.multiplyScalar(direction.x * speed * delta);
const moveZ = worldDirection.multiplyScalar(-direction.z * speed * delta); // 注意 direction.z 的符号,W前进通常是负 Z
velocity.add(moveX).add(moveZ); // 将按键输入转换的速度增量加到总速度上
}
// 应用速度更新相机位置
camera.position.addScaledVector(velocity, delta);
// ... (边界检查, 跳跃, 拾取物体 - 记得用 camera) ...
// 渲染
renderer.render(scene, camera); // <<< 改动:使用 camera >>>
prevTime = time;
// 更新小地图 (也要用 camera.position)
updateMiniMap(); // <<< updateMiniMap内部也需使用 camera.position >>>
} else {
// 如果暂停了,可能还需要保留对 requestAnimationFrame 的调用,或者用其他方式恢复
requestAnimationFrame(animate); // 或者根据暂停逻辑决定是否调用
}
}
// onWindowResize 函数
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight; // <<< 改动:使用 camera >>>
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// updateMiniMap 函数 (示意)
function updateMiniMap() {
// ... 获取 ctx ...
// ctx.clearRect(...)
// ctx.fillRect(...) // 绘制背景
// 玩家位置 (使用 camera.position)
const playerX = (camera.position.x + 25) * 4; // <<< 改动 >>>
const playerZ = (camera.position.z + 25) * 4; // <<< 改动 >>>
ctx.fillStyle = 'blue';
ctx.fillRect(playerX - 2.5, playerZ - 2.5, 5, 5); // 居中绘制
// ... 绘制其他物体 ...
}
进阶用法:拥抱 PointerLockControls
(Advanced Usage: Embrace PointerLockControls
)
手动处理欧拉角和旋转顺序虽然可行,但 Three.js 提供了一个更方便、更健壮的工具:PointerLockControls
。它专门用于实现第一人称视角的鼠标锁定控制,能自动处理相对移动、避免万向锁问题,代码更简洁。
使用它的大致步骤:
-
引入控件:
// 如果你使用 ES6 模块 import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js'; // 如果你用的是全局脚本引入,确保 PointerLockControls.js 已被加载
-
初始化控件:
let controls; // 全局变量 function init() { // ... 创建 camera ... controls = new THREE.PointerLockControls(camera, document.body); // 可选:锁定/解锁时的事件监听 controls.addEventListener('lock', () => console.log('Pointer locked')); controls.addEventListener('unlock', () => console.log('Pointer unlocked')); // 需要将 controls 添加到场景中,因为它会移动相机这个 Object3D scene.add(controls.getObject()); // ... 其他初始化 ... // 鼠标点击时请求锁定 document.body.addEventListener('click', () => { controls.lock(); }); // 移除旧的 onMouseMove 监听器,因为 PointerLockControls 会接管 // document.removeEventListener('mousemove', onMouseMove); }
-
更新
animate
函数中的移动逻辑:
移动计算需要基于controls.getObject()
的方向,而不是直接用camera
(虽然controls.getObject()
就是camera
)。计算worldDirection
和rightDirection
的方法不变,但目标是移动controls.getObject()
的位置。function animate() { // ... 时间计算 delta ... if (!isPaused && controls.isLocked) { // 检查是否锁定且未暂停 // ... (阻尼、重力计算照旧) ... if (moveForward || moveBackward || moveLeft || moveRight) { // 从 controls 获取相机对象 (也就是 camera) const cameraObject = controls.getObject(); const moveDirection = new THREE.Vector3(direction.x, 0, direction.z); moveDirection.normalize(); const worldDirection = new THREE.Vector3(); cameraObject.getWorldDirection(worldDirection); // worldDirection.y = 0; // 不再需要强制 y=0,因为controls内部处理了移动 // worldDirection.normalize(); // 可能也不需要normalize,看controls的移动方式 // PointerLockControls 提供了更直接的移动方法 if (direction.z !== 0) { // moveForward 是正z还是负z取决于你的坐标系习惯,这里假设 W 是负 z controls.moveForward(-direction.z * speed * delta); } if (direction.x !== 0) { controls.moveRight(direction.x * speed * 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; } // 边界检查也要作用于 controls.getObject().position controls.getObject().position.x = Math.max(-24, Math.min(24, controls.getObject().position.x)); controls.getObject().position.z = Math.max(-24, Math.min(24, controls.getObject().position.z)); // ... (处理拾取物体 - 现在基准是 controls.getObject()) ... // ... (渲染) ... // updateMiniMap (也要用 controls.getObject().position) } // ... requestAnimationFrame ... }
PointerLockControls
帮你省去了onMouseMove
里复杂的旋转计算,并且内置了moveForward
和moveRight
方法,让移动代码更直观。
安全小贴士 (Quick Security Tip):
浏览器要求 requestPointerLock()
或 controls.lock()
必须由用户主动触发(比如点击事件),不能在页面加载后自动执行,这是出于安全考虑。确保你的锁定逻辑放在用户交互的回调里。
通过切换到单一相机并使用正确的相对旋转逻辑(无论是手动欧拉角还是 PointerLockControls
),你的镜头控制问题应该就能迎刃而解了。