Interpolación entre estados: del salto al movimiento
Calcular estados intermedios en vez de acumular incrementos: el alfa del paso fijo, los ángulos por el camino corto, el color en el espacio adecuado y las curvas.
Hay dos maneras de que algo se mueva y confundirlas explica la mayoría de las animaciones que se sienten mal. Una es integrar: aplicar una velocidad al estado actual y dejar que el resultado emerja. La otra es interpolar: conocer el punto de partida y el de llegada, y calcular en qué punto intermedio está el objeto en función del tiempo. La segunda es la que usan las transiciones de interfaz, la que permite dibujar suave con una simulación a paso fijo, y la que hay que escribir a mano porque en canvas no existe ninguna transición.
- Escribir interpolación lineal y aplicarla con un progreso normalizado.
- Dibujar entre dos pasos de simulación con el alfa del acumulador.
- Interpolar ángulos por el camino corto y colores en un espacio perceptual.
- Elegir la curva de aceleración según lo que el movimiento tenga que comunicar.
Interpolar: la función que va de A a B
La operación básica ocupa una línea y conviene tenerla nombrada, porque va a aparecer en todas partes.
const mezclar = (a, b, t) => a + (b - a) * t;
Con t entre cero y uno, devuelve el punto intermedio. Todo lo demás es decidir de dónde sale la t.
La diferencia con la integración es que el objeto no recuerda dónde está: se calcula. Eso tiene una consecuencia que se aprecia en cuanto pasan unos minutos de ejecución: la interpolación no acumula error, mientras que sumar incrementos sí. Una animación de un minuto hecha con incrementos termina en un sitio ligeramente distinto en cada máquina; hecha con interpolación, termina exactamente en el destino en todas.
function crearTransicion(desde, hasta, duracion) {
return { desde, hasta, duracion, t: 0, activa: true };
}
function avanzarTransicion(tr, dt) {
if (!tr.activa) return tr.hasta;
tr.t = Math.min(1, tr.t + dt / tr.duracion);
if (tr.t === 1) tr.activa = false;
return mezclar(tr.desde, tr.hasta, tr.t);
}
Que t se acote a uno no es cosmético: sin ese Math.min, un delta grande hace que el valor sobrepase el destino y vuelva, con un rebote que nadie ha pedido.
Dibujar entre dos pasos de simulación
Aquí es donde la interpolación deja de ser un adorno y pasa a ser obligatoria. Con el paso fijo de la lección anterior, el estado del mundo avanza en saltos de duración constante que no coinciden con los fotogramas. Si dibujas siempre el último estado simulado, el objeto avanza a tirones: unos fotogramas se dibujan justo después de un paso y otros justo antes del siguiente.
La solución es guardar dos estados por objeto y dibujar entre ellos usando el resto del acumulador.
function simular(mundo, paso) {
for (const c of mundo.cuerpos) {
c.xPrevio = c.x; // el estado de ANTES de este paso
c.yPrevio = c.y;
c.vy += 980 * paso;
c.x += c.vx * paso;
c.y += c.vy * paso;
if (c.y > mundo.suelo) { c.y = mundo.suelo; c.vy *= -0.7; }
}
}
function dibujar(ctx, mundo, alfa) {
ctx.clearRect(0, 0, mundo.ancho, mundo.alto);
for (const c of mundo.cuerpos) {
const x = mezclar(c.xPrevio, c.x, alfa);
const y = mezclar(c.yPrevio, c.y, alfa);
ctx.beginPath();
ctx.arc(x, y, c.r, 0, Math.PI * 2);
ctx.fill();
}
}
El alfa es el que sale del bucle con acumulador: acumulador / PASO. Vale cero justo después de simular y se acerca a uno según se acumula tiempo pendiente.
Hay un detalle que produce un fallo desconcertante y merece anticiparse: cuando un objeto teletransporta, la interpolación dibuja el viaje. Si una bola sale por la derecha y reaparece por la izquierda, o si una colisión la reposiciona de golpe, el estado previo y el actual están muy lejos y el fotograma intermedio muestra la bola cruzando la pantalla. La corrección consiste en igualar los dos estados en el momento del salto:
if (c.x > mundo.ancho) { c.x -= mundo.ancho; c.xPrevio = c.x; } // sin viaje intermedio
Las transiciones con duración funcionan perfectamente mientras nadie las interrumpa, y en una interfaz real se interrumpen constantemente: el usuario mueve el ratón antes de que termine el resaltado, cambia de pestaña a mitad del deslizamiento, pulsa otro botón. Lo que hace todo el mundo es reiniciar la transición desde el valor actual hacia el nuevo destino con la misma duración, y eso produce una discontinuidad de velocidad que el ojo detecta perfectamente aunque no sepa nombrarla: el objeto viene acelerando, y de pronto arranca otra vez desde cero hacia otro sitio. Se ve como un tirón. El problema de fondo es que una transición con duración fija codifica el movimiento como una función del progreso, y al reiniciarla pierdes la única información que hacía falta para empalmar: la velocidad que llevaba. Hay tres soluciones y forman una escalera. La primera, casi gratis: no reiniciar el progreso. Si el destino cambia pero la transición ya iba por la mitad, conserva la t y cambia solo el destino y el origen —haciendo que el origen sea el valor actual— de modo que la parte de curva que queda por recorrer sea la de deceleración. Es una chapuza y se nota mucho menos que reiniciar. La segunda, la correcta para interfaces: el seguimiento exponencial hacia el objetivo, con la potencia del delta que ya conoces. No tiene principio ni final, así que cambiar el destino a mitad no interrumpe nada: la velocidad sale de la distancia que falta y es continua por construcción. El precio es que no puedes prometer una duración exacta, porque teóricamente nunca llega; en la práctica se corta cuando la distancia baja de medio píxel. La tercera, la general: el muelle. Guardas posición y velocidad, aplicas una fuerza proporcional a la distancia al objetivo y un amortiguamiento proporcional a la velocidad, e integras. v += (objetivo - x) * rigidez * dt; v *= Math.pow(amortiguacion, dt); x += v * dt;. Cambiar el objetivo a mitad de movimiento es simplemente cambiar hacia dónde tira la fuerza: la velocidad se conserva, el empalme es perfecto, y sale gratis el rebote cuando lo quieres. Es lo que hacen por dentro las bibliotecas de animación modernas y lo que explica por qué sus interacciones se sienten distintas. La regla que resume las tres: si el movimiento puede ser interrumpido por el usuario, no lo modeles con una duración; modélalo con un estado físico. Las duraciones son para lo que empieza y termina sin que nadie lo toque.
Interpolar lo que no es un número
Dos casos rompen la interpolación lineal ingenua y aparecen en cualquier animación real.
Los ángulos dan la vuelta por el lado largo. Mezclar linealmente entre 350 y 10 grados recorre 340 grados hacia atrás en vez de 20 hacia delante. La corrección es normalizar la diferencia al intervalo de media vuelta:
const TAU = Math.PI * 2;
function mezclarAngulo(a, b, t) {
const d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI; // el camino corto
return a + d * t;
}
El doble módulo no es paranoia: el operador de JavaScript devuelve valores negativos para operandos negativos, y sin el segundo ajuste la función falla para la mitad de los ángulos.
Los colores se apagan si se mezclan en el espacio equivocado. Interpolar componente a componente entre dos colores saturados en sRGB pasa por un centro gris y sucio, porque los canales del formato no son perceptualmente uniformes. Mezclar en un espacio perceptual como OKLCH mantiene la luminosidad y la saturación por el camino:
function mezclarOklch(a, b, t) {
const L = mezclar(a.L, b.L, t);
const C = mezclar(a.C, b.C, t);
const H = (mezclarAngulo(a.H * Math.PI / 180, b.H * Math.PI / 180, t) * 180 / Math.PI + 360) % 360;
return `oklch(${L} ${C} ${H})`;
}
const azul = { L: 0.72, C: 0.14, H: 258 };
const verde = { L: 0.85, C: 0.16, H: 145 };
ctx.fillStyle = mezclarOklch(azul, verde, t);
Fíjate en que el tono se interpola como ángulo, porque lo es: 350 y 10 son tonos vecinos. Es el mismo problema de antes en otro disfraz.
Un aviso de rendimiento que se paga caro: construir una cadena de color por objeto y por fotograma obliga al navegador a analizar texto miles de veces por segundo. En escenas densas conviene precalcular una tabla de, por ejemplo, sesenta y cuatro pasos y consultarla por índice.
Las curvas
Interpolar linealmente es correcto para la mecánica —la interpolación entre pasos de simulación tiene que ser lineal— y casi siempre erróneo para la percepción. Nada en el mundo físico arranca a velocidad máxima.
Una curva es una función que reasigna el progreso antes de mezclar:
const curvas = {
lineal: t => t,
entrada: t => t * t, // arranca lento
salida: t => 1 - (1 - t) * (1 - t), // frena al llegar
suave: t => t * t * (3 - 2 * t), // clasica en ambos extremos
fuerte: t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
rebote: t => 1 + 2.70158 * Math.pow(t - 1, 3) + 1.70158 * Math.pow(t - 1, 2),
};
const valor = mezclar(desde, hasta, curvas.salida(tr.t));
La elección no es estética, comunica algo distinto en cada caso.
Salida —rápido al principio, frenando al final— es la que corresponde a algo que responde a una acción del usuario: la reacción es inmediata y el asentamiento es suave. Es la que se usa mal con menos frecuencia y la que debería ser tu opción por defecto en interfaces.
Entrada es para algo que se va: sale despacio y acelera al desaparecer. Usarla para algo que aparece hace que la interfaz se sienta lenta, porque el primer tercio del tiempo casi no se mueve nada.
Suave en ambos extremos es para movimientos autónomos que el usuario no ha provocado: una cámara que se recoloca, un elemento que se reordena.
Rebote llama la atención y por eso hay que reservarla para lo que debe llamarla. En una animación que se repite muchas veces al día, cansa.
Y una regla que ahorra discusiones: la duración importa más que la curva. Entre 150 y 250 milisegundos para una reacción directa, entre 250 y 400 para un cambio de estado grande. Por encima de medio segundo, cualquier curva se percibe como lentitud.