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

AnimationClip y las pistas de keyframes

Qué contiene una pista, los seis tipos que existen y cuándo usar cada uno, los tres modos de interpolación, y cómo construir un clip a mano o recortar uno cargado.

⏱ 18 min

Un clip es un contenedor de pistas y una pista es un par de arrays: tiempos y valores. Toda la animación de Three.js se reduce a eso, y la elección del tipo de pista no es cosmética: determina qué matemática usa el mixer para interpolar y para mezclar. Confundir una NumberKeyframeTrack de cuatro componentes con una QuaternionKeyframeTrack da datos idénticos y comportamiento distinto, y es un error que solo se manifiesta cuando dos animaciones se solapan.

🎯 Al terminar esta lección sabrás
  • Describir la estructura interna de una pista y calcular su tamaño en memoria.
  • Elegir el tipo de pista correcto para una propiedad dada.
  • Distinguir los tres modos de interpolación y sus efectos.
  • Recortar, desplazar y optimizar clips cargados de un fichero.

Qué hay dentro de una pista

const pista = new THREE.VectorKeyframeTrack(
  'cubo.position',                          // ruta de propiedad
  [ 0, 1, 2 ],                              // times, en segundos, ascendentes
  [ 0,0,0,  2,1,0,  0,0,0 ]                 // values, aplanados
);

Tres arrays y un nombre. times tiene un elemento por keyframe; values tiene times.length * valueSize elementos, donde valueSize sale del tipo: 3 para un vector, 4 para un cuaternión, 1 para un número.

El coste en memoria es directo: un personaje con 60 huesos animados a 30 fps durante 2 segundos son 60 pistas de posición, rotación y escala, con 60 keyframes cada una. La rotación es la cara: 60 huesos × 60 keys × 4 floats × 4 bytes son 57 KB solo de cuaterniones. Un set de veinte animaciones de personaje ronda el megabyte, y eso es lo que hay que tener en cuenta al decidir cuántas cargar de golpe.

Los seis tipos disponibles y su uso:

Tipo valueSize Para qué
NumberKeyframeTrack 1 opacity, intensity, morphTargetInfluences[n]
VectorKeyframeTrack 2, 3 o 4 position, scale
QuaternionKeyframeTrack 4 quaternion
ColorKeyframeTrack 3 material.color
BooleanKeyframeTrack 1 visible
StringKeyframeTrack 1 propiedades de texto

La fila del cuaternión es la importante y ya viste por qué: el mixer usa el tipo para decidir si interpola linealmente o con slerp, y si mezcla por media ponderada o por slerp acumulativo. Una rotación tiene que ir en QuaternionKeyframeTrack, sin excepciones.

BooleanKeyframeTrack y StringKeyframeTrack fuerzan interpolación discreta por su naturaleza: no hay valor intermedio entre visible y no visible.

Los tres modos de interpolación

pista.setInterpolation( THREE.InterpolateSmooth );

InterpolateDiscrete. El valor salta de un keyframe al siguiente sin transición. Es lo que se usa para animación por fotogramas, para conmutadores y para pistas de visibilidad.

InterpolateLinear. El valor por defecto de casi todas las pistas. Recta entre keyframes. Para cuaterniones, la implementación hace slerp, no lerp componente a componente.

InterpolateSmooth. Interpolación cúbica con tangentes calculadas a partir de los keyframes vecinos —una spline de Catmull-Rom—. Da un movimiento con aceleración y frenada natural, y por eso es lo que quieres para una cámara. El precio es que puede sobrepasar los valores de los keyframes: una pista que va de 0 a 1 puede pasar momentáneamente de 1. Con una escala o una opacidad eso produce artefactos.

QuaternionKeyframeTrack no admite InterpolateSmooth: no hay una spline cúbica bien definida sobre la esfera de cuaterniones en la implementación de Three.js. Si lo pides, se ignora.

glTF puede traer un cuarto modo, CUBICSPLINE, en el que las tangentes vienen guardadas explícitamente en el fichero en lugar de deducirse. GLTFLoader lo implementa con un interpolante propio, marcado internamente con isInterpolantFactoryMethodGLTFCubicSpline, que guarda tres valores por keyframe: tangente de entrada, valor y tangente de salida. Por eso una pista de glTF con cubic spline tiene un values tres veces más largo de lo que esperarías.

Construir un clip a mano

function clipDePuerta( nombre, duracion ) {
  const abrir = new THREE.QuaternionKeyframeTrack(
    'Puerta.quaternion',
    [ 0, duracion ],
    [
      ...new THREE.Quaternion().toArray(),                                    // cerrada
      ...new THREE.Quaternion().setFromAxisAngle(
        new THREE.Vector3( 0, 1, 0 ), Math.PI / 2 ).toArray(),                // abierta
    ]
  );

  const chirrido = new THREE.NumberKeyframeTrack(
    'PuertaSonido.volumen',
    [ 0, duracion * 0.2, duracion ],
    [ 0, 1, 0 ]
  );

  return new THREE.AnimationClip( nombre, duracion, [ abrir, chirrido ] );
}

