Modos de bucle, el evento finished y la limpieza
Los tres modos de repetición y cómo interactúan con clampWhenFinished, los dos eventos que dispara el mixer con sus datos exactos, y la rutina de destrucción que evita fugas.
Una animación de un solo disparo tiene que decidir dos cosas al terminar: qué pose deja y a quién avisa. Three.js resuelve la primera con clampWhenFinished y la segunda con un evento en el mixer, y la interacción entre ambas es lo que permite encadenar animaciones sin temporizadores. Y como el mixer es el objeto que retiene referencias a todo el esqueleto, es también donde se produce la fuga de memoria más común de las aplicaciones con personajes.
- Configurar los tres modos de bucle con su número de repeticiones.
- Explicar qué hace
clampWhenFinishedy por qué cambia el estado final. - Escuchar los eventos
finishedyloopy leer sus datos. - Escribir una rutina de destrucción que no deje nada retenido.
Los tres modos
accion.setLoop( THREE.LoopRepeat, Infinity ); // por defecto
accion.setLoop( THREE.LoopOnce, 1 );
accion.setLoop( THREE.LoopPingPong, 4 );
setLoop( mode, repetitions ) asigna las dos propiedades a la vez. Los valores por defecto son loop = LoopRepeat y repetitions = Infinity.
LoopRepeat. Al llegar al final, time vuelve al principio. El número de vueltas lo da repetitions.
LoopOnce. Se reproduce una vez y para. La documentación del código aclara algo importante: “Setting this number has no effect if loop is set to LoopOnce”. Con LoopOnce, repetitions se ignora.
LoopPingPong. Va y vuelve. Internamente, en las vueltas impares el mixer devuelve duration - time, es decir, reproduce el clip invertido. Cada ida y cada vuelta cuentan como una repetición: repetitions = 4 son dos ciclos completos de ida y vuelta.
clampWhenFinished: qué pose queda
El estado en que termina una acción depende de un solo booleano, y el código lo dice sin ambigüedad:
// De AnimationAction._updateTime, cuando la accion termina:
if ( this.clampWhenFinished ) this.paused = true;
else this.enabled = false;
clampWhenFinished |
Estado final | Qué se ve |
|---|---|---|
false (defecto) |
enabled = false |
la acción deja de contribuir; el objeto vuelve a su pose de reposo |
true |
paused = true |
la acción sigue contribuyendo, congelada en el último frame |
Para una animación de «abrir una tapa» que debe quedarse abierta, clampWhenFinished = true es obligatorio. Sin él, la tapa se cierra de golpe en el instante en que termina la animación, que es un bug clásico y desconcertante.
const abrir = mixer.clipAction( clipAbrir );
abrir.setLoop( THREE.LoopOnce );
abrir.clampWhenFinished = true;
abrir.play();
Y hay una advertencia en la documentación que merece la pena leer literal: “This member has no impact if the action is interrupted”. Solo actúa si el último bucle termina de verdad. Si haces crossFadeTo a otra acción antes del final, clampWhenFinished no interviene.
Los dos eventos
AnimationMixer hereda de EventDispatcher y dispara dos tipos.
finished. Al terminar el último bucle. Sus datos son exactamente estos:
mixer.addEventListener( 'finished', ( e ) => {
e.action; // la AnimationAction que ha terminado
e.direction; // 1 si iba hacia delante, -1 si hacia atras
} );
loop. Cada vez que una acción da la vuelta sin ser la última:
mixer.addEventListener( 'loop', ( e ) => {
e.action; // la accion que ha ciclado
e.loopDelta; // cuantas vueltas ha dado en este update, con signo
} );
loopDelta es normalmente 1, pero puede ser mayor si un delta grande atravesó varios ciclos de golpe, y negativo con timeScale negativo.
El uso canónico de finished es encadenar sin temporizadores:
const cola = [ 'agacharse', 'coger', 'levantarse' ];
let i = 0;
function siguiente() {
const clip = THREE.AnimationClip.findByName( gltf.animations, cola[ i ] );
const a = mixer.clipAction( clip );
a.reset();
a.setLoop( THREE.LoopOnce );
a.clampWhenFinished = true;
a.play();
}
mixer.addEventListener( 'finished', ( e ) => {
i ++;
if ( i < cola.length ) siguiente();
else console.log( 'secuencia completa' );
} );
siguiente();
Encadenar así es correcto y setTimeout no lo es, por dos razones: el temporizador no respeta mixer.timeScale ni las pausas, y se desincroniza si el frame se salta. El evento se dispara exactamente en el update en que la acción termina.
El uso canónico de loop es esperar a un punto seguro del ciclo antes de transicionar, que es lo que hace el ejemplo oficial de blending:
function transicionarAlAcabarElCiclo( saliente, entrante, duracion ) {
mixer.addEventListener( 'loop', function alCiclar( e ) {
if ( e.action !== saliente ) return;
mixer.removeEventListener( 'loop', alCiclar );
transicionar( saliente, entrante, duracion );
} );
}
Con eso, el cambio de andar a estar de pie ocurre siempre con los pies juntos y no a mitad de zancada.
Los dos eventos vienen del mixer, no de la acción. Un solo listener recibe los eventos de todas las acciones de ese mixer, y hay que filtrar por e.action. Olvidarlo produce el bug de que terminar una animación cualquiera dispare la lógica de otra.
La rutina de destrucción
El mixer retiene referencias fuertes a la raíz, a todos los objetos enlazados y a los buffers de acumulación. Nada de eso se libera con stop() ni con scene.remove().
function destruirPersonaje( modelo, mixer ) {
// 1. Parar todo y soltar las cachés del mixer.
mixer.stopAllAction();
mixer.uncacheRoot( modelo );
// 2. Sacar de la escena.
modelo.removeFromParent();
// 3. Liberar recursos de GPU.
const vistos = new Set();
modelo.traverse( ( o ) => {
if ( ! o.isMesh ) return;
o.geometry.dispose();
const mats = Array.isArray( o.material ) ? o.material : [ o.material ];
for ( const m of mats ) {
if ( vistos.has( m ) ) continue;
vistos.add( m );
for ( const clave of Object.keys( m ) ) {
const v = m[ clave ];
if ( v && v.isTexture ) v.dispose();
}
m.dispose();
}
} );
// 4. Quitar listeners que capturen el modelo.
mixer.removeEventListener( 'finished', alTerminar );
}
Los tres métodos de descacheo tienen alcances distintos:
| Método | Libera |
|---|---|
uncacheAction( clip, raiz ) |
una acción concreta |
uncacheClip( clip ) |
todas las acciones de ese clip y sus interpolantes |
uncacheRoot( raiz ) |
todo lo asociado a un objeto raíz |
Al destruir un personaje, el que quieres es uncacheRoot. Y el orden importa: si haces dispose() antes de uncacheRoot, el mixer sigue apuntando a objetos cuyos recursos de GPU ya no existen, y el siguiente update puede escribir en propiedades de un objeto medio destruido.
Fíjate también en que el Set de materiales vistos no es adorno: en un modelo con varias primitivas es normal que compartan material, y llamar a dispose() dos veces sobre la misma textura no es idempotente en todos los casos.
Hay un fallo de este sistema que aparece una vez cada varias horas, no se reproduce a voluntad y consume días de depuración. El evento finished se dispara dentro de _updateTime, en el frame en que el tiempo de la acción cruza el final del clip. Con LoopOnce eso es fiable. Pero cuando encadenas animaciones cortas con LoopOnce y el navegador entrega un delta grande —porque el usuario cambió de pestaña, porque el recolector de basura hizo una pausa, porque llegó una compilación de shader—, el tiempo puede saltar más allá del final del clip entero. Con LoopOnce el código acota el tiempo a duration y dispara el evento igualmente, así que estás a salvo. El problema real está en el modo LoopRepeat con repetitions finitas: ahí, un delta que atraviesa varios ciclos calcula loopDelta = Math.floor( time / duration ) y suma esa cantidad de golpe al contador; si con eso se pasa del número de repeticiones, dispara finished una sola vez, correctamente, pero no dispara los eventos loop intermedios que se saltó. Cualquier lógica que cuente vueltas escuchando loop —un contador de pasos, un disparo de sonido por ciclo, un contador de vueltas de una noria— se desincroniza en silencio y nunca vuelve a cuadrar. La defensa tiene dos partes y las dos son baratas. La primera: acotar el delta antes de pasarlo al mixer, con Math.min( delta, 1 / 15 ), lo que garantiza que ningún update atraviesa más de un ciclo de una animación de más de 66 milisegundos. Conectar el Timer al documento no sustituye a ese recorte: connect( document ) anula el salto de la pestaña oculta, que es el disparador más frecuente, pero no acota un cuadro lento cualquiera, así que el Math.min sigue haciendo falta. La segunda: usar loopDelta en lugar de asumir que cada evento es una vuelta, porque el dato está ahí precisamente para esto: contador += Math.abs( e.loopDelta ) en vez de contador ++. Las dos líneas juntas eliminan una clase entera de bugs que solo aparecen en producción, en el portátil de otra persona, y que nunca se reproducen en desarrollo porque en desarrollo la pestaña siempre está en primer plano.