Animation Groups

Animation Groups for Coordinated Playback

An AnimationGroup plays animations on several targets as one clip: addTargetedAnimation(animation, target), then play(loop), pause(), stop(), goToFrame(), normalize(from, to) to align lengths, a mask to include or exclude targets, and end and loop observables. The glTF loader makes each glTF animation a group, as the Khronos sample Fox of GLTFLoader shows:

The fox's glTF animations arrive as animation groupsJavaScript
const fox = await BABYLON.LoadAssetContainerAsync('fox.glb', scene);
for (const group of fox.animationGroups) {
  console.log(`${group.name}: frames ${group.from} to ${Math.round(group.to)}, ` +
    `${group.targetedAnimations.length} bone animations, playing ${group.isPlaying === true}`);
}
const [survey, walk] = fox.animationGroups;
survey.stop();                                   // the loader started the first group
walk.start(true, 1.5);                           // loop at 1.5 times speed
Output
Survey: frames 0 to 205, 21 bone animations, playing true
Walk: frames 0 to 42, 21 bone animations, playing false
Run: frames 0 to 69, 21 bone animations, playing false

Seconds become frames at 60 fps (Walk's 0.71 s is 42.5 frames), and the loader starts the first group unless animationStartMode says otherwise. ImportAnimationsAsync retargets another file's groups onto existing nodes by name, so one set of clips serves several characters.

Two AnimationGroups, Survey and Walk, each driving several parts of a box-built fox as one clipHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 300px; display: block; touch-action: none; outline: none; }
  #bar { max-width: 600px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 6px 8px; } #info { font-family: monospace; white-space: pre; } button { font: inherit; padding: 3px 10px; }
</style>
<canvas id="view"></canvas>
<div id="bar"><button id="survey">Survey</button><button id="walk">Walk x1.5</button><button id="pause">Pause</button></div><div id="info" style="padding: 0 8px"></div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
// The book loads the Khronos Fox sample, whose clips arrive as animation groups. This sandbox
// cannot download it, so a fox is built from boxes and its two clips are grouped by hand.
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#f7f3ecff');
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2 - 0.9, 1.2, 4.2, new BABYLON.Vector3(0, 0.55, 0), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.5), scene);
const orange = new BABYLON.StandardMaterial('fur', scene);
orange.diffuseColor = BABYLON.Color3.FromHexString('#d9722b');
const dark = new BABYLON.StandardMaterial('paws', scene);
dark.diffuseColor = BABYLON.Color3.FromHexString('#3b2a20');
const ground = BABYLON.MeshBuilder.CreateGround('ground', { width: 6, height: 6 }, scene);
ground.material = new BABYLON.StandardMaterial('grass', scene);
ground.material.diffuseColor = BABYLON.Color3.FromHexString('#cfd8b4');

const part = (name, size, position, parent, material = orange) => {
  const mesh = BABYLON.MeshBuilder.CreateBox(name, size, scene);
  mesh.position.copyFrom(position);
  mesh.parent = parent;
  mesh.material = material;
  return mesh;
};
const V = (x, y, z) => new BABYLON.Vector3(x, y, z);
const body = part('body', { width: 0.35, height: 0.3, depth: 0.9 }, V(0, 0.55, 0), null);
const head = part('head', { width: 0.3, height: 0.28, depth: 0.32 }, V(0, 0.22, -0.52), body);
part('snout', { width: 0.12, height: 0.1, depth: 0.18 }, V(0, -0.05, -0.22), head, dark);
[-0.09, 0.09].forEach((x) => part('ear', { width: 0.08, height: 0.14, depth: 0.05 }, V(x, 0.2, 0.02), head));
const tail = new BABYLON.TransformNode('tailPivot', scene);
tail.parent = body;
tail.position.set(0, 0.05, 0.45);
part('tail', { width: 0.14, height: 0.14, depth: 0.55 }, V(0, 0.1, 0.25), tail).rotation.x = -0.5;
const legs = [[-0.12, -0.35], [0.12, -0.35], [-0.12, 0.35], [0.12, 0.35]].map(([x, z], i) => {
  const hip = new BABYLON.TransformNode('hip' + i, scene);   // the pivot at the top of the leg
  hip.parent = body;
  hip.position.set(x, -0.12, z);
  part('leg' + i, { width: 0.09, height: 0.42, depth: 0.09 }, V(0, -0.2, 0), hip, dark);
  return hip;
});

const anim = (name, property, keys) => {             // a looping float animation at 60 fps
  const a = new BABYLON.Animation(name, property, 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT,
    BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
  a.setKeys(keys.map(([frame, value]) => ({ frame, value })));
  return a;
};
// Survey: the head looks around and the tail swishes, 3 s long
const survey = new BABYLON.AnimationGroup('Survey', scene);
survey.addTargetedAnimation(anim('look', 'rotation.y', [[0, 0], [60, 0.7], [120, -0.7], [180, 0]]), head);
survey.addTargetedAnimation(anim('swish', 'rotation.y', [[0, -0.4], [45, 0.4], [90, -0.4], [135, 0.4], [180, -0.4]]), tail);
// Walk: legs swing in diagonal pairs and the body bobs, 0.71 s = 42.5 frames
const walk = new BABYLON.AnimationGroup('Walk', scene);
legs.forEach((hip, i) => {
  const sign = i === 0 || i === 3 ? 1 : -1;
  walk.addTargetedAnimation(anim('swing' + i, 'rotation.x', [[0, 0.5 * sign], [21.25, -0.5 * sign], [42.5, 0.5 * sign]]), hip);
});
walk.addTargetedAnimation(anim('bob', 'position.y', [[0, 0.55], [10.6, 0.58], [21.25, 0.55], [31.9, 0.58], [42.5, 0.55]]), body);
walk.addTargetedAnimation(anim('tailUp', 'rotation.x', [[0, -0.2], [42.5, -0.2]]), tail);

const info = document.getElementById('info');
const report = () => {
  info.textContent = [survey, walk].map((group) => `${group.name.padEnd(7)} frames ${group.from} to ` +
    `${group.to}, ${group.targetedAnimations.length} animations, playing ${group.isPlaying === true}`).join('\n');
};
document.getElementById('survey').onclick = () => { walk.stop(); survey.start(true); report(); };
document.getElementById('walk').onclick = () => { survey.stop(); walk.start(true, 1.5); report(); };   // loop, 1.5x speed
document.getElementById('pause').onclick = () => { survey.pause(); walk.pause(); report(); };
walk.start(true, 1.5);
report();
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>