El reloj compartido: rAF y document.timeline
Tu bucle y las animaciones de la plataforma miden el tiempo en la misma escala. Cómo aprovecharlo para sincronizar sin deriva, y qué es una DocumentTimeline con origen desplazado.
La marca de tiempo que recibe tu callback de requestAnimationFrame y el valor de document.timeline.currentTime son el mismo número. No son dos relojes parecidos: es el mismo reloj, leído desde dos sitios. Esa coincidencia, que la especificación garantiza, es lo que permite mezclar un bucle propio con animaciones de la plataforma sin que deriven, y lo que convierte a document.timeline en el puente entre el mundo imperativo y el declarativo.
- Comprobar que la marca de rAF y
document.timeline.currentTimecoinciden. - Sincronizar un bucle de dibujo con el progreso de una animación WAAPI.
- Programar animaciones desde dentro de un bucle usando
startTime. - Explicar qué hace una
DocumentTimelineconoriginTimedesplazado.
Un solo reloj
Tanto la marca de rAF como document.timeline.currentTime son un DOMHighResTimeStamp medido desde el origen temporal del documento, el mismo que usa performance.now(). Y dentro de un callback de animación, ambos valen exactamente lo mismo, porque el navegador fija el instante del frame una vez y lo usa para actualizar las animaciones y para invocar los callbacks:
requestAnimationFrame((ts) => {
console.log(ts, document.timeline.currentTime, ts === document.timeline.currentTime);
console.log(performance.now()); // mayor: es la hora real, no la del frame
});
performance.now() dentro del callback devuelve un número mayor, porque mide cuándo se está ejecutando la línea, no cuándo empezó el frame. La diferencia son los microsegundos o milisegundos que el navegador ha tardado en llegar hasta ahí, y varía de frame a frame.
De aquí sale la regla que ya vimos y que ahora tiene explicación completa: usa siempre el argumento del callback. No solo porque sea estable entre callbacks del mismo frame, sino porque es el valor que la plataforma está usando para calcular sus propias animaciones. Cualquier cálculo que hagas con él está en fase con ellas.
Fuera de un callback de animación, document.timeline.currentTime refleja el instante del último frame actualizado, no la hora actual. Y en un documento que todavía no ha pintado su primer frame puede valer null, así que en código que corre muy pronto conviene comprobarlo.
Leer una animación desde el bucle
Como los relojes coinciden, no hay que sincronizar nada: basta con leer el estado de la animación cada frame.
const anim = el.animate(
{ transform: ['translateX(0)', 'translateX(600px)'] },
{ duration: 3000, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'both' }
);
function frame() {
const p = anim.effect.getComputedTiming().progress;
if (p !== null) {
// Dibuja una estela en canvas usando el mismo progreso, con la misma curva
dibujarEstela(p);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
progress ya tiene aplicada la curva de temporización y la dirección, así que el dibujo en el canvas sigue exactamente el movimiento del elemento del DOM, incluida su aceleración. Reimplementar la curva en el bucle daría un resultado parecido y desincronizado; leerla del propio efecto da uno idéntico.
Este es el patrón para cualquier caso híbrido: un elemento del DOM animado por la plataforma y un efecto en canvas o WebGL que lo acompaña. La animación manda, el bucle la sigue.
Programar animaciones desde el bucle
En la otra dirección, desde el bucle puedes colocar animaciones en instantes exactos usando startTime, que vive en la misma escala:
function frame(ts) {
if (debeDispararse(ts)) {
const a = chispa.animate({ opacity: [1, 0], scale: [0.5, 2] }, { duration: 400 });
a.ready.then(() => { a.startTime = ts; }); // anclada al instante de este frame
}
requestAnimationFrame(frame);
}
Asignar startTime = ts ancla la animación al instante del frame en el que se decidió lanzarla, y no al instante en que el navegador la puso en marcha, que puede ser uno o dos frames después. Para una animación aislada es imperceptible; para una ráfaga de decenas de partículas lanzadas desde una simulación, es la diferencia entre un efecto coherente y uno que se ve arrítmico.
También puedes anclarlas en el pasado o en el futuro con la misma aritmética, que es el mecanismo de coreografía que vimos con startTime, ahora accesible desde un bucle que decide en tiempo real cuándo debe ocurrir cada cosa.
El orden dentro del ciclo de actualización de un frame es fijo y explica un comportamiento que parece un fallo de un frame. El navegador primero actualiza las animaciones y dispara sus eventos, y solo después ejecuta los callbacks de requestAnimationFrame. Es decir: cuando tu callback se ejecuta, las animaciones de la plataforma ya han calculado su valor para este frame. Leer progress da el valor de este frame, no del anterior, y por eso el patrón de la estela está en fase. Pero la consecuencia contraria también es cierta: si en tu callback modificas una animación —le cambias el currentTime, le asignas keyframes nuevos— ese cambio no se refleja hasta el frame siguiente en lo que ya se calculó, aunque sí en el estilo final porque el cálculo de estilo viene después de los callbacks. Donde muerde de verdad es al mezclar con getComputedStyle: leer el estilo computado dentro de un callback fuerza al motor a recalcular con el estado actual, así que sí verás tu cambio, pero habrás pagado un cálculo de estilo sincrónico en mitad del frame. Es una de las formas más caras y menos evidentes de meter jank en un bucle que por lo demás está bien escrito.
DocumentTimeline con origen desplazado
DocumentTimeline es constructible, y su único parámetro desplaza el instante cero:
const retrasada = new DocumentTimeline({ originTime: 2000 });
Una animación conectada a esa línea de tiempo tiene su cero dos segundos después del origen del documento, así que su currentTime es dos mil milisegundos menor que el que tendría en la línea normal. En la práctica equivale a un retardo de dos segundos aplicado a todo lo que cuelgue de ella.
const anim = new Animation(
new KeyframeEffect(el, { opacity: [0, 1] }, { duration: 400 }),
retrasada
);
anim.play();
Su utilidad real es tener un origen común compartido por un grupo de animaciones, de modo que todas midan el tiempo desde el mismo instante sin necesidad de calcular retardos relativos. Una secuencia de veinte animaciones ancladas a una línea de tiempo propia se puede desplazar entera cambiando el origen, en vez de recalcular veinte retardos.
Es un mecanismo poco usado porque la aritmética con startTime cubre casi todo lo mismo con menos objetos. Pero conviene conocerlo por lo que anticipa: document.timeline no es la línea de tiempo, es una. La propiedad anim.timeline es escribible, y una animación puede cambiar de fuente de tiempo sin dejar de ser la misma animación.
anim.timeline = otraLinea; // la animacion cambia de fuente de tiempo
Ese es el punto donde el modelo deja de ser “animaciones que avanzan con el reloj” y pasa a ser algo más general. Es el asunto del nivel siguiente.
Registra en el mismo callback ts, document.timeline.currentTime y performance.now() durante veinte frames y comprueba cuáles coinciden y cuánto se separa el tercero. Después crea una animación WAAPI de tres segundos y dibuja en un canvas una barra que use su progress, y otra que uses tu propio contador de tiempo con la misma duración y una curva escrita a mano. Observa si las dos barras se separan al provocar carga en el hilo principal.