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

Controlar una acción: play, stop, pausa y velocidad

La diferencia real entre stop, paused y enabled, cómo funcionan timeScale y setEffectiveTimeScale, y para qué sirven startAt, halt, warp y syncWith.

⏱ 17 min

AnimationAction tiene tres formas distintas de «parar» una animación y no son intercambiables: stop(), paused = true y enabled = false dejan el sistema en estados diferentes, y elegir mal produce comportamientos que parecen aleatorios. Lo mismo pasa con la velocidad: hay una propiedad timeScale y un método setEffectiveTimeScale, y el método no es un simple setter. Esta lección es el catálogo preciso de qué hace cada cosa.

🎯 Al terminar esta lección sabrás
  • Distinguir stop, paused y enabled por el estado en que dejan la acción.
  • Explicar por qué setEffectiveTimeScale existe además de la propiedad timeScale.
  • Programar el inicio de una acción con startAt y frenarla con halt.
  • Sincronizar dos acciones y ajustar la duración de una sin tocar el clip.

Arrancar y parar

const accion = mixer.clipAction( clip );

accion.play();     // registra la accion en el mixer, empieza a evaluarse
accion.stop();     // la des-registra y la resetea
accion.reset();    // resetea sin des-registrar

play() no hace nada más que registrar la acción como activa: this._mixer._activateAction( this ). No resetea el tiempo. Una acción que se paró con paused = true y a la que llamas play() continúa donde estaba.

stop() hace dos cosas: des-registra y llama a reset(). Y reset() pone paused = false, enabled = true, time = 0, olvida el conteo de bucles y cancela cualquier desvanecimiento o warp pendiente.

Las tres formas de detener, con lo que quedan:

¿Sigue registrada? ¿Conserva time? ¿Afecta a la propiedad?
stop() no no, vuelve a 0 no
paused = true , congela la pose actual
enabled = false no, peso efectivo 0

La fila del medio es la que sorprende y la que resuelve un caso muy concreto. Una acción pausada sigue escribiendo su valor en la propiedad cada frame, porque su peso sigue siendo el que era: el personaje se queda congelado en la pose exacta en que estaba. Una acción con enabled = false deja de contribuir del todo, y si no hay ninguna otra acción escribiendo esa propiedad, el objeto vuelve a su transformación original de golpe.

Y hay un detalle documentado en el código de reset() que conviene retener: desactivar enabled no resetea la acción. Al volver a activarla, continúa desde donde estaba.

Velocidad y duración

timeScale y su setter

accion.timeScale = 2;                 // el doble de rapido
accion.setEffectiveTimeScale( 2 );    // lo mismo, y ademas cancela el warp

La propiedad se puede asignar directamente y funciona. El método hace dos cosas más:

// Implementacion real de setEffectiveTimeScale:
setEffectiveTimeScale( timeScale ) {
  this.timeScale = timeScale;
  this._effectiveTimeScale = this.paused ? 0 : timeScale;
  return this.stopWarping();
}

Actualiza el valor efectivo inmediatamente en lugar de esperar al siguiente update, y cancela cualquier warp en curso. Esa segunda parte es la razón de existir del método: si hay un cambio gradual de velocidad programado, asignar timeScale a pelo no lo cancela y el warp seguirá sobrescribiendo tu valor. Usa el método siempre que puedas haber programado un warp.

Los valores tienen semántica completa:

accion.timeScale = 1;      // normal
accion.timeScale = 0.5;    // mitad de velocidad
accion.timeScale = 0;      // congelada, equivalente a paused para el avance
accion.timeScale = -1;     // hacia atras

Reproducir hacia atrás funciona de verdad, incluidos los bucles y el evento finished, que llega con direction: -1.

Y existe la escala global del mixer, que multiplica a la de cada acción:

mixer.timeScale = 0.25;    // toda la escena a cuarto de velocidad

Ajustar la duración sin tocar el clip

accion.setDuration( 3 );

No modifica el clip: calcula el timeScale necesario para que el clip completo dure ese tiempo. La implementación es literal:

setDuration( duration ) {
  this.timeScale = this._clip.duration / duration;
  return this.stopWarping();
}

Es el método que quieres cuando una animación tiene que encajar en una ventana temporal fija —sincronizar con audio, con una transición de página, con otra animación— sin importar cuál era su duración original.

