跳转到主要内容

09|动画系统

requestAnimationFrame vs GSAP,gsap.to/from/timeline,KeyframeTrack 关键帧动画,AnimationObjectGroup 共享动画,AnimationMixer 模型动画

前 8 课的动画都是在 requestAnimationFrame 循环里手动写位移和旋转。本节课引入 GSAP 补间动画和 Three.js 原生关键帧系统,让动画控制更优雅、更强大。

运行效果:动画系统演示

01 学习目标

本节课聚焦 Three.js 动画系统:

  • 理解 requestAnimationFrame 循环 vs GSAP 动画的区别
  • 掌握 gsap.to() / gsap.from() / gsap.timeline()
  • 学会用 GSAP 控制相机和物体的平滑移动
  • 掌握 AnimationMixer 播放模型动画
  • 学会关键帧动画(KeyframeTrack / AnimationClip)
  • 理解 AnimationObjectGroup 让多个对象共享动画

02 requestAnimationFrame vs GSAP

特性requestAnimationFrameGSAP
用途每帧逻辑(物理、LOD、渲染)补间动画(平滑过渡)
控制手动管理 delta、进度自动插值、缓动曲线
缓动需要自己实现内置 30+ 缓动函数
序列需要自己编排timeline 一行搞定
性能最优优秀(自动优化)

两者可以同时使用:rAF 做渲染循环,GSAP 做动画控制。

03 gsap.to() — 补间动画

import gsap from 'gsap'

/**
 * gsap.to() — 从当前状态动画到目标状态
 * 最常用的动画方法
 *
 * 关键参数:
 * - duration:动画时长(秒)
 * - ease:缓动曲线(如 "power2.out")
 * - repeat:重复次数(-1 = 无限)
 * - yoyo:来回播放
 * - delay:延迟开始
 * - onComplete / onStart:回调
 */
gsap.to(cube.position, {
  x: 3,
  duration: 1.5,
  ease: 'power2.inOut',
  repeat: -1,    // 无限重复
  yoyo: true,    // 来回播放
})

// 回调
gsap.to(cube.position, {
  x: 3,
  duration: 1,
  onComplete: () => console.log('动画完成'),
  onStart: () => console.log('动画开始'),
})

04 gsap.from() — 入场动画

/**
 * gsap.from() — 从指定状态动画到当前状态
 * 适合做入场动画
 *
 * 注意:from() 会立即应用初始值(immediateRender: true)
 */
gsap.from(cube.scale, {
  x: 0, y: 0, z: 0,
  duration: 1.5,
  ease: 'elastic.out(1, 0.3)',  // 弹性回弹
})

05 gsap.timeline() — 时间线

/**
 * gsap.timeline() — 时间线编排多个动画
 *
 * 位置参数(第三个参数):
 * - 绝对时间:1(第 1 秒)
 * - 相对时间:"+=" 前一个动画结束后 / "-=" 前一个动画结束前
 * - "<" 与前一个动画同时开始
 */
const tl = gsap.timeline({
  defaults: { duration: 0.8, ease: 'power2.out' },
  repeat: -1,
  yoyo: true,
})

// 阶段 1:向上跳
tl.to(group.position, { y: 2 })
  // 阶段 2:与上一动画同时开始,旋转 180°
  .to(group.rotation, { y: Math.PI }, '<')
  // 阶段 3:在阶段 2 结束前 0.4 秒就开始放大
  .to(group.scale, { x: 1.2, y: 1.2, z: 1.2 }, '-=0.4')

位置参数速查

参数含义
1第 1 秒
"+=0.5"前一个动画结束后 0.5s
"-=0.2"前一个动画结束前 0.2s
"<"与前一个动画同时开始
"<0.2"前一个动画开始后 0.2s

06 KeyframeTrack 关键帧动画

四种 Track 类型

/**
 * KeyframeTrack — 手动关键帧动画
 *
 * 四种类型:
 * - VectorKeyframeTrack:位置、缩放(三维向量)
 * - QuaternionKeyframeTrack:旋转(四元数)
 * - ColorKeyframeTrack:颜色(离散插值)
 * - NumberKeyframeTrack:数值(如透明度)
 */

// 位置关键帧(VectorKeyframeTrack)
// 原点 → 右上 → 左下 → 回原点
const positionKF = new THREE.VectorKeyframeTrack(
  '.position',
  [0, 1, 2, 3],
  [
    0, 0, 0,       // t=0: 原点
    5, 5, 0,       // t=1: 右上
    -5, -3, 0,     // t=2: 左下
    0, 0, 0,       // t=3: 回原点
  ],
)

