wandres.dev
ANIMACIÓN DE MODELOS · AnimationMixer y clips

El modelo del sistema de animación: mixer, clip y action

Qué responsabilidad tiene cada una de las tres piezas, cómo se enlaza una pista con una propiedad de un objeto, y por qué el mixer necesita un delta y no un tiempo absoluto.

⏱ 18 min

El sistema de animación de Three.js tiene tres clases y confundir sus papeles es la causa de casi todos los problemas de este nivel. Un AnimationClip son datos y no tiene estado de reproducción. Un AnimationAction es una reproducción concreta de un clip sobre un objeto concreto. Y el AnimationMixer es quien mezcla y aplica el resultado. La separación es la misma que entre un fichero de audio, una pista sonando y la mesa de mezclas, y una vez la tienes clara todo lo demás encaja.

🎯 Al terminar esta lección sabrás
  • Asignar cada responsabilidad a la clase correcta del sistema.
  • Explicar cómo una pista de keyframes se enlaza con una propiedad concreta de un objeto.
  • Justificar por qué mixer.update recibe un delta y no un tiempo absoluto.
  • Reutilizar un mismo clip sobre varias instancias sin duplicar datos.

Las tres piezas

AnimationClip. Un nombre, una duración y un array de tracks. Cada pista dice qué propiedad de qué objeto cambia y con qué valores a lo largo del tiempo. Es inmutable en la práctica y no sabe nada de reproducción: no tiene un «tiempo actual» ni un «está sonando». Un clip cargado de un glTF vive en gltf.animations.

AnimationAction. El estado de una reproducción: en qué instante va, a qué velocidad, con qué peso, si está en bucle. Se obtiene siempre pidiéndosela al mixer, nunca con new. Un mismo clip puede tener varias acciones a la vez si se reproduce sobre objetos distintos.

AnimationMixer. El propietario. Se construye sobre un objeto raíz, mantiene la caché de acciones, resuelve los enlaces entre pistas y propiedades, y en cada update evalúa todas las acciones activas, mezcla sus resultados según sus pesos y escribe el valor final en cada propiedad.

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const gltf = await new GLTFLoader().loadAsync( '/modelos/soldado.glb' );
scene.add( gltf.scene );

// El mixer se ata a la raiz sobre la que se van a resolver las pistas.
const mixer = new THREE.AnimationMixer( gltf.scene );

// clipAction devuelve la accion, creandola si no existe y
// reutilizandola si ya la habias pedido para el mismo clip y raiz.
const idle = mixer.clipAction( gltf.animations[ 0 ] );
idle.play();

const reloj = new THREE.Timer();
reloj.connect( document );

renderer.setAnimationLoop( ( tiempo ) => {
  reloj.update( tiempo );
  mixer.update( reloj.getDelta() );
  renderer.render( scene, camera );
} );
ℹ️
clipAction cachea

Llamar dos veces a mixer.clipAction( mismoClip ) devuelve la misma acción, no dos. Si quieres saber si ya existe sin crearla, mixer.existingAction( clip ) devuelve la acción o null. Ese detalle importa cuando construyes una máquina de estados: pedir la acción en cada transición es barato y correcto.

Cómo se enlaza una pista con una propiedad

Este es el mecanismo que hace que un clip exportado de Blender encuentre los huesos correctos en Three.js, y entenderlo resuelve la clase entera de errores «la animación carga pero no se mueve nada».

Cada pista tiene un name que es una ruta de propiedad con esta gramática:

nombreDelNodo.propiedad
nombreDelNodo.propiedad[indice]
uuidDelNodo.propiedad
.propiedad                      (sobre la propia raiz del mixer)

Ejemplos reales de un modelo de personaje:

mixamorigHips.position
mixamorigLeftArm.quaternion
Cube.scale
Material.opacity
mixamorigHead.morphTargetInfluences[3]

Cuando llamas a play(), el mixer recorre las pistas, parsea cada nombre con PropertyBinding.parseTrackName, busca dentro del subárbol de su raíz un objeto cuyo name coincida, y crea un PropertyBinding que apunta directamente al array de destino. A partir de ahí, escribir cada frame es una escritura en memoria sin búsquedas.

De ahí sale la regla que rompe más animaciones: los nombres tienen que coincidir. Si renombras un hueso, si clonas el modelo con Object3D.clone() de forma que los nombres se dupliquen, o si el mixer se ata a una raíz que no contiene esos nodos, el enlace falla. Y falla en silencio: no hay excepción, simplemente esa pista no hace nada.

// Diagnostico rapido cuando "no se mueve nada":
gltf.animations[ 0 ].tracks.forEach( ( t ) => {
  const nodo = t.name.split( '.' )[ 0 ];
  const existe = gltf.scene.getObjectByName( nodo ) !== undefined;
  if ( ! existe ) console.warn( 'Pista huerfana:', t.name );
} );

Por qué un delta y no un tiempo

mixer.update( deltaEnSegundos ) recibe el tiempo transcurrido desde la última llamada, no el tiempo total. La diferencia parece menor y es fundamental.

