返回

React-Three-Fiber相机视角问题及GSAP动画冲突解决

javascript

React-Three-Fiber:GSAP 动画后相机视角问题 (controls.target 惹的祸)

问题

在使用 react-three-fiber 开发时遇到一个相机视角问题。我创建了一个名为 HoverableCube 的组件,它渲染一个透明的可交互立方体。点击立方体后,相机应该移动到指定位置并看向该立方体。

具体参数如下:

  • position, rotation, scale:定义立方体的位置、旋转和大小。
  • moveTo:点击立方体后,相机移动到的目标位置(三维向量)。
  • setLastCameraPosition:保存相机移动前的位置。
  • controlsRef: 控制器引用状态。

问题来了:相机移动到 moveTo 位置后,并没有看向立方体,而是始终朝向原点 (0,0,0)。

问题原因分析

根本原因在于 GSAP 动画和 OrbitControlstarget 属性之间的冲突。

  1. GSAP 修改相机位置: GSAP 动画直接修改了 camera.position,这会影响 OrbitControls 的内部计算。
  2. OrbitControlstarget OrbitControls 通过 target 属性来确定相机围绕的焦点。默认情况下,target 是 (0, 0, 0)。
  3. lookAt()失效: GSAP 动画过程中或结束后立即调用 camera.lookAt() 可能会被 OrbitControls 的后续更新覆盖。
  4. controlsRef.current 可能未及时更新: 尝试使用controlsRef 获取状态进行计算时候, GSAP 动画可能还未结束, 获得的数据不一定是最新的。

解决方案

针对上述原因,可以采取以下几种解决方案:

方案一:更新 OrbitControlstarget (推荐)

在 GSAP 动画完成后,更新 OrbitControlstarget 属性为立方体的位置。这是最直接有效的方法。

function HoverableCube({ position, rotation, scale, moveTo, setLastCameraPosition, controlsRef }) {
  const [hovered, setHovered] = useState(false);
  const { camera, controls } = useThree();

  const handleClick = () => {
    setLastCameraPosition([camera.position.x, camera.position.y, camera.position.z]);

    gsap.to(camera.position, {
      x: moveTo[0],
      y: moveTo[1],
      z: moveTo[2],
      duration: 1.5,
      ease: 'power2.out',
      // 动画完成后更新 target
      onComplete: () => {
          controls.target.set(position[0], position[1], position[2]);
          controls.update(); // 重要!确保更新控制器
      }
    });
  };

  return (
    // ... (其余代码不变)
    <group>
      {/* Main Cube */}
      <mesh
        castShadow
        receiveShadow
        position={position}
        rotation={rotation}
        scale={scale}
        onPointerOver={() => setHovered(true)}
        onPointerOut={() => setHovered(false)}
        onClick={handleClick} // camera click move 
      >
        <boxGeometry args={[1, 1, 1]} />
        <meshStandardMaterial color="blue" transparent={false} opacity={0.01} />
      </mesh>

      {/* White Outline on Hover */}
      {hovered && (
        <lineSegments position={position} rotation={rotation} scale={scale}>
          <edgesGeometry attach="geometry" args={[new THREE.BoxGeometry(1, 1, 1)]} />
          <lineBasicMaterial attach="material" color="white" linewidth={1} />
        </lineSegments>
      )}
    </group>
  );
}

原理:

  • gsap.toonComplete 回调函数在动画完成后执行。
  • controls.target.set(...)OrbitControls 的焦点设置为立方体的位置。
  • controls.update() 强制 OrbitControls 更新其内部状态,使相机看向新的 target

代码解释:
利用GSAP的onComplete函数, 在动画结束后立刻更改controls.target并调用controls.update(). 确保了动画平滑, 相机转向正确.

方案二:使用 THREE.Vector3.lerp()

不直接修改 camera.position,而是使用 THREE.Vector3.lerp()useFrame 钩子中平滑地插值相机位置和朝向。

import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useRef } from 'react';
// ... (其余导入)

function HoverableCube({ position, rotation, scale, moveTo, setLastCameraPosition, controlsRef}) {
    const [hovered, setHovered] = useState(false);
  const { camera, controls } = useThree();
    const targetPosition = useRef(new THREE.Vector3());
     const isAnimating = useRef(false);

    const handleClick = () => {
       setLastCameraPosition([camera.position.x, camera.position.y, camera.position.z]);
        targetPosition.current.set(moveTo[0], moveTo[1], moveTo[2]);
        isAnimating.current = true;

    };

    useFrame(() => {
     if (isAnimating.current) {
        // 使用 lerp 平滑过渡相机位置
        camera.position.lerp(targetPosition.current, 0.1);

          // 更新controls.target, 让相机持续看向立方体.
           controls.target.lerp(new THREE.Vector3(position[0], position[1], position[2]), 0.1);
        controls.update(); //更新控制器

        // 如果相机位置与目标位置非常接近,则停止动画.
        if (camera.position.distanceTo(targetPosition.current) < 0.01) {
           isAnimating.current = false;
        }
     }
});
  return (
     // ...(其余代码)
    <group>
      {/* Main Cube */}
      <mesh
        castShadow
        receiveShadow
        position={position}
        rotation={rotation}
        scale={scale}
        onPointerOver={() => setHovered(true)}
        onPointerOut={() => setHovered(false)}
        onClick={handleClick} // camera click move 
      >
        <boxGeometry args={[1, 1, 1]} />
        <meshStandardMaterial color="blue" transparent={false} opacity={0.01} />
      </mesh>

      {/* White Outline on Hover */}
      {hovered && (
        <lineSegments position={position} rotation={rotation} scale={scale}>
          <edgesGeometry attach="geometry" args={[new THREE.BoxGeometry(1, 1, 1)]} />
          <lineBasicMaterial attach="material" color="white" linewidth={1} />
        </lineSegments>
      )}
    </group>
  )
}