// 旋转关键帧(四元数):0° → 180° → 0°
const xAxis = new THREE.Vector3(1, 0, 0)
const qInitial = new THREE.Quaternion().setFromAxisAngle(xAxis, 0)
const qFinal = new THREE.Quaternion().setFromAxisAngle(xAxis, Math.PI)

const quaternionKF = new THREE.QuaternionKeyframeTrack(
  '.quaternion',
  [0, 1, 2],
  [
    qInitial.x, qInitial.y, qInitial.z, qInitial.w,
    qFinal.x, qFinal.y, qFinal.z, qFinal.w,
    qInitial.x, qInitial.y, qInitial.z, qInitial.w,
  ],
)

// 颜色关键帧(离散插值):红 → 绿 → 蓝,瞬间切换
const colorKF = new THREE.ColorKeyframeTrack(
  '.material.color',
  [0, 1, 2],
  [1, 0, 0, 0, 1, 0, 0, 0, 1],
  THREE.InterpolateDiscrete,
)

// 透明度关键帧(线性插值):1 → 0 → 1,平滑过渡
const opacityKF = new THREE.NumberKeyframeTrack(
  '.material.opacity',
  [0, 1, 2],
  [1, 0, 1],
)

// 组合成动画片段
const clip = new THREE.AnimationClip('myAnimation', 3, [
  positionKF,
  quaternionKF,
  colorKF,
  opacityKF,
])

// 用 AnimationMixer 播放
const mixer = new THREE.AnimationMixer(cube)
const action = mixer.clipAction(clip)
action.play()

插值方式

插值效果适用场景
InterpolateLinear(默认)平滑过渡位置、透明度
InterpolateDiscrete瞬间切换颜色、离散状态
InterpolateSmooth平滑曲线需要更自然的过渡

07 AnimationObjectGroup 共享动画

/**
 * AnimationObjectGroup — 让多个对象共享同一个动画状态
 *
 * 核心价值:关键帧求值只做一次,结果广播给所有对象。
 * 对象越多,"广播"比起"重复求值"的优势越大。
 */
const animationGroup = new THREE.AnimationObjectGroup()

// 25 个立方体同时挂到 scene(渲染)和 animationGroup(共享动画)
for (let i = 0; i < 5; i++) {
  for (let j = 0; j < 5; j++) {
    const mesh = new THREE.Mesh(geometry, material)
    mesh.position.x = 32 - (16 * i)
    mesh.position.z = 32 - (16 * j)
    scene.add(mesh)
    animationGroup.add(mesh)  // 加入共享组
  }
}

// mixer 绑定到 group 而不是单个 mesh
const mixer = new THREE.AnimationMixer(animationGroup)
const action = mixer.clipAction(clip)
action.play()

性能对比

方案每帧开销
AnimationObjectGroup1 次求值 + 25 次属性写入
独立 Mixer × 2525 次求值 + 25 次属性写入

08 AnimationMixer 模型动画

/**
 * AnimationMixer — 播放模型自带的动画
 *
 * 基本流程:
 * 1. 创建 mixer
 * 2. 获取 clip
 * 3. 创建 action
 * 4. 播放
 * 5. 每帧更新
 */
async function loadAnimatedModel(scene: THREE.Scene) {
  const loader = new GLTFLoader()
  const gltf = await loader.loadAsync('/models/watch/diegoWatchAnimation4.gltf')
  const model = gltf.scene

  // 自动缩放居中
  const box = new THREE.Box3().setFromObject(model)
  const maxDim = Math.max(box.getSize().x, box.getSize().y, box.getSize().z)
  model.scale.setScalar(10 / maxDim)
  const newBox = new THREE.Box3().setFromObject(model)
  model.position.sub(newBox.getCenter(new THREE.Vector3()))

  scene.add(model)

  // 创建动画混合器
  if (gltf.animations.length > 0) {
    const mixer = new THREE.AnimationMixer(model)
    const action = mixer.clipAction(gltf.animations[0])
    action.setLoop(THREE.LoopRepeat, Infinity)
    action.play()
    return { mixer, model }
  }

  return { mixer: null, model }
}

AnimationAction 控制方法

方法作用说明
play()播放从当前位置继续播放
pause()暂停保持当前位置
stop()停止重置到初始状态
reset()重置回到第 0 帧
resetToLoopStart()重置到循环起点回到当前循环的起点

循环模式

