wandres.dev
FORMAS Y RUTAS · El modelo de path

Por qué el orden importa: acumulación, pintado y estado

Ver las tres formas en que el orden de las llamadas cambia el resultado en canvas, y las reglas que evitan que un bucle de dibujo se degrade sin avisar.

⏱ 16 min

En una API declarativa el orden de las declaraciones importa poco; en una API imperativa de modo inmediato importa todo. En el canvas hay tres órdenes distintos que afectan al resultado: el de construcción de la ruta, el de pintado sobre el búfer y el de las asignaciones de estado. Cada uno tiene su lógica y cada uno produce una clase de bug característica.

🎯 Al terminar esta lección sabrás
  • Distinguir los tres tipos de dependencia del orden en la API del canvas.
  • Reconocer la degradación cuadrática por acumulación de ruta y su síntoma.
  • Ordenar las operaciones de pintado para conseguir el apilamiento correcto.
  • Escribir un bucle de dibujo con las guardas que impiden los tres errores.

Orden 1: la construcción de la ruta

La ruta actual acumula. Cada llamada de construcción añade a lo que ya había, y el punto actual condiciona lo que hacen las siguientes.

ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(80, 20);
ctx.arc(80, 60, 40, 0, Math.PI / 2);

Ese arc no empieza donde tú crees: empieza en (120, 60), que es el punto del arco a ángulo cero, y como el punto actual era (80, 20), se añade una línea recta entre ambos. Cambiar el orden de las dos últimas llamadas produce una figura completamente distinta.

La regla que evita todos los problemas de este tipo es hacer explícito el punto de partida de cada elemento:

ctx.beginPath();
ctx.moveTo(20, 20); ctx.lineTo(80, 20);
ctx.moveTo(120, 60); ctx.arc(80, 60, 40, 0, Math.PI / 2);

Orden 2: el pintado sobre el búfer

Lo que se pinta después tapa a lo anterior. No hay z-index, no hay reordenación: hay el orden en que llamaste.

Esto obliga a pensar la escena en capas y a pintarlas de atrás hacia adelante:

function pintar(escena) {
  ctx.clearRect(0, 0, W, H);
  pintarFondo();                 // 1. lo que va detras de todo
  pintarRejilla();               // 2. referencia
  for (const f of escena.figuras) pintarFigura(f);   // 3. contenido
  pintarSeleccion(escena);       // 4. adornos de estado
  pintarInterfaz();              // 5. lo que nunca se tapa
}

Cuando la escena tiene profundidad variable, el orden de pintado es una ordenación explícita de la lista antes de recorrerla:

const ordenadas = [...escena.figuras].sort((a, b) => a.z - b.z);
for (const f of ordenadas) pintarFigura(f);

Y hay un detalle de rendimiento que aparece aquí: ordenar en cada fotograma un array de miles de elementos cuesta. Si el orden cambia poco, mantén la lista ya ordenada y reinserta en el sitio correcto cuando algo cambie, en lugar de reordenar entera.

Un caso concreto del orden de pintado que confunde: el trazo se dibuja centrado en la ruta, así que la mitad exterior del borde queda fuera de la forma y la mitad interior queda encima del relleno. Por eso fill seguido de stroke se ve distinto de stroke seguido de fill:

// A: relleno y luego borde -> el borde tapa el relleno por dentro
ctx.fill(); ctx.stroke();

// B: borde y luego relleno -> el relleno tapa la mitad interior del borde,
//    el borde se ve la mitad de grueso
ctx.stroke(); ctx.fill();

La variante A es la que casi siempre quieres. La B es útil cuando buscas un borde exactamente exterior sin recurrir a recortes.

Orden 3: las asignaciones de estado

El estado se lee en el momento del dibujo. Una asignación posterior no afecta a lo ya dibujado, y una asignación anterior afecta a todo lo que venga después hasta que cambie.

ctx.fillStyle = '#89b4fa';
ctx.fillRect(10, 10, 40, 40);       // azul
ctx.fillRect(60, 10, 40, 40);       // tambien azul: el estado persiste
ctx.fillStyle = '#a6e3a1';
ctx.fillRect(110, 10, 40, 40);      // verde

La persistencia del estado es lo que hace posible agrupar dibujos por estilo y ahorrar cambios de estado, y es también lo que hace que una función de dibujo mal escrita contamine a las siguientes. La disciplina es simple: una función de dibujo o restaura todo lo que toca, o documenta que deja el contexto en un estado concreto. La forma barata de cumplirlo es envolver en save y restore.

