Lesson 8 · Añadir un Mascota que Baila
Learn how to load animated 3D characters into your Three.js scene, discover what
animations are baked into a model, and switch between them at runtime.
Aprende a cargar personajes 3D animados, ver qué animaciones tiene el modelo y
cambiar entre ellas en tiempo real.
What you'll learn: GLTFLoader, AnimationMixer, listing &
selecting animations, crossfading, and custom procedural motion with Math.sin().
Lo que aprenderás: GLTFLoader, AnimationMixer, listar y seleccionar
animaciones, crossfade, y movimiento procedural con Math.sin().
A .glb file (GL Transmission Format Binary) bundles everything a 3D character needs into one file — the binary sibling of the text-based .gltf format. Think of it as a ZIP for 3D assets:
Where to find models: Create in Blender,
browse Sketchfab or
Poly Pizza for CC0 models.
The animatedSudoCat.glb used in this lesson is already in
docs/assets/models/.
Three.js ships a loader for glTF/GLB in its examples/jsm addons folder. Because this project uses a no-build importmap setup, you import it directly from the CDN:
// dancingCat.js
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load(
// path relative to index.html
'./assets/models/animatedSudoCat.glb',
// onLoad — called once when the file is fully parsed
(gltf) => {
scene.add(gltf.scene);
console.log('Loaded!', gltf);
},
// onProgress — called while downloading (optional)
(xhr) => {
const pct = (xhr.loaded / xhr.total * 100).toFixed(0);
console.log(`${pct}% loaded`);
},
// onError
(err) => console.error('Load failed:', err)
);
gltf object structure:
gltf.scene is the root Object3D you add to your scene.
gltf.animations is an array of
AnimationClip
objects — one per animation baked into the file.
Before you can play an animation you need to know what's inside the file.
Add this to your onLoad callback and open the browser console:
(gltf) => {
scene.add(gltf.scene);
// ── Inspect every clip ──────────────────────────────────
console.log(`Found ${gltf.animations.length} animation(s):`);
gltf.animations.forEach((clip, i) => {
console.log(
` [${i}] "${clip.name}"` +
` duration: ${clip.duration.toFixed(2)}s` +
` tracks: ${clip.tracks.length}`
);
});
// Example output:
// [0] "idle" duration: 2.40s tracks: 18
// [1] "dance" duration: 4.00s tracks: 18
// [2] "wave" duration: 1.60s tracks: 18
}
Each AnimationClip has a name, a duration
in seconds, and an array of tracks — one KeyframeTrack per bone
channel (position, quaternion, scale).
Can't open DevTools? You can render the list to the DOM too —
the live panel at the top of this page does exactly that.
Scroll up and look at Animation Inspector to see what clips are in
animatedSudoCat.glb.
AnimationMixer is the runtime engine that drives clips on a specific
object. It converts keyframe data into actual transforms every frame.
// 1. Create a mixer attached to the model root
const mixer = new THREE.AnimationMixer(gltf.scene);
// 2. Create an "action" from a clip
const clip = gltf.animations[0]; // first clip
const action = mixer.clipAction(clip);
// 3. Configure (optional)
action.loop = THREE.LoopRepeat; // LoopOnce | LoopPingPong | LoopRepeat
action.timeScale = 1.0; // playback speed (negative = reverse)
action.weight = 1.0; // 0 = invisible, 1 = full influence
// 4. Play it
action.play();
// 5. Advance the mixer in your animation loop
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta(); // seconds since last frame
mixer.update(delta); // THIS is what makes it move
renderer.render(scene, camera);
}
Always use clock.getDelta() — not a fixed 0.016.
A fixed delta drifts on fast/slow machines. Clock.getDelta()
measures real elapsed time so animations stay in sync with wall-clock time.
Hard-cutting between animations looks jarring. crossFadeTo() blends
the outgoing clip's pose into the incoming one over a transition time.
// Build a lookup map so you can switch by name
const actions = {};
gltf.animations.forEach((clip) => {
actions[clip.name] = mixer.clipAction(clip);
});
let currentAction = null;
function playAnimation(name, fadeDuration = 0.3) {
const next = actions[name];
if (!next || next === currentAction) return;
if (currentAction) {
// Smoothly blend from current into next
currentAction.crossFadeTo(next, fadeDuration, true);
}
next.reset(); // rewind to frame 0
next.play();
currentAction = next;
}
// Usage:
playAnimation('idle'); // start with idle
setTimeout(() => playAnimation('dance'), 3000); // switch after 3 s
The third argument to crossFadeTo(next, duration, warp)
controls whether the time scales also blend. true is usually
the right choice when the clips have different durations.
// Play "wave" once then return to "idle"
function playOnce(name, returnTo = 'idle', fadeDuration = 0.3) {
const action = actions[name];
if (!action) return;
action.loop = THREE.LoopOnce;
action.clampWhenFinished = true; // freeze on last frame
playAnimation(name, fadeDuration);
// mixer fires 'finished' when a LoopOnce action ends
mixer.addEventListener('finished', function onFinished(e) {
if (e.action === action) {
mixer.removeEventListener('finished', onFinished);
playAnimation(returnTo, fadeDuration);
}
});
}
You can blend multiple animations simultaneously by adjusting their
weight — useful for things like "idle + additive wave" or
"run + aim upper body":
// Play two clips at half influence each
const idleAction = mixer.clipAction(actions['idle'].getClip());
const waveAction = mixer.clipAction(actions['wave'].getClip());
idleAction.weight = 1.0;
waveAction.weight = 0.5; // wave "bleeds through" at 50%
idleAction.play();
waveAction.play();
// Gradually increase wave weight over 1 second
let t = 0;
function tick(delta) {
t = Math.min(t + delta, 1.0);
waveAction.weight = t; // 0 → 1 over 1 s
mixer.update(delta);
}
Three.js normalises weights automatically — you don't need them to sum to 1. A weight of 0 completely silences a clip while still advancing its internal clock.
Create docs/js/dancingCat.js with everything from the steps above:
/**
* dancingCat.js
* Loads animatedSudoCat.glb and provides animation controls.
*/
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { addAnimationCallback } from './scene.js';
const loader = new GLTFLoader();
const clock = new THREE.Clock();
let mixer = null;
let catGroup = null;
let actions = {};
let currentAction = null;
let proceduralOn = true;
/** Load the model and return a controls object. */
export async function loadDancingCat(scene) {
return new Promise((resolve, reject) => {
loader.load(
'./assets/models/animatedSudoCat.glb',
(gltf) => {
// ── Normalise size ──────────────────────────────────────
const cat = gltf.scene;
const box = new THREE.Box3().setFromObject(cat);
const sz = box.getSize(new THREE.Vector3());
const max = Math.max(sz.x, sz.y, sz.z);
if (max > 0) cat.scale.setScalar(1.5 / max);
// Centre on base
box.setFromObject(cat);
const center = box.getCenter(new THREE.Vector3());
cat.position.sub(center);
cat.position.y = -box.min.y;
// ── Group wrapper lets us move/rotate without disturbing baked anim ──
catGroup = new THREE.Group();
catGroup.add(cat);
catGroup.position.set(8, 0, 5);
scene.add(catGroup);
// ── Shadows ─────────────────────────────────────────────
cat.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
// ── Build action map ────────────────────────────────────
console.log(`animatedSudoCat has ${gltf.animations.length} animation(s):`);
mixer = new THREE.AnimationMixer(cat);
gltf.animations.forEach((clip, i) => {
console.log(` [${i}] "${clip.name}" ${clip.duration.toFixed(2)}s`);
actions[clip.name] = mixer.clipAction(clip);
});
// Auto-play the first clip
const firstName = gltf.animations[0]?.name;
if (firstName) playAnimation(firstName);
// Register frame callback
addAnimationCallback(danceUpdate);
resolve({
playAnimation,
playOnce,
getAnimationNames: () => Object.keys(actions),
setProceduralDance: (on) => { proceduralOn = on; },
setTimeScale: (s) => { if (currentAction) currentAction.timeScale = s; },
});
},
undefined,
(err) => { console.warn('Could not load cat:', err); reject(err); }
);
});
}
/** Crossfade to a named animation. */
export function playAnimation(name, fadeDuration = 0.3) {
const next = actions[name];
if (!next || next === currentAction) return;
if (currentAction) currentAction.crossFadeTo(next, fadeDuration, true);
next.reset();
next.play();
currentAction = next;
}
/** Play a clip once then return to another. */
export function playOnce(name, returnTo, fadeDuration = 0.3) {
const action = actions[name];
if (!action) return;
action.loop = THREE.LoopOnce;
action.clampWhenFinished = true;
playAnimation(name, fadeDuration);
mixer.addEventListener('finished', function cb(e) {
if (e.action === action) {
mixer.removeEventListener('finished', cb);
if (returnTo) playAnimation(returnTo, fadeDuration);
}
});
}
/** Called every frame by the scene loop. */
function danceUpdate(elapsed) {
if (!catGroup) return;
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
if (!proceduralOn) return;
// Bobbing (Y axis, sine wave)
catGroup.position.y = Math.sin(elapsed * 2.5) * 0.08;
// Side-to-side rotation
catGroup.rotation.y = -Math.PI / 4 + Math.sin(elapsed * 1.2) * 0.15;
// Breathing scale pulse
const b = 1 + Math.sin(elapsed * 3) * 0.02;
catGroup.scale.set(b, b, b);
}
import { initScene, animate } from './scene.js';
import { parseCSV } from './csvParser.js';
import { buildVisualization } from './visualization.js';
import { loadDancingCat } from './dancingCat.js';
async function main() {
const scene = initScene();
try {
const data = await parseCSV('./data/your-data.csv');
await buildVisualization(scene, data);
// loadDancingCat resolves to a controls object
const cat = await loadDancingCat(scene).catch((e) => {
console.warn('Cat unavailable:', e);
return null;
});
// Optional: wire up UI buttons after load
if (cat) {
document.querySelectorAll('[data-anim]').forEach((btn) => {
btn.addEventListener('click', () => {
cat.playAnimation(btn.dataset.anim);
});
});
}
} catch (err) {
console.error('Init error:', err);
}
animate();
}
main();
Even without baked animations, you can drive motion with math.
The danceUpdate function runs ~60× per second:
// elapsed = seconds since the page loaded (from the scene loop)
// ── Bobbing ────────────────────────────────────────────────────────
// Math.sin() returns a smooth wave between -1 and +1.
// * 2.5 → frequency (higher = faster oscillation)
// * 0.08 → amplitude (how far it travels in world units)
catGroup.position.y = Math.sin(elapsed * 2.5) * 0.08;
// ── Side-to-side rotation ──────────────────────────────────────────
// Start at -45° (-Math.PI/4), wobble ±0.15 radians (~9°)
catGroup.rotation.y = -Math.PI / 4 + Math.sin(elapsed * 1.2) * 0.15;
// ── Breathing ─────────────────────────────────────────────────────
// Scale oscillates between 0.98 and 1.02 (±2%)
const b = 1 + Math.sin(elapsed * 3) * 0.02;
catGroup.scale.set(b, b, b);
Experiment with the values above to change how the cat moves:
2.5 → 5.00.08 → 0.2catGroup.position.x = Math.sin(elapsed) * 0.5;catGroup.rotation.y = elapsed * 2;if (Math.sin(elapsed*4) > 0.9) catGroup.position.y = 0.3;// Coordinate system: X = right, Y = up, Z = toward camera
// Near Spanish pedestals (left side of plaza)
catGroup.position.set(-8, 0, 5);
// Near Chinese pedestals (right side)
catGroup.position.set(8, 0, 5);
// Centre stage
catGroup.position.set(0, 0, 8);
// On top of a pedestal (pedestal height ≈ 5 units for highest bar)
catGroup.position.set(-6.2, 5.0, 0);
// Change facing direction
catGroup.rotation.y = -Math.PI / 4; // -45° (faces the plaza centre)
catGroup.rotation.y = 0; // faces +X
catGroup.rotation.y = Math.PI; // faces -X (toward camera)
docs/assets/models/.loadDancingCat():
loader.load('./assets/models/your-model.glb', ...);
playAnimation() calls to use the new names.
License check: Look for CC0, Public Domain, or Royalty-Free.
Sketchfab shows the license on each model's page before download.
Verificación de licencia: Busca CC0, Dominio Público o Libre de Regalías.
¡Felicidades! Congratulations! You can now load a .glb, inspect every animation it contains, play them by name, crossfade between them, and layer procedural motion on top. Your mascot is ready to dance!