You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React-Three-Fiber中如何实现第三人称相机从后方跟随坦克?

稳定的第三人称相机跟随方案(结合useRaycastVehicle)

问题描述

正在开发一款3D坦克游戏,基于useRaycastVehicle实现了坦克控制,但第三人称相机的实现存在以下问题:

  • 当前通过判断z轴坐标是否为负值翻转视角,逻辑生硬,体验不佳。
  • 尝试参考教程代码时,出现相机卡入地面、无法正常跟随坦克的情况。

当前代码

import { useFrame, useThree } from "@react-three/fiber";
import { useRaycastVehicle } from "@react-three/cannon";
import { useEffect, useRef } from "react";

import { useKeyboardControls } from "@react-three/drei";
import Beetle from "./Beetle";
import Wheel from "./Wheel";
import { Vector3 } from "three";

export default function Tank({
    radius = 0.7,
    width = 1.2,
    height = -0.04,
    front = 1.3,
    back = -1.15,
    steer = 0.75,
    force = 2000,
    maxBrake = 1e5,
    ...props
}) {
    const {
        forward,
        backward,
        leftward,
        rightward,
        // shoot: jump,
    } = useKeyboardControls((state) => state);

    const chassis = useRef();
    const wheel1 = useRef();
    const wheel2 = useRef();
    const wheel3 = useRef();
    const wheel4 = useRef();

    const wheelInfo = {
        radius,
        directionLocal: [0, -1, 0],
        suspensionStiffness: 30,
        suspensionRestLength: 0.3,
        maxSuspensionForce: 1e4,
        maxSuspensionTravel: 0.3,
        dampingRelaxation: 10,
        dampingCompression: 4.4,
        axleLocal: [-1, 0, 0],
        chassisConnectionPointLocal: [1, 0, 1],
        useCustomSlidingRotationalSpeed: true,
        customSlidingRotationalSpeed: -30,
        frictionSlip: 2,
    };

    const wheelInfo1 = {
        ...wheelInfo,
        isFrontWheel: true,
        chassisConnectionPointLocal: [-width / 2, height, front],
    };
    const wheelInfo2 = {
        ...wheelInfo,
        isFrontWheel: true,
        chassisConnectionPointLocal: [width / 2, height, front],
    };
    const wheelInfo3 = {
        ...wheelInfo,
        isFrontWheel: false,
        chassisConnectionPointLocal: [-width / 2, height, back],
    };
    const wheelInfo4 = {
        ...wheelInfo,
        isFrontWheel: false,
        chassisConnectionPointLocal: [width / 2, height, back],
    };

    const [myTank, api] = useRaycastVehicle(() => ({
        chassisBody: chassis,
        wheels: [wheel1, wheel2, wheel3, wheel4],
        wheelInfos: [wheelInfo1, wheelInfo2, wheelInfo3, wheelInfo4],
        indexForwardAxis: 2,
        indexRightAxis: 0,
        indexUpAxis: 1,
    }));

    const { camera, scene } = useThree();

    const pos = useRef([0, 0, 0]);
    const rot = useRef([0, 0, 0]);
    useEffect(() => {
        if (chassis.current) {
            chassis.current.api.rotation.subscribe((r) => (rot.current = r));
            chassis.current.api.position.subscribe((p) => (pos.current = p));
        }
    }, [chassis.current]);

    useFrame(() => {
        for (let e = 2; e < 4; e++)
            api.applyEngineForce(
                forward || backward
                    ? force * (forward && !backward ? -1 : 1)
                    : 0,
                2
            );
        for (let s = 0; s < 2; s++)
            api.setSteeringValue(
                leftward || rightward
                    ? steer * (leftward && !rightward ? 1 : -1)
                    : 0,
                s
            );

        const cameraOffset = new Vector3(0, 4, -5);

        var zDimensionNegative = pos.current[2] > -5 ? 0 : 10;

        camera.position
            .copy(
                new Vector3(
                    pos.current[0],
                    pos.current[1],
                    pos.current[2] + zDimensionNegative
                )
            )
            .add(cameraOffset);
    });

    return (
        <group ref={myTank} position={[0, -0.4, 0]}>
            <Beetle
                ref={chassis}
                rotation={props.rotation}
                position={props.position}
                angularVelocity={props.angularVelocity}
            />
            <Wheel
                ref={wheel1}
                radius={radius}
                leftSide
            />
            <Wheel
                ref={wheel2}
                radius={radius}
            />
            <Wheel
                ref={wheel3}
                radius={radius}
                leftSide
            />
            <Wheel
                ref={wheel4}
                radius={radius}
            />
        </group>
    );
}

