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:
<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>
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.
<!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>