Saltar al contenido

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.

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.

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

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

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

JS
if (c.x > mundo.ancho) { c.x -= mundo.ancho; c.xPrevio = c.x; }   // sin viaje intermedio

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:

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

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

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