模式效果说明
THREE.LoopOnce播放一次播完停止在最后一帧
THREE.LoopRepeat循环播放播完从头开始
THREE.LoopPingPong来回播放播完倒放回来
// 设置循环模式
action.setLoop(THREE.LoopRepeat, Infinity)  // 无限循环
action.setLoop(THREE.LoopOnce, 1)           // 只播放一次
action.setLoop(THREE.LoopPingPong, 3)       // 来回播放 3 次

时间缩放(timeScale)

mixer.timeScale = 0    // 暂停
mixer.timeScale = 1    // 恢复
mixer.timeScale = 0.5  // 慢放(0.5 倍速)
mixer.timeScale = 2    // 快进(2 倍速)
mixer.timeScale = -1   // 倒放

权重(weight)

// 设置动画权重(0-1)
action.weight = 0.5  // 50% 混合

// 多动画混合
const walkAction = mixer.clipAction(walkClip)
const runAction = mixer.clipAction(runClip)

walkAction.weight = 0.7  // 70% 走路
runAction.weight = 0.3   // 30% 跑步

walkAction.play()
runAction.play()

动画事件

// 监听动画完成事件
action.getMixer().addEventListener('finished', (event) => {
  console.log('动画播放完成:', event.action.getClip().name)
})

// 监听循环完成事件
action.getMixer().addEventListener('loop', (event) => {
  console.log('循环完成:', event.action.getClip().name)
})

BlendMode — 动画混合模式

模式效果适用场景
NormalAnimationBlendMode(默认)直接覆盖单一动画
AdditiveAnimationBlendMode叠加增量多动画组合(走路+挥手)
// Normal 模式:动画值直接覆盖目标属性
const clip1 = new THREE.AnimationClip('walk', 2, [walkTrack])

// Additive 模式:动画值是相对于参照姿态的偏移
const clip2 = new THREE.AnimationClip('wave', 1, [waveTrack], THREE.AdditiveAnimationBlendMode)

09 GSAP 缓动函数

缓动效果适用场景
"power1.out"默认,平滑减速通用
"power2.inOut"平滑加速减速移动过渡
"back.out(1.7)"回弹入场动画
"bounce.out"弹跳落地效果
"elastic.out(1, 0.3)"弹性强调效果
"none"线性匀速旋转

10 对比表格

概念说明关键点
gsap.to()补间动画从当前到目标
gsap.from()入场动画从指定到当前
gsap.timeline()时间线编排多个动画
KeyframeTrack关键帧手动定义时间+值
AnimationClip动画片段包含多条 Track
AnimationObjectGroup共享动画组广播同一动画到多个对象
AnimationMixer动画混合器播放 AnimationClip
BlendMode动画混合模式Normal(覆盖)/ Additive(叠加)
InterpolateDiscrete离散插值颜色瞬间切换
InterpolateLinear线性插值数值平滑过渡

11 代码实现要点

GSAP 动画控制

// 保存动画引用,方便控制
let currentAnim: gsap.core.Tween | null = null

currentAnim = gsap.to(cube.position, { x: 3, duration: 1 })

// 控制播放
currentAnim.pause()
currentAnim.play()
currentAnim.reverse()
currentAnim.kill()  // 销毁动画

重置动画状态

function killCurrent() {
  if (currentAnim) {
    currentAnim.kill()
    currentAnim = null
  }
  // 重置对象到初始状态
  cube.position.set(-3, 0.5, 0)
  cube.rotation.set(0, 0, 0)
  cube.scale.set(1, 1, 1)
}

12 API 速查

API用途
gsap.to(target, vars)补间动画
gsap.from(target, vars)入场动画
gsap.timeline(vars)时间线
new THREE.QuaternionKeyframeTrack(path, times, values)旋转关键帧
new THREE.ColorKeyframeTrack(path, times, values, interpolation)颜色关键帧
new THREE.NumberKeyframeTrack(path, times, values)数值关键帧
new THREE.AnimationClip(name, duration, tracks)动画片段
new THREE.AnimationObjectGroup()共享动画组
new THREE.AnimationMixer(root)动画混合器
mixer.clipAction(clip)获取动画动作
mixer.update(delta)更新动画状态

12 复盘自测

Q1:ColorKeyframeTrack 使用了 InterpolateDiscrete(离散插值),而 NumberKeyframeTrack 使用默认的 InterpolateLinear(线性插值)。如果把颜色也改成线性插值,视觉上会发生什么?为什么官方示例选择用离散插值处理颜色?

立方体会平滑渐变,经过中间色,像呼吸灯一样柔和过渡。而离散插值是不经过中间色的——颜色瞬间切换,像红绿灯。为什么官方示例选择用离散插值处理颜色?这个问题本身没有技术深度——两种插值方式的区别才是重点。