function pintarFigura(f) {
  ctx.save();
  ctx.fillStyle = f.color;
  ctx.globalAlpha = f.opacidad;
  ctx.fillRect(f.x, f.y, f.w, f.h);
  ctx.restore();     // el contexto queda como estaba
}

Ese patrón tiene un coste —save y restore no son gratis— y en bucles muy calientes se sustituye por asignar explícitamente todos los valores que la función usa, sin depender de lo que hubiera. Pero como norma por defecto es el correcto.

La degradación cuadrática silenciosa: el bug que no da la cara hasta producción

Reunamos aquí el peor efecto del orden, porque merece un tratamiento completo. Considera este bucle, que parece impecable:

for (const p of puntos) {
  ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
  ctx.fill();
}

Falta beginPath. En la primera vuelta se rellena un círculo. En la segunda, la ruta contiene dos círculos —más el segmento que los une, porque arc conecta— y se rellenan los dos. En la vuelta N se rellenan N formas. El total es del orden de N al cuadrado operaciones de relleno. Con cien puntos son cinco mil rellenos en lugar de cien: imperceptible. Con cinco mil puntos son doce millones y medio: la pestaña se cuelga. Ahora la parte cruel. Durante el desarrollo trabajas con datos de prueba pequeños y todo va bien. El bug aparece con datos reales, en el navegador de un usuario, y se manifiesta como “la aplicación se congela con archivos grandes”, que es un síntoma que apunta a cualquier sitio menos al que es. Además, el resultado visual es correcto mientras el estilo no cambie entre iteraciones, así que ni siquiera hay una pista visual. Hay tres detectores fiables. El primero: si al cambiar el color a mitad de bucle todas las formas anteriores cambian de color, falta beginPath. El segundo: cronometra el bucle con N y con 2N; si el tiempo se multiplica por cuatro en lugar de por dos, tienes complejidad cuadrática. El tercero, el más directo: pon beginPath como primera línea de cada iteración y mide otra vez. Y la lección general que va más allá del canvas: en cualquier API con estado acumulativo, un olvido no produce un error, produce un coste, y los costes no se detectan con tests de corrección.

El bucle de dibujo con guardas

Poniendo las tres reglas juntas, este es el esqueleto que no se degrada:

function pintarEscena(ctx, escena, vista) {
  ctx.save();
  ctx.setTransform(vista.dpr, 0, 0, vista.dpr, 0, 0);
  ctx.clearRect(0, 0, vista.ancho, vista.alto);

  // Agrupar por estilo reduce los cambios de estado
  const porColor = new Map();
  for (const f of escena.figuras) {
    if (!visible(f, vista)) continue;              // culling antes que nada
    if (!porColor.has(f.color)) porColor.set(f.color, []);
    porColor.get(f.color).push(f);
  }

  for (const [color, grupo] of porColor) {
    ctx.fillStyle = color;
    ctx.beginPath();                                // UNA ruta por grupo
    for (const f of grupo) {
      ctx.moveTo(f.x + f.r, f.y);                   // moveTo explicito
      ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
    }
    ctx.fill();                                     // UN relleno por grupo
  }

  ctx.restore();
}

Ese bucle tiene cuatro propiedades que lo hacen escalar. El descarte de lo invisible ocurre antes de tocar el contexto. Los cambios de fillStyle son tantos como colores distintos, no como figuras. Hay una sola ruta y un solo fill por color, lo que aprovecha el modelo de subrutas en lugar de sufrirlo. Y el moveTo explícito antes de cada arc impide los segmentos de conexión.

La diferencia de rendimiento frente a la versión ingenua —un beginPath, un arc y un fill por figura— es de un factor que en escenas de decenas de miles de elementos ronda entre cinco y veinte veces, según el navegador. Y el código no es más largo.

⚔️ Reto práctico

Coge la versión ingenua y la agrupada, genera cincuenta mil círculos con diez colores distintos, y cronometra las dos con performance.now(). Después prueba una tercera variante: la agrupada pero con un beginPath y un fill por figura dentro de cada grupo. Los tres números te dirán cuánto cuesta cada cosa por separado: el cambio de estado, la llamada de relleno y la construcción de ruta.