Skip to main content

09 | Animation System

requestAnimationFrame vs GSAP, gsap.to/from/timeline, KeyframeTrack, AnimationObjectGroup, AnimationMixer model animation

In the first 8 lessons, all animations were manually written in the requestAnimationFrame loop. This lesson introduces GSAP tweening and Three.js native keyframe system for more elegant and powerful animation control.

Demo: Animation System

01 Learning Objectives

This lesson focuses on Three.js animation system:

  • Understand requestAnimationFrame loop vs GSAP animation differences
  • Master gsap.to() / gsap.from() / gsap.timeline()
  • Learn to use GSAP for smooth camera and object movement
  • Master AnimationMixer for model animation playback
  • Learn keyframe animation (KeyframeTrack / AnimationClip)
  • Understand AnimationObjectGroup for shared animation across multiple objects

02 requestAnimationFrame vs GSAP

FeaturerequestAnimationFrameGSAP
PurposePer-frame logic (physics, LOD, rendering)Tweening (smooth transitions)
ControlManual delta, progress managementAuto interpolation, easing curves
EasingMust implement yourself30+ built-in easing functions
SequencingMust orchestrate yourselftimeline in one line
PerformanceOptimalExcellent (auto-optimized)

Both can be used simultaneously: rAF for render loop, GSAP for animation control.

03 gsap.to() — Tweening

import gsap from 'gsap'

/**
 * gsap.to() — Animate from current state to target state
 * Most commonly used animation method
 */
gsap.to(cube.position, {
  x: 3,
  duration: 1.5,
  ease: 'power2.inOut',
  repeat: -1,    // Infinite repeat
  yoyo: true,    // Play back and forth
})

04 gsap.from() — Entry Animation

/**
 * gsap.from() — Animate from specified state to current state
 * Good for entry animations
 *
 * Note: from() applies initial values immediately (immediateRender: true)
 */
gsap.from(cube.scale, {
  x: 0, y: 0, z: 0,
  duration: 1.5,
  ease: 'elastic.out(1, 0.3)',  // Elastic bounce
})

05 gsap.timeline() — Timeline

/**
 * gsap.timeline() — Orchestrate multiple animations on a timeline
 *
 * Position parameters (third argument):
 * - Absolute: 1 (at 1 second)
 * - Relative: "+=" after previous / "-=" before previous ends
 * - "<" start with previous
 */
const tl = gsap.timeline({
  defaults: { duration: 0.8, ease: 'power2.out' },
  repeat: -1,
  yoyo: true,
})

tl.to(group.position, { y: 2 })
  .to(group.rotation, { y: Math.PI }, '<')
  .to(group.scale, { x: 1.2, y: 1.2, z: 1.2 }, '-=0.4')

06 KeyframeTrack Keyframe Animation

Four Track Types

// Position keyframe (VectorKeyframeTrack)
// Origin → top-right → bottom-left → back to origin
const positionKF = new THREE.VectorKeyframeTrack(
  '.position',
  [0, 1, 2, 3],
  [
    0, 0, 0,       // t=0: origin
    5, 5, 0,       // t=1: top-right
    -5, -3, 0,     // t=2: bottom-left
    0, 0, 0,       // t=3: back to origin
  ],
)

// Rotation keyframe (quaternion): 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,
  ],
)

// Color keyframe (discrete): red → green → blue, instant switch
const colorKF = new THREE.ColorKeyframeTrack(
  '.material.color',
  [0, 1, 2],
  [1, 0, 0, 0, 1, 0, 0, 0, 1],
  THREE.InterpolateDiscrete,
)

// Opacity keyframe (linear): 1 → 0 → 1, smooth transition
const opacityKF = new THREE.NumberKeyframeTrack(
  '.material.opacity',
  [0, 1, 2],
  [1, 0, 1],
)

// Combine into AnimationClip
const clip = new THREE.AnimationClip('myAnimation', 3, [
  positionKF,
  quaternionKF,
  colorKF,
  opacityKF,
])

// Play with AnimationMixer
const mixer = new THREE.AnimationMixer(cube)
const action = mixer.clipAction(clip)
action.play()

Interpolation Modes:

InterpolationEffectUse Case
InterpolateLinear (default)Smooth transitionPosition, opacity
InterpolateDiscreteInstant switchColor, discrete states
InterpolateSmoothSmooth curveMore natural transitions

