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