const clip = clipDePuerta( 'abrir', 1.2 );
mixer.clipAction( clip ).play();

Si pasas -1 como duración, AnimationClip la deduce del keyframe más tardío de todas sus pistas. Es lo que hace resetDuration(), y es útil cuando construyes las pistas por código y no quieres llevar la cuenta.

Para animaciones sencillas existe AnimationClipCreator en three/addons/animation/AnimationClipCreator.js, con métodos estáticos para rotaciones, pulsaciones, sacudidas y cambios de color. Es un atajo cómodo para prototipos.

Manipular clips cargados

Un caso muy frecuente: el modelo trae una sola animación larga con todos los movimientos concatenados, y tú necesitas trozos.

const largo = gltf.animations[ 0 ];   // 0 a 20 s, todo seguido

// subclip( clipOrigen, nombre, frameInicio, frameFin, fps )
const idle    = THREE.AnimationUtils.subclip( largo, 'idle',    0,   60, 30 );
const andar   = THREE.AnimationUtils.subclip( largo, 'andar',  61,  120, 30 );
const correr  = THREE.AnimationUtils.subclip( largo, 'correr', 121, 180, 30 );

subclip copia el clip, filtra los keyframes cuyo tiempo por fps cae en el rango, desplaza todas las pistas para que el nuevo clip empiece en cero y recalcula la duración. Los índices son frames, no segundos, y por eso hay que pasar el fps correcto: si el original se exportó a 24 y le pasas 30, los cortes caen en el sitio equivocado.

Otras operaciones útiles sobre un clip cargado:

// Buscar por nombre en un array de clips o en un objeto animado.
const salto = THREE.AnimationClip.findByName( gltf.animations, 'Salto' );

// Quitar keyframes redundantes: los que estan en la recta entre sus vecinos.
// Reduce el tamano en memoria sin cambiar el resultado visible.
clip.optimize();

// Recortar los keyframes fuera del rango real de la animacion.
clip.trim();

// Comprobar que las pistas son coherentes: tiempos ascendentes,
// longitudes de arrays correctas, sin NaN.
console.log( clip.validate() );

optimize() merece un comentario. Muchos exportadores escriben un keyframe por frame aunque el valor no cambie o cambie linealmente. Sobre una animación de personaje exportada de Blender sin optimizar, optimize() suele quitar entre el 30 y el 60 % de los keyframes sin diferencia perceptible. Es gratis y se hace una vez al cargar.

💡
Optimizar en el pipeline, no en el navegador

gltf-transform resample hace lo mismo que optimize() pero en tiempo de construcción, con lo que el ahorro también se nota en la descarga. Y gltf-transform sparse convierte a accesores dispersos las pistas casi constantes. Si controlas el pipeline, es mejor sitio que el runtime.

Las pistas de posición de la raíz son el motivo de que tu personaje se vaya volando

Hay un problema que aparece en cuanto pasas de un personaje que se anima en el sitio a uno que se mueve por el mundo, y que confunde a todo el mundo porque el síntoma no sugiere la causa. Las animaciones de locomoción vienen de captura de movimiento, y el actor se desplazaba de verdad al caminar. Esa traslación está guardada en la pista de posición del hueso raíz, lo que se llama root motion. Si reproduces ese clip mientras además mueves el objeto con tu código de control, obtienes las dos traslaciones sumadas: el personaje se va al infinito, o patina, o vibra. Y si en algún momento haces crossFadeTo entre dos clips con root motion distinto, el salto de posición es visible. Hay tres soluciones y conviene saber cuál se aplica a qué caso. La primera, y la que usa la mayoría de las webs: eliminar la pista de raíz del clip, filtrando clip.tracks para quitar la que acaba en .position sobre el hueso raíz, y mover el personaje enteramente por código. La animación queda «en el sitio» y el desplazamiento lo controlas tú, lo que es lo correcto si el movimiento responde a la entrada del usuario. La segunda, la de los motores de juego serios: consumir el root motion, es decir, leer cuánto se ha desplazado la raíz desde el último frame, aplicárselo al objeto contenedor y devolver la raíz a cero. Con eso el personaje avanza a la velocidad que la animación dicta y los pies no patinan nunca, a cambio de que el control sea menos directo. La tercera es no tener el problema: pedir al animador clips en el sitio. En Mixamo, que es de donde salen la mitad de los personajes de la web, la casilla se llama In Place y está en el panel de descarga. Reconocer cuál de las tres te toca al empezar el proyecto ahorra una reescritura del sistema de movimiento a mitad de camino.