AnimationMixer

Driving Clips with AnimationMixer

An AnimationMixer owns the playback for one root object. mixer.clipAction(clip) returns the clip's AnimationAction, which carries the state: play(), stop(), paused, time, timeScale, weight, and setLoop(THREE.LoopRepeat | LoopOnce | LoopPingPong, repetitions). Nothing moves until you advance the mixer with mixer.update(delta) in the render loop. The same pull-out clip, now on the covered shelf:

Playing the pull-out clip once and holding its last frameHTML
<script src="three-map.js"></script>
<script type="module">
import * as THREE from 'three';
import { createStage, createShelf, loadCovers } from './bookshelf.js';
const { renderer, scene, camera } = createStage({ width: 640, height: 190 });
scene.add(createShelf(await loadCovers()));
const book = scene.getObjectByName('Salt and Saffron');
const { x, y, z } = book.position;                     // keys start where the book stands
const clip = new THREE.AnimationClip('pull-out', -1, [
  new THREE.VectorKeyframeTrack('.position', [0, 1.2], [x, y, z, x, y + 0.12, z + 0.35]),
  new THREE.NumberKeyframeTrack('.rotation[x]', [0, 1.2], [0, -0.3]),
]);
const mixer = new THREE.AnimationMixer(book);
const action = mixer.clipAction(clip).setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;                       // hold the last frame, do not reset
action.play();
mixer.addEventListener('finished', () => { window.__done = true; });
const timer = new THREE.Timer();
renderer.setAnimationLoop(() => {
  timer.update();
  mixer.update(timer.getDelta());
  renderer.render(scene, camera);
});
</script>
Browser output of Listing 5.25
Browser output of 25

The mixer fires finished when a LoopOnce action ends and loop at each repetition, for chaining clips. Without clampWhenFinished the book would jump back onto the shelf. Call timer.update() without the loop's timestamp here: that timestamp can be older than the timer's creation, and the negative first delta ran this clip backward to its start in testing.

An AnimationMixer pulls Salt and Saffron off the shelf once and holds the last frameHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
  .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="ui"><button id="play">Pull out</button> <button id="back">Put back</button>
  timeScale <input type="range" id="speed" min="0.2" max="3" step="0.1" value="1"> <span id="state"></span></div>
<script type="module">
import * as THREE from 'three';

const BOOKS = [['The Quiet Harbor', '#1f5f8b'], ['Patterns of the Deep Web', '#5b3f99'], ['Salt and Saffron', '#e09a10'],
  ['Small Steps to Big Summits', '#3f7d3a'], ["The Clockmaker's Paradox", '#b5452f'], ['Gardens in Glass', '#2a9d8f']];
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 260);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 260, 0.1, 50);
camera.position.set(0.8, 0.5, 3.1);
camera.lookAt(0, -0.1, 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 plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.7), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
scene.add(plank);
// Books stand spine-out: 0.14 m thick, 0.62 m deep
BOOKS.forEach(([title, color], i) => {
  const book = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.9 + 0.05 * (i % 3), 0.62), new THREE.MeshStandardMaterial({ color }));
  book.name = title;
  book.position.set((i - 2.5) * 0.18, -0.49 + book.geometry.parameters.height / 2, 0);
  scene.add(book);
});

const book = scene.getObjectByName('Salt and Saffron');
const { x, y, z } = book.position;                          // keys start where the book stands
const clip = new THREE.AnimationClip('pull-out', -1, [
  new THREE.VectorKeyframeTrack('.position', [0, 1.2], [x, y, z, x, y + 0.12, z + 0.45]),
  new THREE.NumberKeyframeTrack('.rotation[x]', [0, 1.2], [0, -0.3]),
]);
const mixer = new THREE.AnimationMixer(book);
const action = mixer.clipAction(clip).setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;                            // hold the last frame instead of resetting
const state = document.getElementById('state');
mixer.addEventListener('finished', () => { state.textContent = `finished at time ${action.time.toFixed(2)} s`; });

function run(direction) {
  action.paused = false;
  action.timeScale = direction * +document.getElementById('speed').value;
  if (!action.isRunning()) action.play();
  if (direction < 0 && action.time === 0) action.time = clip.duration;
  state.textContent = direction > 0 ? 'pulling out…' : 'putting back…';
}
document.getElementById('play').onclick = () => { action.reset(); run(1); };
document.getElementById('back').onclick = () => run(-1);
action.play();

const timer = new THREE.Timer();
renderer.setAnimationLoop(() => {
  timer.update();
  mixer.update(timer.getDelta());                           // nothing moves until the mixer advances
  renderer.render(scene, camera);
});
</script>