Crossfading Actions

Blending and Crossfading Actions

Actions playing on one mixer blend by weight for every bound property. The fox's Walk and Run clips drive the same 24 bones; SkeletonUtils.clone() copies the fox with its own skeleton (a plain clone() would share bones), and the three copies play Walk and Run at weights 1 and 0, 0.5 and 0.5, and 0 and 1:

Walk, an even blend of Walk and Run, and Run on three cloned foxesHTML
<script src="three-map.js"></script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { clone } from 'three/addons/utils/SkeletonUtils.js';
import { createStage, renderFrames } from './bookshelf.js';
const { renderer, scene, camera } = createStage({ width: 640, height: 150 });
camera.position.set(0, 0.3, 1.45);
camera.lookAt(0, 0.2, 0);
const gltf = await new GLTFLoader().loadAsync('fox.glb');
const [, walk, run] = gltf.animations;                 // Survey, Walk, Run
[1, 0.5, 0].forEach((walkWeight, i) => {
  const fox = clone(gltf.scene);
  fox.scale.setScalar(0.005);
  fox.position.x = (i - 1) * 0.95;
  fox.rotation.y = -Math.PI / 2;                       // side view, heading left
  scene.add(fox);
  const mixer = new THREE.AnimationMixer(fox);
  mixer.clipAction(walk).setEffectiveWeight(walkWeight).play();
  mixer.clipAction(run).setEffectiveWeight(1 - walkWeight).play();
  mixer.update(0.3);                                   // the same moment for all three
});
renderFrames(renderer, scene, camera);
</script>
Walk, a blend and Run at mixer time 0.3 s. Fox model by PixelMannen (CC0), rigging and animation by tomkranis (CC BY 4.0), glTF conversion by @AsoboStudio and @scurest (CC BY 4.0), via the Khronos glTF Sample Assets
Walk, a blend and Run at mixer time 0.3 s. Fox model by PixelMannen (CC0), rigging and animation by tomkranis (CC BY 4.0), glTF conversion by @AsoboStudio and @scurest (CC BY 4.0), via the Khronos glTF Sample Assets

Over time, walkAction.crossFadeTo(runAction, 0.5, true) moves the weights across half a second, and warp (true) also matches the time scales so the strides agree. Additive clips (AnimationUtils.makeClipAdditive()) layer a gesture over any base pose.

Two clips blended by weight on three books, and a crossFadeTo between them on a fourthHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
  .labels { display: flex; width: 600px; max-width: 100%; text-align: center; }
  .labels div { flex: 1; }
  .ui { padding: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="labels"><div>sway 1, hop 0</div><div>sway 0.5, hop 0.5</div><div>sway 0, hop 1</div><div id="fadeLabel">crossfade: sway</div></div>
<div class="ui"><button id="fade">Crossfade</button></div>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 220);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(30, 600 / 220, 0.1, 50);
camera.position.set(0, 0.6, 4.4);
camera.lookAt(0, 0.35, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
const floor = new THREE.Mesh(new THREE.BoxGeometry(6, 0.04, 1), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
floor.position.y = -0.02;
scene.add(floor);

// Both clips drive the same two properties, so their weights blend them
const q = (z) => new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), z).toArray();
const sway = new THREE.AnimationClip('sway', 1, [
  new THREE.VectorKeyframeTrack('.position', [0, 0.5, 1], [0, 0.4, 0, 0, 0.44, 0, 0, 0.4, 0]),
  new THREE.QuaternionKeyframeTrack('.quaternion', [0, 0.5, 1], [...q(0.15), ...q(-0.15), ...q(0.15)]),
]);
const hop = new THREE.AnimationClip('hop', 1, [
  new THREE.VectorKeyframeTrack('.position', [0, 0.5, 1], [0, 0.4, 0, 0, 0.8, 0, 0, 0.4, 0]),
  new THREE.QuaternionKeyframeTrack('.quaternion', [0, 0.5, 1], [...q(0), ...q(Math.PI), ...q(2 * Math.PI)]),
]);

const colors = ['#1f5f8b', '#e09a10', '#b5452f', '#2a9d8f'];
const mixers = [];
const actions = colors.map((color, i) => {
  const book = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.8, 0.12), new THREE.MeshStandardMaterial({ color }));
  book.position.x = (i - 1.5) * 1.25;
  const group = new THREE.Group();                     // clips animate the book inside its slot
  group.position.x = book.position.x;
  book.position.x = 0;
  group.add(book);
  scene.add(group);
  const mixer = new THREE.AnimationMixer(book);
  mixers.push(mixer);
  const walkWeight = [1, 0.5, 0, 1][i];
  const a = mixer.clipAction(sway).setEffectiveWeight(walkWeight).play();
  const b = mixer.clipAction(hop).setEffectiveWeight(1 - walkWeight).play();
  return [a, b];
});

// The fourth book crossfades over half a second; warp matches the clips' time scales too
let onSway = true;
document.getElementById('fade').onclick = () => {
  const [a, b] = actions[3];
  const [from, to] = onSway ? [a, b] : [b, a];
  to.reset().setEffectiveWeight(1).play();
  from.crossFadeTo(to, 0.5, true);
  onSway = !onSway;
  document.getElementById('fadeLabel').textContent = `crossfade: ${onSway ? 'sway' : 'hop'}`;
};

const timer = new THREE.Timer();
renderer.setAnimationLoop(() => {
  timer.update();
  const dt = timer.getDelta();
  mixers.forEach((m) => m.update(dt));
  renderer.render(scene, camera);
});
</script>