Q2:AnimationObjectGroup 让 25 个立方体共享同一个 AnimationMixer 的动画。如果不用 AnimationObjectGroup,而是给每个立方体单独创建一个 AnimationMixer,性能上有什么区别?在什么场景下性能差异会变得明显?

AnimationObjectGroup(共享一个 Mixer):每帧只做一次关键帧求值,结果广播给 25 个立方体,开销 = 1 次求值 + 25 次属性写入。独立 Mixer:每帧做 25 次求值 + 25 次属性写入。在对象数量大、关键帧密集、多条 Track 叠加、移动端场景下性能差异会很明显。AnimationObjectGroup 的核心价值是关键帧求值只做一次,结果广播给所有对象。

Q3:代码中 GSAP 动画和 KeyframeTrack 动画同时作用在立方体上。如果它们同时控制同一个属性(比如都控制 rotation.y),会发生什么?为什么?

视觉闪烁/抖动:每帧两个值交替覆盖,物体来回跳。一方完全被压制:如果执行顺序稳定,后执行的一方始终覆盖先执行的。项目中的正确做法:代码刻意让 GSAP 和 KeyframeTrack 作用于不同层级(GSAP 控制组,KeyframeTrack 控制单个立方体),利用场景图的变换链实现叠加。

Q4:AnimationClip 的 blendMode 有两个选项:NormalAnimationBlendMode(默认)和 AdditiveAnimationBlendMode。如果一个角色同时播放"走路"和"挥手"两个动画,用 Normal 模式会怎样?用 Additive 模式又会怎样?

Normal 模式(覆盖):两个动画争夺同一组骨骼,后执行的覆盖先执行的,结果:右臂要么走路、要么挥手,不会同时做到。Additive 模式(叠加):动画值是相对于参照姿态的偏移量,两个动画各自计算偏移然后叠加,结果:角色边走边挥手。下半身只受走路影响,上半身的右臂同时受走路摆动和挥手动作的叠加。

Q5:Three.js 的动画系统中,如果用固定的 delta(如 0.016)而不是真实的 deltaTime,动画会有什么问题?在什么情况下这种问题会变得不可接受?

固定 delta = 假装时间均匀流逝,但现实里帧率会波动。轻则动画速度不准,重则音画不同步、多人不同步、物理失真。Clock.getDelta() 是 Three.js 给的标准答案。在多人同步场景、音画同步、物理仿真、录制/回放、长时间运行的场景下是不可以被接受的。

13 大白话解释

GSAP 动画 vs requestAnimationFrame

requestAnimationFrame 像自己开车——每踩一脚油门(每帧更新一次位置),你需要自己算速度、算路线、算刹车。GSAP 像自动驾驶——你只需要告诉它"去哪"和"多久到",它自己算路线、算速度、算缓停。

两者可以同时用:rAF 做渲染循环(每帧必须做的事:渲染、物理、LOD),GSAP 做动画控制(平滑过渡、入场效果、时间线编排)。

Additive Animation BlendMode(叠加动画混合模式)

Normal 模式像替换:你告诉演员"现在摆这个姿势",演员直接摆出来,之前的姿势消失。

Additive 模式像叠加:你告诉演员"在现有基础上,右手再抬高一点",演员保留当前姿势,只在右手的基础上加一点变化。

另一种理解方式:

  • Normal 模式(替换):直接覆盖原图。你拍了一张站着的人,再拍一张坐着的,第二张直接把第一张盖掉,只能看到坐着。
  • Additive 模式(叠加):像两片透明胶片叠在一起。一张是"站着的人",另一张是"举手",叠在一起就是"站着举手"。

具体例子:

  • 走路动画(Normal):全身骨骼 → 站立 → 左脚前迈 → 右脚前迈 → 循环
  • 挥手动画(Additive):右臂 → 相对于当前姿势 +30° → -30° → 循环

如果两个都是 Normal,同时播放 → 骨骼被两个动画争夺,抖动。 一个 Normal + 一个 Additive → 走路决定全身姿态,挥手在右臂上叠加,边走边挥手。

AnimationObjectGroup 的"广播"机制

想象一个舞蹈老师教 25 个学生跳舞。

  • AnimationObjectGroup 方式:老师做一次示范,25 个学生同时跟着做。开销 = 1 次示范 + 25 次模仿。
  • 独立 Mixer 方式:老师分别给每个学生单独教一遍。开销 = 25 次示范 + 25 次模仿。

学生越多,“广播"的优势越大。这就是 AnimationObjectGroup 的核心价值。


本文是 Three.js 造物日记学习系列的第 9 篇笔记。课程评分:9.8/10。