El mixer mantiene su propio reloj interno, mixer.time, que avanza sumando los deltas multiplicados por mixer.timeScale. Ese reloj es el que usan los interpolantes de desvanecimiento y de warping para saber cuándo termina un fadeIn. Si le pasaras un tiempo absoluto, el mixer no podría aplicar su propia escala temporal ni pausar, porque no controlaría el avance.

Con deltas, en cambio, salen gratis tres cosas:

mixer.timeScale = 0;      // pausa TODO, sin tocar ninguna accion
mixer.timeScale = 0.25;   // camara lenta global
mixer.timeScale = -1;     // toda la escena hacia atras

Y también sale gratis el modo paso a paso: pasar un delta fijo en lugar del real avanza exactamente ese tanto.

El delta sale del reloj del bucle, el THREE.Timer que se montó arriba. Que esté conectado al documento importa más aquí que en ninguna otra parte de la escena, porque el mixer es el consumidor de delta más delicado que hay: es el único que además dispara eventos en función del tiempo que le pasas, y un evento perdido no se recupera en el cuadro siguiente.

⚠️
El delta gigante de la pestaña en segundo plano

Si el usuario cambia de pestaña treinta segundos y vuelve con un reloj que no se ha enterado de la pausa, el primer delta vale 30. El mixer avanza treinta segundos de golpe: las animaciones en bucle saltan a un punto arbitrario y las de un solo disparo terminan y lanzan su evento finished todas a la vez, en el mismo cuadro. reloj.connect( document ) cubre ese caso. La defensa que conviene añadir, porque cubre las pausas que no son de visibilidad —el depurador, una recolección de basura larga, una compilación de shader—, es acotar: mixer.update( Math.min( reloj.getDelta(), 0.1 ) ).

Varias instancias del mismo modelo

Un clip son datos y se puede compartir. Lo que no se puede compartir es el mixer, porque su enlace apunta a objetos concretos.

El error clásico es usar Object3D.clone() sobre un modelo con esqueleto: clona la jerarquía pero no reconstruye el vínculo entre el SkinnedMesh y sus huesos, así que todas las copias se deforman con el mismo esqueleto. La solución está en los addons:

import { clone } from 'three/addons/utils/SkeletonUtils.js';

const mixers = [];
for ( let i = 0; i < 20; i ++ ) {
  const copia = clone( gltf.scene );          // reconstruye el skinning
  copia.position.x = ( i - 10 ) * 2;
  scene.add( copia );

  const m = new THREE.AnimationMixer( copia ); // un mixer por instancia
  m.clipAction( gltf.animations[ 0 ] )         // el MISMO clip, compartido
   .startAt( Math.random() * 2 )               // desfase para que no vayan al unisono
   .play();
  mixers.push( m );
}

renderer.setAnimationLoop( ( tiempo ) => {
  reloj.update( tiempo );
  const d = reloj.getDelta();          // el mismo delta para los veinte mixers
  for ( const m of mixers ) m.update( d );
  renderer.render( scene, camera );
} );

Veinte mixers, veinte instancias, un solo clip en memoria. Los datos de keyframes, que son la parte pesada, no se duplican.

El mixer no interpola nada: la mezcla la hace el PropertyMixer, y por eso los cuaterniones no se rompen

Hay una sutileza en el corazón del sistema que explica por qué mezclar dos animaciones de rotación produce un resultado correcto en lugar de un objeto que se pliega sobre sí mismo. Cuando varias acciones afectan a la misma propiedad, no se puede simplemente promediar sus valores: para posiciones y escalas la media ponderada funciona, pero para cuaterniones no. El promedio componente a componente de dos cuaterniones unitarios no es un cuaternión unitario, y el resultado normalizado no corresponde a la rotación intermedia salvo para ángulos muy pequeños; con dos rotaciones separadas por más de noventa grados, el promedio pasa cerca del origen y la orientación resultante gira por el camino equivocado, produciendo el efecto de miembro roto que todo el mundo ha visto alguna vez. Three.js lo resuelve con una clase intermedia que casi nadie conoce, PropertyMixer, que tiene un buffer de acumulación por propiedad y un método de acumulación distinto según el tipo de valor: para valores numéricos hace la media ponderada directa, y para cuaterniones hace Quaternion.slerpFlat, es decir, interpolación esférica acumulativa sobre el arco más corto. El mixer detecta el tipo de la propiedad al crear el binding —mirando si el destino tiene cuatro componentes y el nombre acaba en quaternion— y elige la ruta correcta. Esto tiene dos consecuencias prácticas. La primera: los clips exportados a glTF usan cuaterniones y no ángulos de Euler, precisamente porque los Euler no se pueden mezclar de forma correcta ni interpolar sin gimbal lock; si tienes una animación en Euler, conviértela. La segunda: si escribes pistas a mano para una propiedad de rotación, tienes que usar QuaternionKeyframeTrack y no NumberKeyframeTrack con cuatro componentes, aunque los datos sean idénticos. El tipo de la pista es lo que le dice al mixer qué matemática aplicar, y elegir mal produce un resultado que se ve bien mientras solo hay una acción y se rompe en cuanto haces un crossfade.