返回

Three.js FPS 镜头控制反了?一文解决反转与侧翻

javascript

好的,这是你要的博客文章内容:

修正 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
    }
}

这段代码做了几件事:

  1. 它使用两个全局变量 yawpitch 来累积鼠标的移动量。movementX (左右移动) 总是减少 yawmovementY (上下移动) 总是减少 pitch
  2. 它把计算出的 yawpitch 直接 赋值给 currentCamera.rotation.ycurrentCamera.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)动作。

核心矛盾: 你试图用一套基于世界坐标系(或者说,基于初始北向相机)的 yawpitch 逻辑,去控制一堆具有不同初始旋转状态的相机。这就像你蒙着眼睛开车,只知道方向盘向左打是左转,向右打是右转,但如果有人偷偷把你的车原地调个头,你再向左打方向盘,车实际上是向右走的。

你的移动计算(animate 函数里的 getWorldDirection)倒是用了 currentCamera 的当前方向,这没问题。问题出在旋转控制上。

咋解决? (How to Fix It?)

最地道、最符合 FPS 游戏开发习惯的做法是:放弃多相机切换的思路,只用一个相机,并且让这个相机根据鼠标输入进行相对旋转。

单一相机才是王道 (Single Camera is the Way to Go)

FPS 游戏里,玩家的视角就是那个唯一的相机。玩家移动,相机跟着移动;玩家转头,相机跟着旋转。不需要为东南西北各准备一个相机。

原理 (The Gist):

不再维护全局的 yawpitch 状态。每次鼠标移动时,计算出移动了多少 (movementX, movementY),然后让 当前 相机在它 自己 的坐标系下,或者基于世界坐标系进行 相对 旋转。关键在于“相对”二字,而不是设置绝对的旋转值。

改动步骤 (Steps to Modify):

  1. 移除多余相机:

    • 删除 cameras 对象及其 north, south, east, west 属性。
    • 删除 init 函数中对这些相机的初始化代码。
    • 删除 onKeyDown 中处理 Digit1Digit4 的相机切换逻辑。
  2. 创建单一相机:

    • 在全局声明一个相机变量,比如 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); // 别忘了加到场景里 (或者作为某个对象的子对象)
      
  3. 重写 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.ycamera.rotation.x 来累积旋转量。
      • 限制 camera.rotation.x 的范围防止过度旋转。
  4. 更新 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
      }
      
  5. 更新渲染和窗口大小调整:

    • renderer.render(scene, camera); // 使用单相机渲染
    • onWindowResize 函数中更新 camera.aspectcamera.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。它专门用于实现第一人称视角的鼠标锁定控制,能自动处理相对移动、避免万向锁问题,代码更简洁。

使用它的大致步骤:

  1. 引入控件:

    // 如果你使用 ES6 模块
    import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js';
    // 如果你用的是全局脚本引入,确保 PointerLockControls.js 已被加载
    
  2. 初始化控件:

    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);
    }
    
  3. 更新 animate 函数中的移动逻辑:
    移动计算需要基于 controls.getObject() 的方向,而不是直接用 camera(虽然 controls.getObject() 就是 camera)。计算 worldDirectionrightDirection 的方法不变,但目标是移动 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 里复杂的旋转计算,并且内置了 moveForwardmoveRight 方法,让移动代码更直观。

安全小贴士 (Quick Security Tip):

浏览器要求 requestPointerLock()controls.lock() 必须由用户主动触发(比如点击事件),不能在页面加载后自动执行,这是出于安全考虑。确保你的锁定逻辑放在用户交互的回调里。

通过切换到单一相机并使用正确的相对旋转逻辑(无论是手动欧拉角还是 PointerLockControls),你的镜头控制问题应该就能迎刃而解了。