原理:

  • useFrame 钩子在每一帧都会执行。
  • THREE.Vector3.lerp() 用于计算两个向量之间的线性插值。
  • camera.position.lerp(targetPosition, 0.1) 使相机位置逐渐接近 targetPosition,实现平滑过渡。
  • 利用controls.target.lerp, 使得动画中能逐步趋近目标.

代码解释:

通过每一帧改变一小部分的方式, 把相机位置与相机焦点同步更新.
distanceTo函数用来比较当前位置和目标位置差距,如果距离够小就判断动画完成.

方案三:禁用 OrbitControls,手动控制相机

完全弃用 OrbitControls,使用 useFramecamera.lookAt() 手动控制相机的每一帧的位置和朝向。

import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useRef } from 'react'
// ... 其他导入

function HoverableCube({ position, scale, rotation, moveTo, setLastCameraPosition, controlsRef }) {
  const [hovered, setHovered] = useState(false);
  const { camera } = useThree();
    const targetPosition = useRef(new THREE.Vector3());
  const isAnimating = useRef(false);

  const handleClick = () => {
    setLastCameraPosition([camera.position.x, camera.position.y, camera.position.z])
      targetPosition.current.set(moveTo[0], moveTo[1], moveTo[2]);
       isAnimating.current = true;
  };

 useFrame(() => {
      if(isAnimating.current)
        {
           camera.position.lerp(targetPosition.current, 0.1);

             //直接控制lookAt
             camera.lookAt(position[0], position[1], position[2]);

            if (camera.position.distanceTo(targetPosition.current) < 0.01) {
              isAnimating.current = false;
            }
        }
 });

  return (
        //...(其余代码)
     <group>
      {/* Main Cube */}
      <mesh
        castShadow
        receiveShadow
        position={position}
        rotation={rotation}
        scale={scale}
        onPointerOver={() => setHovered(true)}
        onPointerOut={() => setHovered(false)}
        onClick={handleClick} // camera click move 
      >
        <boxGeometry args={[1, 1, 1]} />
        <meshStandardMaterial color="blue" transparent={false} opacity={0.01} />
      </mesh>

      {/* White Outline on Hover */}
      {hovered && (
        <lineSegments position={position} rotation={rotation} scale={scale}>
          <edgesGeometry attach="geometry" args={[new THREE.BoxGeometry(1, 1, 1)]} />
          <lineBasicMaterial attach="material" color="white" linewidth={1} />
        </lineSegments>
      )}
    </group>
  );
}

原理:

  • 禁用 OrbitControls 后,完全由开发者控制相机的行为。
  • useFrame 钩子中,使用 camera.lookAt() 直接指定相机朝向。
  • 利用lerp制作相机位置过渡动画。

代码解释:

此方案较为底层, 直接每帧刷新, 没有使用OrbitControls。适合需要精细控制相机, 不希望引入OrbitControls干扰的情况。

注意: 这种方法需要手动处理相机的所有交互逻辑(例如旋转、缩放等),代码会更复杂。

进阶使用技巧(方案一的补充)

如果需要更复杂的相机动画,可以在 GSAP 动画中使用 timeline 来编排多个动画步骤。

const handleClick = () => {
   setLastCameraPosition([camera.position.x, camera.position.y, camera.position.z]);

    const tl = gsap.timeline({
      onComplete: () => {
          controls.target.set(position[0], position[1], position[2]);
         controls.update();
      }
    });

    tl.to(camera.position, {
        x: moveTo[0],
        y: moveTo[1],
      z: moveTo[2],
       duration: 1.5,
      ease: 'power2.out',
    })
     .to(controls.target, { // 同时缓动target
          x: position[0],
          y: position[1],
        z: position[2],
          duration: 1.5, // 时间匹配, 调整来达到视觉效果.
      ease: 'power2.out',
    }, 0); // ",0" 保证同时执行
}

使用 gsap.timeline 可以将相机位置的动画和controls.target变化组合起来。利用 timeline 可以制作更为精细的转场动画.

总结

处理 react-three-fiber 中相机视角问题的关键在于理解 GSAP 动画、OrbitControlstarget 属性以及 camera.lookAt() 之间的关系。通过选择合适的解决方案,可以实现平滑的相机动画和正确的视角控制。优先选择方案一,它在简洁性和效果之间取得了很好的平衡。如果需要更底层的控制, 可考虑方案二与方案三。