截图说明

  • 理想效果:相机始终稳定跟随坦克后方,视角自然流畅,无卡顿或生硬翻转。
  • 问题效果:相机卡入地面,无法随坦克正常移动。
  • 当前方案效果:视角会基于z轴生硬翻转,体验不流畅。

解决方案

以下是优化后的相机实现逻辑,核心是基于坦克实时姿态计算相机位置,并添加平滑过渡和碰撞检测:

代码修改

  1. 新增Three.js相关导入:
import { Vector3, Euler, Quaternion, Raycaster } from "three";
  1. 替换useFrame中的相机控制代码:
useFrame((state, delta) => {
    // 保留原有坦克控制逻辑
    for (let e = 2; e < 4; e++)
        api.applyEngineForce(
            forward || backward
                ? force * (forward && !backward ? -1 : 1)
                : 0,
            2
        );
    for (let s = 0; s < 2; s++)
        api.setSteeringValue(
            leftward || rightward
                ? steer * (leftward && !rightward ? 1 : -1)
                : 0,
            s
        );

    // 相机跟随逻辑
    const tankPos = new Vector3(...pos.current);
    const tankRot = new Euler(...rot.current);
    const tankQuat = new Quaternion().setFromEuler(tankRot);

    // 定义相机相对于坦克的偏移量(可按需调整高度和距离)
    const localOffset = new Vector3(0, 4, -5);
    // 将偏移量应用坦克旋转,确保相机始终在坦克后方
    localOffset.applyQuaternion(tankQuat);

    // 计算相机目标位置
    const targetCamPos = tankPos.clone().add(localOffset);

    // 平滑过渡相机位置(delta*5控制平滑速度,数值越大越灵敏)
    camera.position.lerp(targetCamPos, delta * 5);

    // 让相机始终看向坦克主体(微调y轴偏移优化视角)
    camera.lookAt(tankPos.clone().add(new Vector3(0, 1, 0)));

    // 可选:碰撞检测,避免相机穿过地面或障碍物
    const raycaster = new Raycaster(
        tankPos,
        targetCamPos.clone().sub(tankPos).normalize()
    );
    // 排除坦克自身模型避免误检测
    const excludeObjects = [chassis.current, wheel1.current, wheel2.current, wheel3.current, wheel4.current];
    const intersects = raycaster.intersectObjects(state.scene.children.filter(obj => !excludeObjects.includes(obj)), true);
    
    if (intersects.length > 0) {
        const closestHit = intersects[0];
        // 若障碍物在相机与坦克之间,调整相机位置避免穿模
        if (closestHit.distance < localOffset.length()) {
            camera.position.copy(
                tankPos.clone().add(localOffset.clone().setLength(closestHit.distance - 0.5))
            );
        }
    }
});

关键优化点

  • 基于坦克姿态计算偏移:不再依赖固定z轴判断,相机始终跟随坦克朝向移动。
  • 平滑过渡:使用lerp实现相机位置的平滑插值,消除生硬跳转。
  • 定向注视:相机始终聚焦坦克主体,保证视角稳定。
  • 碰撞避免:通过射线检测防止相机穿过地面或场景障碍物,提升真实感。

注意事项

  • 可根据游戏需求调整localOffset的数值,优化相机高度和距离。
  • 平滑速度参数delta*5可根据帧率调整,确保不同设备体验一致。
  • 碰撞检测中需排除坦克自身模型,避免误触发位置调整。

内容的提问来源于stack exchange,提问作者imback0526

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 23:55:31