Programar el arranque y frenar con suavidad

Programar el arranque

// Empieza cuando el reloj del mixer llegue a ese instante.
accion.startAt( mixer.time + 1.5 ).play();

startAt guarda un instante en el reloj del mixer, no en el reloj del navegador. Hasta llegar a él, la acción está registrada pero su delta es cero, y isRunning() devuelve false mientras isScheduled() devuelve true. Esa pareja de métodos es exactamente para distinguir esos dos estados.

Es la forma limpia de encadenar sin setTimeout, y respeta mixer.timeScale: si pones la escena a cámara lenta, el arranque programado también se retrasa proporcionalmente, cosa que un temporizador de JavaScript no haría.

Frenar y acelerar suavemente

accion.halt( 0.8 );                 // frena hasta parar en 0.8 s
accion.warp( 1, 2.5, 1.2 );         // acelera de 1x a 2.5x en 1.2 s
accion.stopWarping();               // cancela el warp en curso

warp programa un interpolante sobre timeScale entre dos valores. Y halt es literalmente warp( timeScaleActual, 0, duracion ): cuando la escala llega a cero, el código pone paused = true.

La diferencia con parar de golpe se nota mucho en animaciones cíclicas. Un personaje corriendo que se detiene con stop() congela un frame arbitrario del ciclo; el mismo personaje con halt( 0.5 ) desacelera de forma que los pasos se acortan hasta pararse, que es lo que hace un cuerpo real.

Sincronizar dos acciones

correr.syncWith( andar );

Copia time y timeScale de la otra acción. Es el paso previo obligatorio a un crossfade entre dos ciclos de locomoción: si andar va por el 40 % de su ciclo y correr empieza en cero, el crossfade mezcla el pie izquierdo adelantado de una con el derecho adelantado de la otra, y el resultado es un tropiezo. Sincronizando primero, los dos ciclos van en fase y la mezcla es limpia.

El requisito es que los dos clips tengan la misma estructura de fase: que el punto de contacto del pie esté en la misma fracción de la duración. Eso hay que pedírselo al animador, o conseguirlo con setDuration sobre uno de los dos.

⚠️
isRunning tiene cinco condiciones

La implementación es this.enabled && ! this.paused && this.timeScale !== 0 && this._startTime === null && this._mixer._isActiveAction( this ). Un isRunning() falso puede significar cinco cosas distintas, y por eso depurar con él a secas confunde. Cuando algo no se mueve, imprime las cinco por separado.

Una acción parada sigue ocupando memoria en el mixer, y ahí está la fuga

El sistema de animación de Three.js mantiene tres cachés internas y ninguna se limpia sola. Cuando llamas a mixer.clipAction( clip ), el mixer crea la acción, crea un PropertyBinding por cada pista y un PropertyMixer por cada propiedad de destino, y guarda todo eso indexado por la pareja clip-raíz. Llamar a stop() no libera nada de eso: solo saca la acción de la lista de activas. Los bindings siguen ahí, apuntando a los objetos, con sus buffers de acumulación reservados. La consecuencia es una fuga de memoria clásica en aplicaciones que crean y destruyen personajes: cargas un enemigo, lo animas, lo eliminas de la escena con scene.remove(), y el mixer sigue guardando referencias a su esqueleto entero, de forma que ni el objeto ni su geometría ni sus texturas pueden ser recolectados. Con veinte enemigos por minuto, la memoria sube monótonamente hasta que la pestaña muere, y en el perfilador se ve una retención que parece venir de la nada. Los tres métodos de limpieza existen y hay que llamarlos explícitamente: mixer.uncacheAction( clip, raiz ) libera una acción concreta; mixer.uncacheClip( clip ) libera todas las acciones de ese clip y sus interpolantes; y mixer.uncacheRoot( raiz ) libera todo lo asociado a un objeto raíz, que es el que quieres cuando eliminas un personaje. La rutina de destrucción correcta es siempre la misma: mixer.stopAllAction(), después mixer.uncacheRoot( modelo ), después scene.remove( modelo ), y solo entonces recorrer el modelo liberando geometrías y materiales. Saltarse el segundo paso deja todo lo demás inútil, porque el mixer sigue reteniendo la raíz y el recolector no toca nada de lo que cuelga de ella.