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.
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 );} );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.propiedadnombreDelNodo.propiedad[indice]uuidDelNodo.propiedad.propiedad (sobre la propia raiz del mixer)Ejemplos reales de un modelo de personaje:
mixamorigHips.positionmixamorigLeftArm.quaternionCube.scaleMaterial.opacitymixamorigHead.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 accionmixer.timeScale = 0.25; // camara lenta globalmixer.timeScale = -1; // toda la escena hacia atrasY 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.
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.