07 AnimationObjectGroup Shared Animation

/**
 * AnimationObjectGroup — Share animation state across multiple objects
 *
 * Core value: keyframe evaluation happens once, results broadcast to all objects.
 * The more objects, the greater the "broadcast" advantage over "repeated evaluation".
 */
const animationGroup = new THREE.AnimationObjectGroup()

for (let i = 0; i < 5; i++) {
  for (let j = 0; j < 5; j++) {
    const mesh = new THREE.Mesh(geometry, material)
    scene.add(mesh)
    animationGroup.add(mesh)  // Join shared group
  }
}

// Mixer binds to group, not individual mesh
const mixer = new THREE.AnimationMixer(animationGroup)

08 AnimationMixer Model Animation

/**
 * AnimationMixer — Play model's built-in animations
 */
async function loadAnimatedModel(scene: THREE.Scene) {
  const loader = new GLTFLoader()
  const gltf = await loader.loadAsync('/models/watch/diegoWatchAnimation4.gltf')
  const model = gltf.scene

  // Auto scale and center
  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)

  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 Control Methods:

MethodEffectDescription
play()PlayContinue from current position
pause()PauseStay at current position
stop()StopReset to initial state
reset()ResetGo back to frame 0
resetToLoopStart()Reset to loop startGo back to current loop’s start

Loop Modes:

ModeEffectDescription
THREE.LoopOncePlay onceStop at last frame
THREE.LoopRepeatLoopRestart from beginning
THREE.LoopPingPongPing-pongPlay then reverse
action.setLoop(THREE.LoopRepeat, Infinity)  // Infinite loop
action.setLoop(THREE.LoopOnce, 1)           // Play once
action.setLoop(THREE.LoopPingPong, 3)       // Ping-pong 3 times

Time Scale (timeScale):

mixer.timeScale = 0    // Pause
mixer.timeScale = 1    // Resume
mixer.timeScale = 0.5  // Slow motion (0.5x)
mixer.timeScale = 2    // Fast forward (2x)
mixer.timeScale = -1   // Reverse

Weight:

action.weight = 0.5  // 50% blend

// Multi-animation blend
const walkAction = mixer.clipAction(walkClip)
const runAction = mixer.clipAction(runClip)
walkAction.weight = 0.7  // 70% walk
runAction.weight = 0.3   // 30% run
walkAction.play()
runAction.play()

Animation Events:

// Listen for animation complete
action.getMixer().addEventListener('finished', (event) => {
  console.log('Animation finished:', event.action.getClip().name)
})

// Listen for loop complete
action.getMixer().addEventListener('loop', (event) => {
  console.log('Loop complete:', event.action.getClip().name)
})

BlendMode:

ModeEffectUse Case
NormalAnimationBlendMode (default)OverrideSingle animation
AdditiveAnimationBlendModeLayered offsetMulti-animation combo (walk+wave)
const clip1 = new THREE.AnimationClip('walk', 2, [walkTrack])
const clip2 = new THREE.AnimationClip('wave', 1, [waveTrack], THREE.AdditiveAnimationBlendMode)

09 GSAP Easing Functions

EasingEffectUse Case
"power1.out"Default, smooth decelerationGeneral
"power2.inOut"Smooth acceleration/decelerationMovement transitions
"back.out(1.7)"OvershootEntry animation
"bounce.out"BounceLanding effect
"elastic.out(1, 0.3)"ElasticEmphasis
"none"LinearConstant rotation

10 Comparison Table

| BlendMode | Animation blend mode | Normal (override) / Additive (layered) |

ConceptDescriptionKey Point
gsap.to()TweenFrom current to target
gsap.from()Entry animationFrom specified to current
gsap.timeline()TimelineOrchestrate multiple animations
KeyframeTrackKeyframeManually define time + values
AnimationClipAnimation clipContains multiple tracks
AnimationObjectGroupShared animation groupBroadcast same animation to multiple objects
AnimationMixerAnimation mixerPlay AnimationClip
InterpolateDiscreteDiscrete interpolationColor instant switch
InterpolateLinearLinear interpolationValue smooth transition

11 API Quick Reference

11 Code Implementation

GSAP Animation Control

let currentAnim: gsap.core.Tween | null = null
currentAnim = gsap.to(cube.position, { x: 3, duration: 1 })

