mcpField
R
r3f-animation

r3f-animation

github.com/zebbern开发4.7k43 次安装1 天前更新
代码仓库

关于 r3f-animation

面向构建 3D 场景的 React Three Fiber 动画技能,涵盖逐帧更新、模型动画、弹簧效果、程序化运动和状态管理实践。

主要功能

  1. 01使用 useFrame 逐帧更新
  2. 02控制 GLTF 动画并交叉淡化
  3. 03使用 @react-spring/three 实现弹簧动画
  4. 04变形目标与骨骼动画
  5. 05程序化运动模式
  6. 06状态管理与渲染性能建议

适用场景

  1. 01为 React Three Fiber 场景中的网格添加动画
  2. 02控制并混合 GLTF 角色动画片段
  3. 03制作弹簧、变形或骨骼运动
  4. 04减少动画组件的重复渲染

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zebbern/claude-code-guide --skill r3f-animation

安装后在 Claude Code 中输入/r3f-animation确认已加载。

详细介绍

React Three Fiber 动画

快速入门

import { Canvas, useFrame } from '@react-three/fiber'
import { useRef } from 'react'

function RotatingBox() {
  const meshRef = useRef()

  useFrame((state, delta) => {
    meshRef.current.rotation.x += delta
    meshRef.current.rotation.y += delta * 0.5
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial color="hotpink" />
    </mesh>
  )
}

export default function App() {
  return (
    <Canvas>
      <ambientLight />
      <RotatingBox />
    </Canvas>
  )
}

useFrame Hook

R3F 中的核心动画 Hook。每帧运行一次。

基本用法
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'

function AnimatedMesh() {
  const meshRef = useRef()

  useFrame((state, delta) => {
    // state contains: clock, camera, scene, gl, mouse, etc.
    // delta is time since last frame in seconds

    meshRef.current.rotation.y += delta
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial color="orange" />
    </mesh>
  )
}
状态对象
useFrame((state, delta, xrFrame) => {
  const {
    clock,           // THREE.Clock
    camera,          // Current camera
    scene,           // Scene
    gl,              // WebGLRenderer
    mouse,           // Normalized mouse position (-1 to 1)
    pointer,         // Same as mouse
    viewport,        // Viewport dimensions
    size,            // Canvas size
    raycaster,       // Raycaster
    get,             // Get current state
    set,             // Set state
    invalidate,      // Request re-render (when frameloop="demand")
  } = state

  // Time-based animation
  const t = clock.getElapsedTime()
  meshRef.current.position.y = Math.sin(t) * 2
})
渲染优先级
// Lower numbers run first. Default is 0.
// Use negative for pre-render, positive for post-render

function PreRender() {
  useFrame(() => {
    // Runs before main render
  }, -1)
}

function PostRender() {
  useFrame(() => {
    // Runs after main render
  }, 1)
}

function DefaultRender() {
  useFrame(() => {
    // Runs at default priority (0)
  })
}
条件动画
function ConditionalAnimation({ isAnimating }) {
  const meshRef = useRef()

  useFrame((state, delta) => {
    if (!isAnimating) return
    meshRef.current.rotation.y += delta
  })

  return <mesh ref={meshRef}>...</mesh>
}

使用 useAnimations 播放 GLTF 动画

播放 GLTF/GLB 文件中动画的推荐方式。

基本用法
import { useGLTF, useAnimations } from '@react-three/drei'
import { useEffect, useRef } from 'react'

function AnimatedModel() {
  const group = useRef()
  const { scene, animations } = useGLTF('/models/character.glb')
  const { actions, names } = useAnimations(animations, group)

  useEffect(() => {
    // Play first animation
    actions[names[0]]?.play()
  }, [actions, names])

  return <primitive ref={group} object={scene} />
}
动画控制
function Character() {
  const group = useRef()
  const { scene, animations } = useGLTF('/models/character.glb')
  const { actions, mixer } = useAnimations(animations, group)

  useEffect(() => {
    const action = actions['Walk']
    if (action) {
      // Playback control
      action.play()
      action.stop()
      action.reset()
      action.paused = true

      // Speed
      action.timeScale = 1.5  // 1.5x speed
      action.timeScale = -1   // Reverse

      // Loop modes
      action.loop = THREE.LoopOnce
      action.loop = THREE.LoopRepeat
      action.loop = THREE.LoopPingPong
      action.repetitions = 3
      action.clampWhenFinished = true

      // Weight (for blending)
      action.weight = 1
    }
  }, [actions])

  return <primitive ref={group} object={scene} />
}
动画间交叉淡化
import { useGLTF, useAnimations } from '@react-three/drei'
import { useState, useEffect, useRef } from 'react'

function Character() {
  const group = useRef()
  const { scene, animations } = useGLTF('/models/character.glb')
  const { actions } = useAnimations(animations, group)
  const [currentAnim, setCurrentAnim] = useState('Idle')

  useEffect(() => {
    // Fade out all animations
    Object.values(actions).forEach(action => {
      action?.fadeOut(0.5)
    })

    // Fade in current animation
    actions[currentAnim]?.reset().fadeIn(0.5).play()
  }, [currentAnim, actions])

  return (
    <group ref={group}>
      <primitive object={scene} />
    </group>
  )
}
动画事件
function AnimatedModel() {
  const group = useRef()
  const { scene, animations } = useGLTF('/models/character.glb')
  const { actions, mixer } = useAnimations(animations, group)

  useEffect(() => {
    // Listen for animation events
    const onFinished = (e) => {
      console.log('Animation finished:', e.action.getClip().name)
    }

    const onLoop = (e) => {
      console.log('Animation looped:', e.action.getClip().name)
    }

    mixer.addEventListener('finished', onFinished)
    mixer.addEventListener('loop', onLoop)

    return () => {
      mixer.removeEventListener('finished', onFinished)
      mixer.removeEventListener('loop', onLoop)
    }
  }, [mixer])

  return <primitive ref={group} object={scene} />
}
动画混合
function CharacterController({ speed = 0 }) {
  const group = useRef()
  const { scene, animations } = useGLTF('/models/character.glb')
  const { actions } = useAnimations(animations, group)

  useEffect(() => {
    // Start all animations
    actions['Idle']?.play()
    actions['Walk']?.play()
    actions['Run']?.play()
  }, [actions])

  // Blend based on speed
  useFrame(() => {
    if (speed < 0.1) {
      actions['Idle']?.setEffectiveWeight(1)
      actions['Walk']?.setEffectiveWeight(0)
      actions['Run']?.setEffectiveWeight(0)
    } else if (speed < 5) {
      const t = speed / 5
      actions['Idle']?.setEffectiveWeight(1 - t)
      actions['Walk']?.setEffectiveWeight(t)
      actions['Run']?.setEffectiveWeight(0)
    } else {
      const t = Math.min((speed - 5) / 5, 1)
      actions['Idle']?.setEffectiveWeight(0)
      actions['Walk']?.setEffectiveWeight(1 - t)
      actions['Run']?.setEffectiveWeight(t)
    }
  })

  return <primitive ref={group} object={scene} />
}

弹簧动画(@react-spring/three)

与 R3F 集成的基于物理的弹簧动画。

安装
npm install @react-spring/three
基本弹簧动画
import { useSpring, animated } from '@react-spring/three'

function AnimatedBox() {
  const [active, setActive] = useState(false)

  const { scale, color } = useSpring({
    scale: active ? 1.5 : 1,
    color: active ? '#ff6b6b' : '#4ecdc4',
    config: { mass: 1, tension: 280, friction: 60 }
  })

  return (
    <animated.mesh
      scale={scale}
      onClick={() => setActive(!active)}
    >
      <boxGeometry />
      <animated.meshStandardMaterial color={color} />
    </animated.mesh>
  )
}
弹簧配置预设
import { useSpring, animated, config } from '@react-spring/three'

function SpringPresets() {
  const { position } = useSpring({
    position: [0, 2, 0],
    config: config.wobbly  // Presets: default, gentle, wobbly, stiff, slow, molasses
  })

  // Or custom config
  const { rotation } = useSpring({
    rotation: [0, Math.PI, 0],
    config: {
      mass: 1,
      tension: 170,
      friction: 26,
      clamp: false,
      precision: 0.01,
      velocity: 0,
    }
  })

  return (
    <animated.mesh position={position} rotation={rotation}>
      <boxGeometry />
      <meshStandardMaterial />
    </animated.mesh>
  )
}
多个弹簧
import { useSprings, animated } from '@react-spring/three'

function AnimatedBoxes({ count = 5 }) {
  const [springs, api] = useSprings(count, (i) => ({
    position: [i * 2 - count, 0, 0],
    scale: 1,
    config: { mass: 1, tension: 280, friction: 60 }
  }))

  const handleClick = (index) => {
    api.start((i) => {
      if (i === index) return { scale: 1.5 }
      return { scale: 1 }
    })
  }

  return springs.map((spring, i) => (
    <animated.mesh
      key={i}
      position={spring.position}
      scale={spring.scale}
      onClick={() => handleClick(i)}
    >
      <boxGeometry />
      <meshStandardMaterial color="orange" />
    </animated.mesh>
  ))
}
手势集成
import { useSpring, animated } from '@react-spring/three'
import { useDrag } from '@use-gesture/react'

function DraggableBox() {
  const [spring, api] = useSpring(() => ({
    position: [0, 0, 0],
    config: { mass: 1, tension: 280, friction: 60 }
  }))

  const bind = useDrag(({ movement: [mx, my], down }) => {
    api.start({
      position: down ? [mx / 100, -my / 100, 0] : [0, 0, 0]
    })
  })

  return (
    <animated.mesh {...bind()} position={spring.position}>
      <boxGeometry />
      <meshStandardMaterial color="hotpink" />
    </animated.mesh>
  )
}
链式动画
import { useSpring, animated, useChain, useSpringRef } from '@react-spring/three'

function ChainedAnimation() {
  const scaleRef = useSpringRef()
  const rotationRef = useSpringRef()

  const { scale } = useSpring({
    ref: scaleRef,
    from: { scale: 0 },
    to: { scale: 1 },
    config: { tension: 200, friction: 20 }
  })

  const { rotation } = useSpring({
    ref: rotationRef,
    from: { rotation: [0, 0, 0] },
    to: { rotation: [0, Math.PI * 2, 0] },
    config: { tension: 100, friction: 30 }
  })

  // Scale first (0-0.5), then rotation (0.5-1)
  useChain([scaleRef, rotationRef], [0, 0.5])

  return (
    <animated.mesh scale={scale} rotation={rotation}>
      <boxGeometry />
      <meshStandardMaterial color="cyan" />
    </animated.mesh>
  )
}

形态目标

在不同网格形状之间进行混合。

import { useGLTF } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'

function MorphingFace() {
  const { scene, nodes } = useGLTF('/models/face.glb')
  const meshRef = useRef()

  useFrame(({ clock }) => {
    const t = clock.getElapsedTime()

    // Access morph target influences
    if (meshRef.current?.morphTargetInfluences) {
      // Animate smile
      const smileIndex = meshRef.current.morphTargetDictionary['smile']
      meshRef.current.morphTargetInfluences[smileIndex] = (Math.sin(t) + 1) / 2
    }
  })

  return (
    <primitive ref={meshRef} object={nodes.Face} />
  )
}
受控形态目标
function MorphControls({ morphInfluences }) {
  const { nodes } = useGLTF('/models/face.glb')
  const meshRef = useRef()

  useFrame(() => {
    if (meshRef.current?.morphTargetInfluences) {
      Object.entries(morphInfluences).forEach(([name, value]) => {
        const index = meshRef.current.morphTargetDictionary[name]
        if (index !== undefined) {
          meshRef.current.morphTargetInfluences[index] = value
        }
      })
    }
  })

  return <primitive ref={meshRef} object={nodes.Face} />
}

// Usage
<MorphControls morphInfluences={{ smile: 0.5, blink: 1, angry: 0 }} />

骨骼动画

访问骨骼
import { useGLTF } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'

function SkeletalCharacter() {
  const { scene } = useGLTF('/models/character.glb')
  const headBoneRef = useRef()

  useEffect(() => {
    // Find skeleton
    scene.traverse((child) => {
      if (child.isSkinnedMesh) {
        const skeleton = child.skeleton
        const headBone = skeleton.bones.find(b => b.name === 'Head')
        headBoneRef.current = headBone
      }
    })
  }, [scene])

  // Animate bone
  useFrame(({ clock }) => {
    if (headBoneRef.current) {
      headBoneRef.current.rotation.y = Math.sin(clock.elapsedTime) * 0.3
    }
  })

  return <primitive object={scene} />
}
骨骼附件
function CharacterWithWeapon() {
  const { scene } = useGLTF('/models/character.glb')
  const weaponRef = useRef()
  const handBoneRef = useRef()

  useEffect(() => {
    scene.traverse((child) => {
      if (child.isSkinnedMesh) {
        const handBone = child.skeleton.bones.find(b => b.name === 'RightHand')
        if (handBone && weaponRef.current) {
          handBone.add(weaponRef.current)
          handBoneRef.current = handBone
        }
      }
    })

    return () => {
      // Cleanup
      if (handBoneRef.current && weaponRef.current) {
        handBoneRef.current.remove(weaponRef.current)
      }
    }
  }, [scene])

  return (
    <>
      <primitive object={scene} />
      <mesh ref={weaponRef} position={[0, 0, 0.5]}>
        <boxGeometry args={[0.1, 0.1, 1]} />
        <meshStandardMaterial color="gray" />
      </mesh>
    </>
  )
}

程序化动画模式

平滑阻尼
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import * as THREE from 'three'

function SmoothFollow({ target }) {
  const meshRef = useRef()
  const currentPos = useRef(new THREE.Vector3())

  useFrame((state, delta) => {
    // Lerp towards target
    currentPos.current.lerp(target, delta * 5)
    meshRef.current.position.copy(currentPos.current)
  })

  return (
    <mesh ref={meshRef}>
      <sphereGeometry args={[0.5]} />
      <meshStandardMaterial color="blue" />
    </mesh>
  )
}
弹簧物理(手动)
function SpringMesh({ target = 0 }) {
  const meshRef = useRef()
  const spring = useRef({
    position: 0,
    velocity: 0,
    stiffness: 100,
    damping: 10
  })

  useFrame((state, delta) => {
    const s = spring.current
    const force = -s.stiffness * (s.position - target)
    const dampingForce = -s.damping * s.velocity

    s.velocity += (force + dampingForce) * delta
    s.position += s.velocity * delta

    meshRef.current.position.y = s.position
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial color="green" />
    </mesh>
  )
}
振荡模式
function OscillatingMesh() {
  const meshRef = useRef()

  useFrame(({ clock }) => {
    const t = clock.elapsedTime

    // Sine wave
    meshRef.current.position.y = Math.sin(t * 2) * 0.5

    // Circular motion
    meshRef.current.position.x = Math.cos(t) * 2
    meshRef.current.position.z = Math.sin(t) * 2

    // Bouncing
    meshRef.current.position.y = Math.abs(Math.sin(t * 3)) * 2

    // Figure 8
    meshRef.current.position.x = Math.sin(t) * 2
    meshRef.current.position.z = Math.sin(t * 2) * 1
  })

  return (
    <mesh ref={meshRef}>
      <sphereGeometry args={[0.3]} />
      <meshStandardMaterial color="purple" />
    </mesh>
  )
}

Drei 动画辅助工具

Float
import { Float } from '@react-three/drei'

function FloatingObject() {
  return (
    <Float
      speed={1}            // Animation speed
      rotationIntensity={1} // Rotation intensity
      floatIntensity={1}   // Float intensity
      floatingRange={[-0.1, 0.1]} // Range of y-axis float
    >
      <mesh>
        <boxGeometry />
        <meshStandardMaterial color="gold" />
      </mesh>
    </Float>
  )
}
MeshWobbleMaterial / MeshDistortMaterial
import { MeshWobbleMaterial, MeshDistortMaterial } from '@react-three/drei'

function WobblyMesh() {
  return (
    <mesh>
      <torusKnotGeometry args={[1, 0.4, 100, 16]} />
      <MeshWobbleMaterial
        factor={1}     // Wobble amplitude
        speed={2}      // Wobble speed
        color="hotpink"
      />
    </mesh>
  )
}

function DistortedMesh() {
  return (
    <mesh>
      <sphereGeometry args={[1, 64, 64]} />
      <MeshDistortMaterial
        distort={0.5}  // Distortion amount
        speed={2}      // Animation speed
        color="cyan"
      />
    </mesh>
  )
}
Trail
import { Trail } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'

function TrailingMesh() {
  const meshRef = useRef()

  useFrame(({ clock }) => {
    const t = clock.elapsedTime
    meshRef.current.position.x = Math.sin(t) * 3
    meshRef.current.position.y = Math.cos(t * 2) * 2
  })

  return (
    <Trail
      width={2}
      length={8}
      color="hotpink"
      attenuation={(t) => t * t}
    >
      <mesh ref={meshRef}>
        <sphereGeometry args={[0.2]} />
        <meshStandardMaterial color="white" />
      </mesh>
    </Trail>
  )
}

使用 Zustand 状态制作动画

import { create } from 'zustand'
import { useFrame } from '@react-three/fiber'

const useStore = create((set) => ({
  isAnimating: false,
  speed: 1,
  toggleAnimation: () => set((state) => ({ isAnimating: !state.isAnimating })),
  setSpeed: (speed) => set({ speed })
}))

function AnimatedMesh() {
  const meshRef = useRef()
  const { isAnimating, speed } = useStore()

  useFrame((state, delta) => {
    if (isAnimating) {
      meshRef.current.rotation.y += delta * speed
    }
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial color="orange" />
    </mesh>
  )
}

// UI Component
function Controls() {
  const { toggleAnimation, setSpeed } = useStore()

  return (
    <div>
      <button onClick={toggleAnimation}>Toggle</button>
      <input
        type="range"
        min="0"
        max="5"
        step="0.1"
        onChange={(e) => setSpeed(parseFloat(e.target.value))}
      />
    </div>
  )
}

状态管理性能

动画中实现高性能状态管理的关键模式。

在 useFrame 中使用 getState()

在 useFrame 中使用 getState() 而非 Hook,以消除订阅开销:

import { create } from 'zustand'

const useGameStore = create((set) => ({
  playerPosition: [0, 0, 0],
  targetPosition: [0, 0, 0],
  setPlayerPosition: (pos) => set({ playerPosition: pos }),
}))

function Player() {
  const meshRef = useRef()

  useFrame((state, delta) => {
    // ✅ GOOD: getState() has no subscription overhead
    const { targetPosition } = useGameStore.getState()

    // Lerp towards target
    meshRef.current.position.lerp(
      new THREE.Vector3(...targetPosition),
      delta * 5
    )
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial color="blue" />
    </mesh>
  )
}
瞬时订阅

订阅状态变化而不触发 React 重新渲染:

import { useEffect, useRef } from 'react'

function Enemy() {
  const meshRef = useRef()

  useEffect(() => {
    // Subscribe directly - updates mesh without re-rendering component
    const unsub = useGameStore.subscribe(
      (state) => state.playerPosition,
      (playerPos) => {
        // Look at player (runs on every state change, no re-render)
        meshRef.current.lookAt(...playerPos)
      }
    )
    return unsub
  }, [])

  return (
    <mesh ref={meshRef}>
      <coneGeometry args={[0.5, 1, 4]} />
      <meshStandardMaterial color="red" />
    </mesh>
  )
}
使用 Shallow 进行选择性订阅

高效订阅多个值:

import { shallow } from 'zustand/shallow'

function HUD() {
  // Only re-renders when health OR score actually changes
  const { health, score } = useGameStore(
    (state) => ({ health: state.health, score: state.score }),
    shallow
  )

  return (
    <Html>
      <div>Health: {health}</div>
      <div>Score: {score}</div>
    </Html>
  )
}

// For single values, no shallow needed
const health = useGameStore((state) => state.health)
隔离动画组件

将依赖状态的 UI 与动画 3D 对象分开:

// ❌ BAD: Parent re-renders cause animation jank
function BadPattern() {
  const [score, setScore] = useState(0)
  const meshRef = useRef()

  useFrame((_, delta) => {
    meshRef.current.rotation.y += delta  // Affected by score re-renders
  })

  return (
    <>
      <mesh ref={meshRef}>...</mesh>
      <ScoreDisplay score={score} />
    </>
  )
}

// ✅ GOOD: Isolated animation component
function GoodPattern() {
  return (
    <>
      <AnimatedMesh />      {/* Never re-renders from score */}
      <ScoreDisplay />      {/* Has its own state subscription */}
    </>
  )
}

function AnimatedMesh() {
  const meshRef = useRef()

  useFrame((_, delta) => {
    meshRef.current.rotation.y += delta  // Smooth, uninterrupted
  })

  return <mesh ref={meshRef}>...</mesh>
}

function ScoreDisplay() {
  const score = useGameStore((state) => state.score)
  return <Html><div>Score: {score}</div></Html>
}

性能提示

  1. 隔离动画组件:只有动画网格会重新渲染
  2. 优先使用 refs 而非状态:避免动画触发 React 重新渲染
  3. 限制高开销计算的频率:使用 delta 累积
  4. 暂停屏幕外动画:检查可见性
  5. 共享动画剪辑:多个实例使用同一个剪辑
// Isolate animation to prevent parent re-renders
function Scene() {
  return (
    <>
      <StaticMesh />   {/* Never re-renders */}
      <AnimatedMesh /> {/* Only this updates */}
    </>
  )
}

// Throttle expensive operations
function ThrottledAnimation() {
  const meshRef = useRef()
  const accumulated = useRef(0)

  useFrame((state, delta) => {
    accumulated.current += delta

    // Only update every 100ms
    if (accumulated.current > 0.1) {
      // Expensive calculation here
      accumulated.current = 0
    }

    // Cheap operations every frame
    meshRef.current.rotation.y += delta
  })
}

另请参阅

  • r3f-loaders - 加载动画 GLTF 模型
  • r3f-fundamentals - useFrame 和动画循环
  • r3f-shaders - 着色器中的顶点动画

评论

支持 Markdown