Saltar al contenido

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.

Las fases de un fotograma

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.

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

JS
// Mal: dibujar avanza el estadofunction 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 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.

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