currentAnim.pause()
currentAnim.play()
currentAnim.reverse()
currentAnim.kill()

Reset Animation State

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 Quick Reference

APIPurpose
gsap.to(target, vars)Tween animation
gsap.from(target, vars)Entry animation
gsap.timeline(vars)Timeline
new THREE.QuaternionKeyframeTrack(path, times, values)Rotation keyframe
new THREE.ColorKeyframeTrack(path, times, values, interpolation)Color keyframe
new THREE.NumberKeyframeTrack(path, times, values)Number keyframe
new THREE.AnimationClip(name, duration, tracks)Animation clip
new THREE.AnimationObjectGroup()Shared animation group
new THREE.AnimationMixer(root)Animation mixer
mixer.clipAction(clip)Get animation action
mixer.update(delta)Update animation state

12 Review Quiz

Q1: ColorKeyframeTrack uses InterpolateDiscrete while NumberKeyframeTrack uses InterpolateLinear. What happens if color also uses linear interpolation?

The cube would smoothly gradient through intermediate colors, like a breathing light. Discrete interpolation switches colors instantly, like a traffic light. Why does the official example use discrete for color? The question itself lacks technical depth — the distinction between interpolation modes is what matters.

Q2: AnimationObjectGroup lets 25 cubes share one AnimationMixer. What’s the performance difference vs individual Mixers?

Shared: 1 evaluation + 25 property writes per frame. Individual: 25 evaluations + 25 property writes. The difference becomes significant with many objects, dense keyframes, multiple tracks, or on mobile. Core value: evaluate once, broadcast to all.

Q3: If GSAP and KeyframeTrack both control the same property (e.g., rotation.y), what happens?

Visual flickering/jittering: two values alternate each frame. One completely suppressed: the later-executing one always overwrites. The code avoids this by having GSAP control the group and KeyframeTrack control individual cubes — different levels in the scene graph.

Q4: NormalAnimationBlendMode vs AdditiveAnimationBlendMode for “walking” + “waving” animations?

Normal (override): two animations fight for the same bones, only one wins. Additive (叠加): animations calculate offsets and combine. Result: character walks and waves simultaneously. Lower body only affected by walking, right arm affected by both.

Q5: What problems arise from using fixed delta (0.016) instead of real deltaTime?

Animation speed becomes inaccurate when frame rate fluctuates. In multiplayer sync, audio-video sync, physics simulation, or recording/playback scenarios, this becomes unacceptable. Clock.getDelta() is the standard solution.

13 Plain English Explanation

GSAP Animation vs requestAnimationFrame

requestAnimationFrame is like driving yourself — you need to calculate speed, route, and braking for every pedal press (every frame update). GSAP is like autopilot — you just tell it “where to go” and “how long to take,” and it calculates the route, speed, and smooth stop.

Both can be used simultaneously: rAF for the render loop (things that must happen every frame: rendering, physics, LOD), GSAP for animation control (smooth transitions, entry effects, timeline orchestration).

Additive Animation BlendMode

Normal mode is like replacement: you tell the actor “pose like this,” the actor does it directly, the previous pose disappears.

Additive mode is like layering: you tell the actor “on top of the current pose, raise your right hand a bit more,” the actor keeps the current pose and only adds a small change to the right hand.

Another way to understand it:

  • Normal mode (override): directly replaces the image. You take a photo of a standing person, then take a photo of a sitting person — the second photo completely covers the first, you can only see sitting.
  • Additive mode (layering): like two transparent films stacked together. One film shows “standing person,” another shows “raising hand,” stacked together becomes “standing and raising hand.”

If both are Normal, playing simultaneously → bones are fought over by two animations, causing jitter. One Normal + one Additive → walking determines the full body pose, waving is layered on the right arm, character walks and waves.

AnimationObjectGroup’s “Broadcast” Mechanism

Imagine a dance teacher teaching 25 students.

  • AnimationObjectGroup way: teacher demonstrates once, 25 students follow simultaneously. Cost: 1 demonstration + 25 imitations.
  • Individual Mixer way: teacher teaches each student separately. Cost: 25 demonstrations + 25 imitations.

The more students, the greater the “broadcast” advantage. This is the core value of AnimationObjectGroup.


This is the 9th article in the Three.js Creation Diary learning series. Course rating: 9.8/10.