Integrar el muelle frame a frame
Implementar un muelle desde cero: por qué el Euler explícito explota, cómo lo arregla el semi-implícito, el acumulador de paso fijo para sobrevivir al framerate variable, y los umbrales de parada correctos.
Un motor de muelles de verdad son unas cuarenta líneas, y treinta y cinco de ellas son las que separan un juguete de algo que se puede meter en producción. Las cinco primeras —fuerza, aceleración, velocidad, posición— las escribe cualquiera. Las otras treinta y cinco resuelven tres problemas que solo aparecen cuando el código lleva un rato corriendo: que el método de integración obvio añade energía en vez de disiparla, que el intervalo entre frames no es constante y que decidir cuándo parar es más sutil de lo que parece.
- Explicar por qué el Euler explícito inyecta energía y cuándo el muelle diverge.
- Implementar el Euler semi-implícito y comprobar que conserva energía mucho mejor.
- Desacoplar el paso de simulación del intervalo entre frames con un acumulador.
- Definir un criterio de parada con dos umbrales y evitar el asentamiento infinito.
El Euler explícito y por qué explota
La traducción literal de la ecuación a código es esta:
// Euler explicito. NO uses esto.
function pasoExplicito(s, objetivo, k, c, m, dt) {
const a = (-k * (s.x - objetivo) - c * s.v) / m;
const x = s.x + s.v * dt; // usa la velocidad VIEJA
const v = s.v + a * dt;
return { x, v };
}
Actualiza la posición con la velocidad del principio del paso y la velocidad con la aceleración del principio del paso. Es lo que sale de escribir las dos derivadas por separado, y es inestable para el oscilador.
La razón es geométrica. En el plano de fases —posición en un eje, velocidad en el otro— un oscilador sin amortiguar describe una elipse cerrada: la energía se conserva. El Euler explícito, en cada paso, avanza por la tangente a esa elipse, y como la elipse es convexa, la tangente siempre sale hacia fuera. Cada paso añade un poco de energía. La trayectoria no es una elipse sino una espiral que crece, y con dt suficientemente grande crece rápido.
La cifra exacta se puede calcular. Para el oscilador sin amortiguar, la matriz de amplificación del Euler explícito tiene autovalores de módulo √(1 + (ω₀·dt)²), que es mayor que uno para cualquier dt positivo. El método no es condicionalmente inestable: es incondicionalmente inestable. Reducir el paso no arregla el problema, solo lo hace más lento: con ω₀ = 40 rad/s y un paso de 20 ms la amplitud crece un 28% en cada paso, lo que son cuatro órdenes de magnitud en cuarenta pasos.
// Demostracion de la divergencia. omega0 = 40 rad/s, sin amortiguamiento.
let s = { x: 1, v: 0 };
for (let i = 0; i < 40; i++) s = pasoExplicito(s, 0, 1600, 0, 1, 0.02);
console.log(s.x.toFixed(3)); // -5590.544 partiendo de 1
Con amortiguamiento real la disipación compensa parte de esa inyección y el método sobrevive un rato, que es por lo que este error llega a producción: funciona en el portátil del desarrollador con muelles blandos y explota en un móvil que pierde frames con un muelle rígido. Y no falla de forma elegante: la posición pasa a Infinity y de ahí a NaN, y el elemento desaparece de la pantalla.
El semi-implícito: cambiar dos líneas de orden
La corrección es absurdamente pequeña: actualiza la velocidad primero y usa la velocidad ya actualizada para mover la posición.
// Euler semi-implicito, tambien llamado simplectico o de Cromer.
function paso(s, objetivo, k, c, m, dt) {
const a = (-k * (s.x - objetivo) - c * s.v) / m;
const v = s.v + a * dt; // primero la velocidad
const x = s.x + v * dt; // y se usa la NUEVA para la posicion
return { x, v };
}
Es el mismo coste computacional y una propiedad radicalmente distinta: el método es simpléctico, lo que significa que conserva una cantidad muy próxima a la energía en vez de dejarla crecer. La trayectoria en el plano de fases no es exactamente la elipse correcta, sino otra elipse ligeramente distinta que el método recorre indefinidamente sin escaparse. Para animación eso es exactamente lo que se necesita: un pequeño error de fase es invisible, un crecimiento sin límite no.
// El mismo muelle rigido sin amortiguar, paso de 40 ms.
let ex = { x: 1, v: 0 }, si = { x: 1, v: 0 };
for (let i = 0; i < 10; i++) {
ex = pasoExplicito(ex, 0, 1600, 0, 1, 0.04);
si = paso(si, 0, 1600, 0, 1, 0.04);
console.log(i, ex.x.toFixed(2), si.x.toFixed(2));
}
// 0 1.00 -1.56 5 44.13 1.45
// 1 -1.56 -0.13 6 59.18 0.38
// 2 -6.68 1.63 7 -38.74 -1.66
// 3 -7.81 -0.79 8 -288.15 0.56
// 4 8.17 -1.19 9 -438.38 1.35
El explícito ya va por los cuatrocientos partiendo de uno; el semi-implícito sigue oscilando entre menos dos y dos. La precisión del semi-implícito con un paso tan grande es mala —hay un error de fase evidente— pero está acotada, y esa es la propiedad que permite dormir tranquilo. Su región de estabilidad es ω₀·dt < 2, un criterio que sí puedes comprobar: con ω₀ = 40 el paso máximo es de 50 ms, y justo en esa frontera el método es marginalmente estable y la amplitud crece de forma lineal en vez de exponencial.
La reacción instintiva ante un integrador impreciso es subir el orden, y RK4 es cuatro veces más caro por paso y no es simpléctico: con amortiguamiento nulo también pierde energía lentamente, solo que hacia abajo en vez de hacia arriba. Para un sistema conservativo que tiene que correr indefinidamente, un método simpléctico de primer orden es mejor elección que uno no simpléctico de cuarto. Para animación de interfaz, donde siempre hay amortiguamiento y la animación dura menos de dos segundos, el semi-implícito con paso fijo pequeño es suficiente y es lo que usan las bibliotecas serias.
El acumulador de paso fijo
El segundo problema es que requestAnimationFrame no entrega intervalos constantes. Hay frames de 16,7 ms, de 33 ms cuando se cae uno, y de 4 segundos cuando el usuario vuelve de otra pestaña. Si alimentas el integrador con el dt real, la simulación depende del framerate: el mismo muelle se comporta distinto a 60 Hz que a 120 Hz, y eso es inaceptable porque significa que tu animación se ve diferente según el monitor.
La solución estándar es desacoplar el reloj de la simulación del reloj de los frames. Acumulas el tiempo transcurrido y consumes ese acumulador en pasos de tamaño fijo:
const PASO = 1 / 240; // 4,17 ms: cuatro subpasos por frame a 60 Hz
const MAX_ACUM = 0.1; // nunca simular mas de 100 ms de golpe
function crearMuelle({ x = 0, objetivo = 0, k = 200, c = 20, m = 1 }) {
let s = { x, v: 0 };
let acumulador = 0;
return {
get x() { return s.x; },
get v() { return s.v; },
set objetivo(valor) { objetivo = valor; }, // cambiar destino no reinicia nada
avanzar(dt) {
acumulador = Math.min(acumulador + dt, MAX_ACUM);
while (acumulador >= PASO) {
s = paso(s, objetivo, k, c, m, PASO);
acumulador -= PASO;
}
return s;
},
};
}
El tope de acumulador es imprescindible. Sin él, volver a una pestaña que llevaba diez minutos en segundo plano dispara 144.000 subpasos en un solo frame y la página se congela varios segundos. Con el tope, la simulación se salta ese tiempo: el muelle aparece donde estaba, no donde habría estado, que es lo correcto porque nadie lo estaba mirando.
El paso de 1/240 no es mágico. La regla es que PASO debe ser bastante menor que el periodo natural del muelle más rápido que vayas a simular: PASO < T₀/10 es un buen punto de partida, y con ω₀ = 40 rad/s eso da T₀ = 0,157 s y un paso máximo de 15 ms. Bajarlo a 4 ms deja margen para muelles bastante más rígidos con un coste despreciable.
El bucle completo, listo para pegar:
const muelle = crearMuelle({ x: 0, objetivo: 300, k: 200, c: 20, m: 1 });
const caja = document.querySelector('.caja');
let anterior = performance.now();
let handle = 0;
function frame(ahora) {
const dt = (ahora - anterior) / 1000;
anterior = ahora;
const s = muelle.avanzar(dt);
caja.style.transform = `translateX(${s.x}px)`;
if (enReposo(s, 300)) {
caja.style.transform = `translateX(300px)`; // aterrizaje exacto
return;
}
handle = requestAnimationFrame(frame);
}
handle = requestAnimationFrame(frame);
Cuándo parar
Un muelle amortiguado nunca llega. La exponencial tiende a cero y no lo alcanza, así que sin criterio de parada el bucle corre para siempre consumiendo batería para mover cosas centésimas de píxel.
El criterio correcto tiene dos condiciones simultáneas, y las dos son necesarias:
const EPS_X = 0.5; // medio pixel
const EPS_V = 1.0; // un pixel por segundo
function enReposo(s, objetivo) {
return Math.abs(s.x - objetivo) < EPS_X && Math.abs(s.v) < EPS_V;
}
Solo con la distancia, un muelle subamortiguado se pararía al cruzar el objetivo a toda velocidad, cortando el rebote de golpe. Solo con la velocidad, se pararía en el punto más alto del rebote, que es justo donde la velocidad es cero y la distancia máxima. Hacen falta las dos.
Los umbrales van en unidades de pantalla, no en fracciones, por el motivo que ya vimos: lo que importa es si se ve, y medio píxel no se ve en ninguna pantalla. Si el muelle anima algo que no son píxeles —una opacidad entre 0 y 1, un tono de color— hay que escalar los umbrales al rango de la magnitud, porque medio píxel de opacidad sería un umbral cincuenta veces mayor que el recorrido entero.
Y el detalle que casi todo el mundo olvida: al parar, escribe el valor exacto del objetivo. Si te limitas a dejar de llamar a requestAnimationFrame, el elemento se queda a 0,4 píxeles de su sitio para siempre, y ese error se acumula si el siguiente movimiento parte de ahí.
El primero es el muelle que nunca se duerme: umbrales demasiado estrictos combinados con un objetivo que se recalcula cada frame por un redondeo de layout, con lo que la condición de reposo nunca se cumple y tienes un requestAnimationFrame eterno en cada tarjeta de una lista de doscientas. Se detecta mirando el perfil de rendimiento en reposo: si hay actividad con la página quieta, tienes muelles zombis.
El segundo es el salto al volver de segundo plano, que el tope del acumulador resuelve solo si de verdad lo pusiste, y que se manifiesta como un tirón de medio segundo al cambiar de pestaña.
El tercero es el más sutil: el muelle que se reinicia en cada render. Si el estado del muelle vive dentro de un componente que se vuelve a crear al cambiar una prop, cada render construye un muelle nuevo con velocidad cero, y el resultado es una animación que arranca y se para constantemente sin llegar nunca. El estado de un muelle es exactamente lo que no puede vivir en el ciclo de render: tiene que estar en una referencia estable, y el objetivo tiene que ser lo único que se actualice desde fuera. Esa es la razón de que la API que expusimos arriba tenga un setter para objetivo y ninguno para x ni para v.
Compara los tres integradores contra la solución exacta: implementa el explícito, el semi-implícito y la fórmula cerrada, simula un segundo con k = 200, m = 1 y ζ = 0,3, y mide el error máximo de cada uno para dt de 16, 8, 4 y 2 milisegundos. Con el semi-implícito deberías obtener errores máximos de 1,07e-1, 5,23e-2, 2,59e-2 y 1,29e-2: se parte por la mitad al partir el paso por la mitad, que es la firma de un método de primer orden.