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.
Los tres modos
accion.setLoop( THREE.LoopRepeat, Infinity ); // por defectoaccion.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.
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.