← Lesson 7: Deploy & Share · All Lessons

🐱 Adding a Dancing Mascot

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().

Live Preview · animatedSudoCat.glb
drag to orbit
Loading model…

Animation Inspector & Controls

Loading animations…
Now playing: 1.0×

1

Understanding .glb Files · Entendiendo los Archivos .glb

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/.

2

Loading a .glb with GLTFLoader · Cargar .glb con GLTFLoader

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.

3

Listing All Animations in a Model · Listar Todas las Animaciones

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.

4

Playing Animations with AnimationMixer · Reproducir Animaciones

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.

5

Switching Animations with crossFadeTo · Cambiar Animaciones con crossFadeTo

Hard-cutting between animations looks jarring. crossFadeTo() blends the outgoing clip's pose into the incoming one over a transition time.

clip A
playing
crossfade
0.3 s
clip B
playing
// 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.

Playing a Clip Once Then Returning · Reproducir una Vez y Volver

// 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);
    }
  });
}
6

Layering Animations with Weights · Capas de Animaciones con Pesos

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.

7

Adding the Full dancingCat.js Module · El Módulo Completo

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);
}
8

Wiring It into main.js · Integrarlo en main.js

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();
9

Understanding the Procedural Dance · Entendiendo el Baile Procedural

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);

Try It! · ¡Pruébalo!

Experiment with the values above to change how the cat moves:

  • Faster bouncing: change 2.55.0
  • Bigger bounce: change 0.080.2
  • Figure-eight: add catGroup.position.x = Math.sin(elapsed) * 0.5;
  • Full spin: catGroup.rotation.y = elapsed * 2;
  • Jumps on beat: if (Math.sin(elapsed*4) > 0.9) catGroup.position.y = 0.3;
10

Positioning Your Mascot · Posicionar tu Mascota

// 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)
11

Finding & Replacing the Mascot Model · Encontrar tu Propio Mascota

  1. Find a model on Sketchfab or Poly Pizza (filter by "Animated" and "glTF").
  2. Download as .glb (Sketchfab: Download → Original or glTF).
  3. Drop the file into docs/assets/models/.
  4. Update the path in loadDancingCat():
    loader.load('./assets/models/your-model.glb', ...);
  5. Run the animation inspector code from Step 3 to see what clips are available, then update your 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!

← Lesson 7: Deploy & Share · All Lessons