* notes / matemáticas

Splines y easings: la matemática del cursor con cola

El cursor con cola dasheada de este sitio termina apoyado en una spline de Catmull-Rom convertida a Béziers cúbicas y un easing con rebote. Tres ecuaciones que llevaba años queriendo entender.

May 20, 202610 min read#matemáticas#splines#easing#canvas#animación#frontend

El esqueleto del efecto

El plan, alto nivel, cabe en cinco viñetas:

  1. Cada pointermove empuja una muestra { x, y, t } a un búfer.
  2. Para evitar ruido, una muestra sólo se acepta si dista al menos MIN_DIST píxeles de la anterior.
  3. En cada requestAnimationFrame se descartan las muestras más viejas que MAX_AGE.
  4. Las muestras restantes se conectan con segmentos curvos que el canvas dibuja como trazos dasheados.
  5. Cada segmento se desvanece según una función de su edad.

Los dos puntos interesantes son (4), cómo se curva la línea, y (5), cómo se borra cada tramo. El resto es bookkeeping.

Del trazo angular al trazo continuo

La implementación naíf encadena lineTo entre muestras consecutivas. Funciona y es rapidísimo, pero acentúa cada muestra como un vértice: las curvas se ven facetadas, sobre todo cuando el ratón gira con rapidez. Aumentar la densidad muestral lo disimula pero no lo resuelve.

lineTo entre muestrasCatmull-Rom como cúbicas de Bézier

Las soluciones decentes son tres y conviene compararlas antes de elegir:

  • Cuadrática a través de los puntos medios. Para cada terna de muestras consecutivas P[i-1], P[i], P[i+1], dibuja una cuadrática del punto medio entre P[i-1] y P[i] al punto medio entre P[i] y P[i+1], usando P[i] como punto de control. Sale gratis y es C¹ continua en los puntos medios, pero "corta esquinas": la curva no pasa por las muestras, las roza.
  • Catmull-Rom. Una familia clásica de splines que sí pasa por todas las muestras y mantiene tangentes continuas entre segmentos.
  • B-splines, NURBS, Béziers interpoladoras... Maquinaria pesada, justificada cuando hay restricciones de geometría que aquí no existen.

Para una cola de cursor (pocas muestras, geometría amistosa, ojo humano poco exigente sobre la matemática exacta), Catmull-Rom es el equilibrio justo entre rigor visual y código corto.

Catmull-Rom, en una página

Catmull-Rom es una spline cúbica interpoladora publicada en 1974 por Edwin Catmull y Raphael Rom, los mismos Catmull y Rom que aparecen en cada git blame de la historia de la computación gráfica. Dados cuatro puntos consecutivos P0, P1, P2, P3, define una curva entre P1 y P2 cuyas tangentes en esos extremos son paralelas a P2 - P0 y P3 - P1 respectivamente.

Esa propiedad es lo que da la sensación de continuidad: la tangente al final de un segmento coincide con la tangente al inicio del siguiente, porque ambas se calculan a partir del mismo trío de puntos. En la jerga del cálculo, la curva es C¹ continua en cada nodo.

Hay un parámetro de tensión τ que escala cuán lejos viaja la curva entre puntos. La forma más usada, la que adopto aquí, es la uniforme, con τ = 1, que en la práctica equivale a dividir entre 6 en la conversión a Bézier que veremos en seguida. Existen variantes paramétricas: la centripetal, con un exponente 0.5, evita los famosos loops cuando dos muestras se acercan demasiado. Para un cursor que reparte muestras con espaciado mínimo MIN_DIST la uniforme basta.

Convertir Catmull-Rom a Béziers cúbicas

Canvas 2D no tiene una primitiva nativa para Catmull-Rom. Sí tiene bezierCurveTo, que dibuja una cúbica entre dos puntos con dos puntos de control intermedios. Felizmente, toda Catmull-Rom uniforme se puede expresar como una cúbica de Bézier equivalente, y los puntos de control se calculan en cuatro líneas:

// Segmento entre P1 y P2, con vecinos P0 y P3.
const cp1x = p1.x + (p2.x - p0.x) / 6
const cp1y = p1.y + (p2.y - p0.y) / 6
const cp2x = p2.x - (p3.x - p1.x) / 6
const cp2y = p2.y - (p3.y - p1.y) / 6
ctx.moveTo(p1.x, p1.y)
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y)

El /6 no es arbitrario: cae de igualar derivadas en los extremos. La derivada de una Bézier cúbica en t=0 vale 3·(CP1 - P1). La tangente que Catmull-Rom prescribe en P1 es (P2 - P0) / 2. Si forzamos 3·(CP1 - P1) = (P2 - P0) / 2, despejamos CP1 = P1 + (P2 - P0) / 6. Análogamente para CP2 en el otro extremo.

P0P1P2P3CP1CP2CP1 = P1 + (P2 − P0) / 6 CP2 = P2 − (P3 − P1) / 6

