Three.js点击触发射击动画并恢复默认待机动画求助
Three.js 机器人动画切换问题解决方案
问题说明
拥有robot_idle.fbx和robot_shoot.fbx两个动画文件,需求如下:
- 页面加载完成后默认播放
robot_idle待机动画 - 用户点击页面时触发
robot_shoot射击动画,动画播放完毕后自动切回待机动画
尝试通过useLoader加载射击动画实现点击触发,但未达到预期效果。
原代码
"use client"; import { useRef, useEffect, useState } from "react"; import * as THREE from "three"; import { Canvas, useLoader, useFrame } from "@react-three/fiber"; import { useAnimations } from "@react-three/drei"; import { FBXLoader } from "three/examples/jsm/Addons.js"; const Model = () => { const [error, setError] = useState<string | null>(null); const [loaded, setLoaded] = useState(false); const fbx = useLoader(FBXLoader, "/models/robot.fbx", undefined, (error) => { setError(`Failed to load FBX: ${error}`); }); const idleAnimation = useLoader(FBXLoader, "/models/animations/robot_idle.fbx", undefined, (error) => { setError(`Failed to load idle animation: ${error}`); }); const { mixer } = useAnimations(idleAnimation.animations, fbx); const mouseX = useRef(0); const [ baseTexture1, normalTexture1, roughnessTexture1, metallicTexture1, emissiveTexture1, aoTexture1, baseTexture2, normalTexture2, roughnessTexture2, metallicTexture2, emissiveTexture2, aoTexture2, ] = useLoader(THREE.TextureLoader, [ "/models/textures/torso_base_color.png", "/models/textures/torso_normal.png", "/models/textures/torso_roughness.png", "/models/textures/torso_metallic.png", "/models/textures/torso_emissive.png", "/models/textures/torso_ao.png", "/models/textures/limbs_base_color.png", "/models/textures/limbs_normal.png", "/models/textures/limbs_roughness.png", "/models/textures/limbs_metallic.png", "/models/textures/limbs_emissive.png", "/models/textures/limbs_ao.png", ]); const groupRef = useRef<THREE.Group>(null); useEffect(() => { if (fbx && idleAnimation) { const torsoMaterial = new THREE.MeshStandardMaterial({ map: baseTexture1, normalMap: normalTexture1, roughnessMap: roughnessTexture1, metalnessMap: metallicTexture1, emissiveMap: emissiveTexture1, aoMap: aoTexture1, emissive: new THREE.Color(0xffffff), emissiveIntensity: 1, roughness: 0.5, metalness: 0.8, }); const limbsMaterial = new THREE.MeshStandardMaterial({ map: baseTexture2, normalMap: normalTexture2, roughnessMap: roughnessTexture2, metalnessMap: metallicTexture2, emissiveMap: emissiveTexture2, aoMap: aoTexture2, emissive: new THREE.Color(0xffffff), emissiveIntensity: 1, roughness: 0.5, metalness: 0.85, }); fbx.traverse((child) => { if (child instanceof THREE.Mesh) { if (child.name.toLowerCase().includes("torso")) { child.material = torsoMaterial; } else { child.material = limbsMaterial; } child.castShadow = true; child.receiveShadow = true; } }); const idleAction = mixer.clipAction(idleAnimation.animations[0]); idleAction.play(); setLoaded(true); } const handleMouseMove = (event: MouseEvent) => { mouseX.current = (event.clientX / window.innerWidth) * 2 - 1; }; window.addEventListener("mousemove", handleMouseMove); return () => { window.removeEventListener("mousemove", handleMouseMove); }; }, [ fbx, idleAnimation, mixer, baseTexture1, normalTexture1, roughnessTexture1, metallicTexture1, emissiveTexture1, aoTexture1, baseTexture2, normalTexture2, roughnessTexture2, metallicTexture2, emissiveTexture2, aoTexture2, ]); useFrame(() => { if (groupRef.current) { groupRef.current.rotation.y = THREE.MathUtils.lerp( groupRef.current.rotation.y, mouseX.current * 0.5, 0.1, ); } }); if (error) { console.error(error); return null; } if (!loaded) { return null; } return ( <group ref={groupRef}> <primitive object={fbx} scale={0.013} position={[0, -1.2, 0]} rotation={[0, Math.PI * 0.1, 0]} /> </group> ); }; const Scene = () => { return ( <> <ambientLight intensity={0.5} /> <directionalLight color="#dec3c4" position={[5, 5, 5]} intensity={1} castShadow /> <Model /> </> ); }; const AnimatedRobot = () => { return ( <div className="h-full w-full"> <Canvas shadows camera={{ position: [0, 0, 3], fov: 60 }}> <Scene /> </Canvas> </div> ); }; export default AnimatedRobot;
解决方案
核心问题在于未正确加载射击动画、管理动画状态切换,以及监听射击动画结束事件。以下是修改后的完整代码:
"use client"; import { useRef, useEffect, useState } from "react"; import * as THREE from "three"; import { Canvas, useLoader, useFrame } from "@react-three/fiber"; import { useAnimations } from "@react-three/drei"; import { FBXLoader } from "three/examples/jsm/Addons.js"; const Model = () => { const [error, setError] = useState<string | null>(null); const [loaded, setLoaded] = useState(false); // 加载模型和两个动画文件 const fbx = useLoader(FBXLoader, "/models/robot.fbx", undefined, (error) => { setError(`Failed to load FBX: ${error}`); }); const idleAnimation = useLoader(FBXLoader, "/models/animations/robot_idle.fbx", undefined, (error) => { setError(`Failed to load idle animation: ${error}`); }); const shootAnimation = useLoader(FBXLoader, "/models/animations/robot_shoot.fbx", undefined, (error) => { setError(`Failed to load shoot animation: ${error}`); }); // 初始化动画混合器,合并两个动画的剪辑 const { mixer, actions } = useAnimations([...idleAnimation.animations, ...shootAnimation.animations], fbx); const mouseX = useRef(0); // 保存动画动作引用,方便切换 const idleActionRef = useRef<THREE.AnimationAction | null>(null); const shootActionRef = useRef<THREE.AnimationAction | null>(null); // 标记是否正在播放射击动画,防止重复触发 const isShootingRef = useRef(false); const [ baseTexture1, normalTexture1, roughnessTexture1, metallicTexture1, emissiveTexture1, aoTexture1, baseTexture2, normalTexture2, roughnessTexture2, metallicTexture2, emissiveTexture2, aoTexture2, ] = useLoader(THREE.TextureLoader, [ "/models/textures/torso_base_color.png", "/models/textures/torso_normal.png", "/models/textures/torso_roughness.png", "/models/textures/torso_metallic.png", "/models/textures/torso_emissive.png", "/models/textures/torso_ao.png", "/models/textures/limbs_base_color.png", "/models/textures/limbs_normal.png", "/models/textures/limbs_roughness.png", "/models/textures/limbs_metallic.png", "/models/textures/limbs_emissive.png", "/models/textures/limbs_ao.png", ]); const groupRef = useRef<THREE.Group>(null); useEffect(() => { if (fbx && idleAnimation && shootAnimation && mixer) { // 材质设置保持不变 const torsoMaterial = new THREE.MeshStandardMaterial({ map: baseTexture1, normalMap: normalTexture1, roughnessMap: roughnessTexture1, metalnessMap: metallicTexture1, emissiveMap: emissiveTexture1, aoMap: aoTexture1, emissive: new THREE.Color(0xffffff), emissiveIntensity: 1, roughness: 0.5, metalness: 0.8, }); const limbsMaterial = new THREE.MeshStandardMaterial({ map: baseTexture2, normalMap: normalTexture2, roughnessMap: roughnessTexture2, metalnessMap: metallicTexture2, emissiveMap: emissiveTexture2, aoMap: aoTexture2, emissive: new THREE.Color(0xffffff), emissiveIntensity: 1, roughness: 0.5, metalness: 0.85, }); fbx.traverse((child) => { if (child instanceof THREE.Mesh) { if (child.name.toLowerCase().includes("torso")) { child.material = torsoMaterial; } else { child.material = limbsMaterial; } child.castShadow = true; child.receiveShadow = true; } }); // 获取动画动作,设置循环模式 idleActionRef.current = actions[Object.keys(actions).find(key => key.includes("idle"))!]; shootActionRef.current = actions[Object.keys(actions).find(key => key.includes("shoot"))!]; if (idleActionRef.current) { idleActionRef.current.setLoop(THREE.LoopRepeat, Infinity); idleActionRef.current.play(); } if (shootActionRef.current) { shootActionRef.current.setLoop(THREE.LoopOnce, 1); shootActionRef.current.clampWhenFinished = true; } setLoaded(true); } // 鼠标移动监听保持不变 const handleMouseMove = (event: MouseEvent) => { mouseX.current = (event.clientX / window.innerWidth) * 2 - 1; }; window.addEventListener("mousemove", handleMouseMove); // 点击事件处理:触发射击动画 const handleClick = () => { if (!isShootingRef.current && idleActionRef.current && shootActionRef.current) { isShootingRef.current = true; // 暂停待机动画,播放射击动画 idleActionRef.current.stop(); shootActionRef.current.reset().play(); // 监听射击动画结束事件,恢复待机动画 const onAnimationEnd = () => { shootActionRef.current?.removeEventListener("finished", onAnimationEnd); idleActionRef.current?.play(); isShootingRef.current = false; }; shootActionRef.current.addEventListener("finished", onAnimationEnd); } }; window.addEventListener("click", handleClick); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("click", handleClick); }; }, [ fbx, idleAnimation, shootAnimation, mixer, actions, baseTexture1, normalTexture1, roughnessTexture1, metallicTexture1, emissiveTexture1, aoTexture1, baseTexture2, normalTexture2, roughnessTexture2, metallicTexture2, emissiveTexture2, aoTexture2, ]); // 更新动画混合器 useFrame((state, delta) => { if (groupRef.current) { groupRef.current.rotation.y = THREE.MathUtils.lerp( groupRef.current.rotation.y, mouseX.current * 0.5, 0.1, ); } // 每帧更新动画混合器,保证动画正常播放 if (mixer) mixer.update(delta); }); if (error) { console.error(error); return null; } if (!loaded) { return null; } return ( <group ref={groupRef}> <primitive object={fbx} scale={0.013} position={[0, -1.2, 0]} rotation={[0, Math.PI * 0.1, 0]} /> </group> ); }; const Scene = () => { return ( <> <ambientLight intensity={0.5} /> <directionalLight color="#dec3c4" position={[5, 5, 5]} intensity={1} castShadow /> <Model /> </> ); }; const AnimatedRobot = () => { return ( <div className="h-full w-full"> <Canvas shadows camera={{ position: [0, 0, 3], fov: 60 }}> <Scene /> </Canvas> </div> ); }; export default AnimatedRobot;
关键修改点
- 加载
robot_shoot.fbx动画文件,将两个动画的剪辑合并传入useAnimations - 保存动画动作的引用,分别设置循环模式:待机动画无限循环,射击动画只播放一次
- 添加页面点击事件,触发时暂停待机动画、播放射击动画,并监听射击动画的
finished事件,动画结束后恢复待机动画 - 使用
isShootingRef标记状态,防止重复点击触发多个射击动画 - 在
useFrame中添加动画混合器的更新逻辑,保证动画正常播放
内容的提问来源于stack exchange,提问作者Yasindu
相关产品推荐
相关产品推荐

