React-Three-Fiber相机视角问题及GSAP动画冲突解决
2025-03-12 11:20:02
React-Three-Fiber:GSAP 动画后相机视角问题 (controls.target 惹的祸)
问题
在使用 react-three-fiber 开发时遇到一个相机视角问题。我创建了一个名为 HoverableCube
的组件,它渲染一个透明的可交互立方体。点击立方体后,相机应该移动到指定位置并看向该立方体。
具体参数如下:
position
,rotation
,scale
:定义立方体的位置、旋转和大小。moveTo
:点击立方体后,相机移动到的目标位置(三维向量)。setLastCameraPosition
:保存相机移动前的位置。controlsRef
: 控制器引用状态。
问题来了:相机移动到 moveTo
位置后,并没有看向立方体,而是始终朝向原点 (0,0,0)。
问题原因分析
根本原因在于 GSAP 动画和 OrbitControls
的 target
属性之间的冲突。
- GSAP 修改相机位置: GSAP 动画直接修改了
camera.position
,这会影响OrbitControls
的内部计算。 OrbitControls
的target
:OrbitControls
通过target
属性来确定相机围绕的焦点。默认情况下,target
是 (0, 0, 0)。lookAt()
失效: GSAP 动画过程中或结束后立即调用camera.lookAt()
可能会被OrbitControls
的后续更新覆盖。controlsRef.current
可能未及时更新: 尝试使用controlsRef 获取状态进行计算时候, GSAP 动画可能还未结束, 获得的数据不一定是最新的。
解决方案
针对上述原因,可以采取以下几种解决方案:
方案一:更新 OrbitControls
的 target
(推荐)
在 GSAP 动画完成后,更新 OrbitControls
的 target
属性为立方体的位置。这是最直接有效的方法。
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.to
的onComplete
回调函数在动画完成后执行。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
,使用 useFrame
和 camera.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 动画、OrbitControls
的 target
属性以及 camera.lookAt()
之间的关系。通过选择合适的解决方案,可以实现平滑的相机动画和正确的视角控制。优先选择方案一,它在简洁性和效果之间取得了很好的平衡。如果需要更底层的控制, 可考虑方案二与方案三。