Saltar al contenido

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

JS
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:

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

JS
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:

JS
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:

JS
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:

JS
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:

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

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