La curva gruesa es el segmento real que dibuja el canvas; los puntos CP1 y CP2 viven en el aire y nunca se renderizan, pero son los que doblan la cúbica para que entre y salga con las tangentes correctas.

Los extremos del rastro

Hay un detalle prosaico que la versión limpia de las fórmulas oculta: para el primer segmento del rastro no existe P[-1], y para el último no existe P[N+1]. Si los ignorásemos, el extremo más reciente del trazo nunca aparecería; es decir, el cursor iría adelantado respecto a su cola. El truco habitual, también el que uso aquí, es duplicar el vecino inexistente:

const p0 = i > 0 ? points[i - 1] : points[i]
const p3 = i + 2 < points.length ? points[i + 2] : points[i + 1]

Geométricamente, duplicar el extremo equivale a forzar una tangente cero en la frontera, una variante conocida como "Catmull-Rom con condiciones naturales". Visualmente, la cola arranca y termina con una pendiente plana en el primer y último píxel, indistinguible a simple vista a la velocidad a la que se mueve un cursor.

El desvanecimiento: easeOutBack en una línea

Una vez dibujado el trazo, falta lo que más cuesta de verbalizar: que se borre con gracia. La opción cándida es una caída lineal: fade = 1 - age / MAX_AGE. Resulta funcional pero plana: el trazo se descolora a velocidad constante hasta apagarse, sin acentuación ni carácter.

Los easings de Robert Penner, una colección publicada en 2002 dentro de su libro sobre animación con ActionScript, ofrecen un menú de curvas con personalidad. La que adopté aquí es easeOutBack:

const easeOutBack = (t: number) => {
  const c1 = 1.7
  const c3 = c1 + 1
  const x = t - 1
  return 1 + c3 * x ** 3 + c1 * x ** 2
}

La constante 1.7 no salió de un sombrero: corresponde a la elección original de Penner para que el sobresalto sea perceptible pero no chocante. Si la subes a 2.7, la curva rebasa más; si la bajas a 0.5, el rebote casi desaparece.

10t = 1f(t)linealeaseOutCubiceaseOutBackEl sobresalto de easeOutBack rebasa 1 alrededor de t ≈ 0.6 antes de asentarse.

Ahora viene un detalle que mi primer intento dejó pasar. En el código, la opacidad se calcula así:

const fade = 1 - Math.min(1, Math.max(0, easeOutBack(age)))

La intuición es que easeOutBack(age) crece de 0 a 1 mientras age crece de 0 a 1, así que 1 - eseo decrece de 1 a 0. Pero easeOutBack no se queda en [0, 1]: rebasa hasta ≈ 1.10 antes de volver. Al recortar con Math.min(1, ...), la opacidad toca cero antes de tiempo (en torno a age ≈ 0.4, no en age = 1) y de ahí en adelante el segmento ya es invisible aunque el búfer lo siga arrastrando.

¿Es eso un bug? Lo fue mi primer instinto. Después caí en la cuenta de que precisamente eso es lo que da el carácter del desvanecimiento: la cola no se apaga uniformemente, se desploma en el último tercio. El "rebote" no es una oscilación visible (para eso existe easeOutElastic), sino un acelerón final que cierra el segmento de golpe. Lo dejé tal cual.

Muestreo: lo que no se ve también cuenta

Tres constantes regulan cuánta resolución gana o pierde el trazo:

  • MAX_AGE = 3000 ms. Vida útil de cada muestra. Por debajo de 800 ms el rastro se siente cortés; por encima de 4000 ms las muestras viejas pesan en el clearRect sin aportar nada visible.
  • MIN_DIST = 4 píxeles. Distancia mínima para aceptar una nueva muestra. Bajarlo a 2 produce splines preciosas y casi imperceptiblemente más caras; subirlo a 10 las hace "saltonas" en giros cerrados aun con Catmull-Rom corrigiendo.
  • DASH = [4, 5]. Patrón del trazo. La proporción 4:5 hace que el "hueco" sea perceptiblemente más largo que el "trazo", reforzando la sensación de cordel y no de línea.

Ninguno de estos números cae de una fórmula; salen del ensayo en pantalla. El sentido común sirve aquí más que cualquier paper: ajusté MIN_DIST mirándome a mí mismo dibujar círculos con el ratón hasta que ningún ángulo se notase.

Closure

Lo que arrancó como un capricho cosmético, "que el cursor deje cola", acabó pidiéndome un repaso silencioso de splines de los setenta, conversiones a Bézier que cualquier ingeniero gráfico conoce de memoria y un easing pensado para botones de Flash 8. La matemática útil casi nunca es nueva: lleva décadas esperando a que alguien la necesite. Catmull y Rom publicaron su algoritmo en 1974, el año en que Honda lanzó el Civic y Donald Knuth empezó a escribir TeX. Su spline ha sobrevivido a ambas cosas y, mientras lees esto, está dibujando la cola que persigue a tu ratón.