Split whole-model animation into sub-model animations, control them independently

Given a single model (f.e. a GLTF model loaded with GLTFLoader) with a single AnimationClip in model.animations[0], here is how to split the single-animation into multiple animations that can be separately controlled. For example this allows controlling a head animation separately from the arms, or blending the head animation with another head animation from another model (with the same bones) separately from the arms, etc.

The single clip in model.animations[0] has all the tracks for all the animations of all mesh parts, and playing this with a single AnimationAction will move all parts of the loaded model in parallel.

We get animations when we load the model:

new GLTFLoader().load('foo.gltf', (model) => {
  console.log(model.animations)
})

model.animations is an array of AnimationClip objects. In many cases, there may be a single clip containing all the tracks for all model parts (for example an entire “walking” animation, which can be played entirely for the whole mesh):

  console.log(model.animations[0].tracks)

Normally you’d play that whole animation using an AnimationMixer something like so:

  const clip = model.animations[0]
  const mixer = new AnimationMixer(model.scene)
  mixer.clipAction(clip).play()

  // continusouly update the animation
  let lastTime = performance.now()
  requestAnimationFrame(function loop(now) {
    const delta = now - lastTime
    mixer.update(delta)
    lastTime = now
  })

To get separate animations, we can split the single clip into multiple clips, that way we can control them independently. Each AnimationClip is basically a list of tracks, and each track inside the clip is a single animation for a single property of the model, containing an array of time values (keyframes) and an array of value (the values that are being animated over time). For example, a clip contains a track for animating the rotation of a spine bone, a track for animating the rotation of a leg bone, etc.

Splitting a clip into multiple clips will be a process like so:

  const clip1 = model.animations[0] 
  const clip2 = new AnimationClip()

  // move one track from the first clip to the second
  const track = clip1.tracks.pop()
  clip2.tracks.push(track)
  clip2.resetDuration() // auto-calculate the duration of the second clip based on its tracks

Now we have two clips, the first clip1 has all the tracks except for one track that is now inside of clip2. We can imagine arbitrary logic to compartmentalize the tracks into separate AnimationClips based on the track .names.

Typically, tracks of an imported model are named after the bones they animate. For example if you have a bone named “left_arm_shoulder” in your model, then the animation clip tracks will contain a track named “left_arm_shoulder.quaternion” for the rotation of that left arm shoulder bone. So you can write logic to split tracks by scanning all the tracks, and plucking the ones you want for a specific part of the model based on the names (f.e. all tracks starting with “left_arm_” or “right_arm_” to control both arms separately from the rest of the model).

  const mainClip = model.animations[0] 
  const armClip = new AnimationClip()

  // move left arm tracks from the original clip to a new clip for the left arm animation.
  const armTracks = mainClip.tracks.filter(track => ['left_arm_', 'right_arm_'].includes(track.name))
  armClip.tracks.push(...armTracks)
  armClip.resetDuration() // auto-calculate the duration of the arm clip based on its tracks

We can continue to make an AnimationAction for each clip, and we only need a single mixer for all the actions:

  const mixer = new AnimationMixer(model.scene)
  const mainAction = mixer.clipAction(mainClip) // returns an AnimationAction wrapping mainClip (without any arm animation).
  const armAction = mixer.clipAction(armClip) // returns an AnimationAction wrapping armClip (with all the arm animation).

  mainAction.play()
  armAction.play()

Where this is really handy is loading an animation from another model with the same bones, and being able to blend a specific sub-portion of the model. For example, suppose we did similar to above and created armAction2 based on the arm animation of a second model, then we could set the weights of the arm actions to blend from one arm animation to another, while the rest of the model continues to have a single animation.

  const mainAction = mixer.clipAction(mainClip) // mainClip without any arm animation.
  const armAction = mixer.clipAction(armClip) // armClip with all the arm animation.
  armAction.weight = 1 // this animation will affect the model
  const armAction2 = mixer.clipAction(armClip2) // armClip2 with all the arm animation from a second model.
  armAction2.weight = 0 // this animation will not affect the model arms currently

  mainAction.play()
  armAction.play()
  armAction2.play()

Later, we can animate the weight of armAction to 0 and animate the weight of armAction2 to 1 in order to transition from one arm animation to the next as you’d typically do with whole-model animation. But with the above approach we can also do this for individual parts of a model:

  // over time, in an animation loop, animate the weights
  if (fadeToArm2 && armAction.weight > 0) {
    armAction.weight = Math.max(0, armAction.weight - 0.05)
    armAction2.weight = Math.min(1, armAction2.weight + 0.05)
  }

Something to note!

Make sure you split your clips into separate clips before calling mixer.clipAction(mainClip), or else all of the model animations will be cached within the mixer. If you split the main clip after you’ve already called clipAction, then the mixer will play the original animations that were in the mainClip at the time you called clipAction even if you subsequently split out any tracks, which may be confusing because the action for mainClip will be playing all animations, plus any new clip and clip action will be playing a subset of the same animations, effectively duplicating the weight of the animations that were split into a second clip (f.e. mainClip’s animation will include arms, and so will armClip, despite that arm tracks were removed from mainClip later).

Do not do this:

  const mainAction = mixer.clipAction(mainClip) // oops!!

  const armClip = new AnimationClip()

  // move left arm tracks from the original clip to a new clip for the left arm animation.
  const armTracks = mainClip.tracks.filter(track => ['left_arm_', 'right_arm_'].includes(track.name))
  armClip.tracks.push(...armTracks)
  armClip.resetDuration() // auto-calculate the duration of the arm clip based on its tracks

  const armAction = mixer.clipAction(armClip)

Do this:

  const armClip = new AnimationClip()

  // move left arm tracks from the original clip to a new clip for the left arm animation.
  const armTracks = mainClip.tracks.filter(track => ['left_arm_', 'right_arm_'].includes(track.name))
  armClip.tracks.push(...armTracks)
  armClip.resetDuration() // auto-calculate the duration of the arm clip based on its tracks

  const mainAction = mixer.clipAction(mainClip) // ok!
  const armAction = mixer.clipAction(armClip)
1 Like