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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 10:59:51