update y draw: por qué son dos funciones y no una
Separar el avance del estado del pintado del estado, con las reglas que hacen que la separación se sostenga y el bucle canónico completo.
La primera animación que escribe cualquiera mezcla las dos cosas: mover la pelota y pintar la pelota en la misma línea. Funciona con una pelota y se derrumba con doscientas, no por rendimiento sino porque deja de haber ningún momento en el que el estado sea coherente. La separación entre avanzar el mundo y dibujarlo es la decisión de arquitectura de la que dependen la pausa, la repetición determinista, el paso fijo, la interpolación y las pruebas automáticas.
- Escribir un bucle con las fases claramente separadas y en el orden correcto.
- Aplicar las dos reglas que impiden que la separación se degrade con el tiempo.
- Entender qué significa exactamente la marca de tiempo que entrega el navegador.
- Reconocer los cuatro problemas que solo aparecen cuando las fases están mezcladas.
Las fases de un fotograma
flowchart TB raf[El navegador llama al callback con la marca de tiempo del fotograma] --> dt[Calcular el delta y acotarlo] dt --> entrada[Aplicar la entrada acumulada desde el fotograma anterior] entrada --> update[update avanza el estado y no toca el contexto] update --> draw[draw lee el estado y no lo modifica] draw --> compositor[El navegador compone la pagina y la presenta] compositor --> raf style raf fill:#89b4fa,color:#11111b style dt fill:#cba6f7,color:#11111b style entrada fill:#94e2d5,color:#11111b style update fill:#a6e3a1,color:#11111b style draw fill:#f9e2af,color:#11111b style compositor fill:#fab387,color:#11111b
El orden no es negociable y cada flecha tiene un motivo. La entrada se aplica antes de actualizar porque si se aplica después, la reacción del mundo a lo que ha hecho el usuario se retrasa un fotograma entero. El dibujo va después de actualizar porque dibujar un estado a medio actualizar produce objetos que se ven en posiciones que nunca ocuparon. Y el dibujo es lo último porque cualquier cosa que hagas después ya no se verá hasta el fotograma siguiente.
function marco(ahora) {
const dt = Math.min(50, ahora - anterior);
anterior = ahora;
aplicarEntrada(mundo, entrada);
actualizar(mundo, dt);
dibujar(ctx, mundo);
requestAnimationFrame(marco);
}
requestAnimationFrame(marco);
Ocho líneas que parecen una obviedad. Lo que las hace valiosas es lo que no hay dentro: ni una llamada a ctx en actualizar, ni una modificación de mundo en dibujar.
Las dos reglas
Regla uno: actualizar no conoce el contexto de dibujo. Ni lo recibe como parámetro, ni lo lee de una variable global, ni llama a measureText. Si actualizar necesita el ancho de un texto para colocar una caja, ese ancho se mide una vez y se guarda en el modelo, no se pregunta en cada fotograma.
Regla dos: dibujar no modifica el estado. Ni avanza contadores, ni acumula, ni marca banderas. Es una función del estado a los píxeles, y esa pureza es lo que permite llamarla dos veces con el mismo estado y obtener lo mismo.
La segunda regla se incumple casi siempre de la misma manera, y merece verlo:
// Mal: dibujar avanza el estado
function dibujar(ctx, mundo) {
for (const p of mundo.particulas) {
p.vida -= 1; // esto es actualizar, no dibujar
ctx.globalAlpha = p.vida / p.vidaMaxima;
ctx.fillRect(p.x, p.y, 3, 3);
}
}
Ese código funciona hasta el día que dibujas dos veces el mismo fotograma —al redimensionar, al restaurar tras un cambio de pestaña, al exportar una imagen— y las partículas envejecen al doble de velocidad sin motivo aparente. El fallo es difícil de encontrar precisamente porque el síntoma aparece lejos de la causa.
Las dos reglas juntas desbloquean cuatro capacidades que de otra forma exigen reescribir el bucle entero:
Pausar. Dejar de llamar a actualizar y seguir llamando a dibujar congela el mundo sin borrar la pantalla. Con las fases mezcladas, pausar significa dejar de dibujar, y entonces cualquier redimensionado deja el canvas en blanco.
Repetir de forma determinista. Con la misma secuencia de deltas y de entradas, actualizar produce siempre el mismo estado. Eso permite grabar una sesión y reproducirla, y es la base de la depuración de física.
Cambiar la frecuencia de una fase sin tocar la otra. Actualizar a paso fijo sesenta veces por segundo mientras se dibuja a la frecuencia real de la pantalla, que puede ser 120, exige que las fases sean independientes.
Probar sin navegador. actualizar es una función pura sobre datos; se ejecuta en un test sin canvas, sin DOM y sin tiempo real.
El argumento que recibe tu callback es un valor con una definición precisa que casi nadie lee: es la marca de tiempo del inicio del fotograma, la misma para todos los callbacks registrados en ese fotograma, y corresponde a un instante que ya ha pasado cuando tu código se ejecuta. Esa distinción parece filosófica y tiene tres consecuencias muy concretas. La primera: si dentro del bucle usas performance.now() en lugar del argumento para calcular el delta, estás midiendo desde que empezó el fotograma hasta que llegó tu código, y esa distancia varía según lo ocupado que esté el navegador. El resultado son deltas que oscilan aunque la pantalla vaya perfectamente estable, y una animación con un temblor sutil que nadie sabe explicar. La regla es simple: el delta se calcula siempre con el argumento del callback, nunca con el reloj. La segunda: si tienes dos bucles de animación en la misma página —algo más común de lo que parece, con dos componentes independientes— ambos reciben exactamente la misma marca de tiempo, con lo que sus animaciones están sincronizadas por construcción. Aprovecharlo es gratis; ignorarlo lleva a implementar sincronización a mano que ya existía. La tercera es la más útil para depurar: comparar el argumento con performance.now() al principio del callback te da, directamente, cuánto ha tardado el navegador en llegar a tu código desde que empezó el fotograma. Si ese número es de dos o tres milisegundos, tu bucle es lo primero que se ejecuta y vas bien. Si es de quince, hay otra cosa en la página consumiendo el fotograma antes que tú, y por muy rápido que dibujes vas a llegar tarde. Es una línea de diagnóstico que ahorra horas de optimizar el sitio equivocado. Y un matiz de precisión: el valor viene deliberadamente redondeado por motivos de seguridad, con una resolución que depende del aislamiento de origen del sitio, así que no lo uses para medir cosas de microsegundos; para eso está el panel de rendimiento.
El bucle completo
Todo junto y pegable: un mundo con partículas, las fases separadas, y una demostración de que dibujar dos veces el mismo estado da el mismo resultado.
<canvas id="c" style="width:640px;height:400px;display:block"></canvas>
<button id="pausa">Pausar</button>
<script type="module">
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
let ancho = 640, alto = 400;
function dimensionar() {
const caja = canvas.getBoundingClientRect();
ancho = caja.width; alto = caja.height;
canvas.width = Math.round(ancho * dpr);
canvas.height = Math.round(alto * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
dibujar(ctx, mundo); // se puede redibujar sin actualizar
}
const mundo = {
particulas: Array.from({ length: 400 }, () => ({
x: Math.random() * 640, y: Math.random() * 400,
vx: (Math.random() - 0.5) * 120, vy: (Math.random() - 0.5) * 120,
r: 1.5 + Math.random() * 2.5,
})),
tono: 210,
};
// --- Fase de estado: no conoce ctx ---
function actualizar(m, dt) {
const s = dt / 1000; // el delta llega en milisegundos
m.tono = (m.tono + 12 * s) % 360;
for (const p of m.particulas) {
p.x += p.vx * s;
p.y += p.vy * s;
if (p.x < 0) { p.x = 0; p.vx = -p.vx; }
if (p.x > ancho) { p.x = ancho; p.vx = -p.vx; }
if (p.y < 0) { p.y = 0; p.vy = -p.vy; }
if (p.y > alto) { p.y = alto; p.vy = -p.vy; }
}
}
// --- Fase de pintura: no modifica m ---
function dibujar(c, m) {
c.clearRect(0, 0, ancho, alto);
c.fillStyle = `oklch(0.75 0.14 ${m.tono})`;
for (const p of m.particulas) {
c.beginPath();
c.arc(p.x, p.y, p.r, 0, Math.PI * 2);
c.fill();
}
}
let anterior = performance.now();
let corriendo = true;
function marco(ahora) {
const dt = Math.min(50, ahora - anterior);
anterior = ahora;
if (corriendo) actualizar(mundo, dt);
dibujar(ctx, mundo);
requestAnimationFrame(marco);
}
document.getElementById('pausa').addEventListener('click', e => {
corriendo = !corriendo;
e.target.textContent = corriendo ? 'Pausar' : 'Reanudar';
});
new ResizeObserver(dimensionar).observe(canvas);
dimensionar();
requestAnimationFrame(marco);
</script>
Dos cosas para comprobar con este código. Pulsa pausa y redimensiona la ventana: el dibujo se rehace correctamente con el mundo congelado, porque dimensionar puede llamar a dibujar sin efectos secundarios. Y fíjate en que el bucle sigue llamando a dibujar durante la pausa, lo que cuesta un poco de GPU y a cambio mantiene la imagen viva ante cualquier cambio de tamaño; la variante que además deja de dibujar cuando nada cambia es el asunto de la última lección de este nivel.
Añade a actualizar un contador de fotogramas y a dibujar una llamada que lo pinte. Después llama a dibujar dos veces seguidas dentro del mismo fotograma y comprueba que el número no cambia. Ahora mueve el incremento del contador a dibujar y repite: el número salta de dos en dos. Ese es, en miniatura, el fallo que produce mezclar las fases.