Saltar al contenido

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.

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.

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

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

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

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

JS
// A: relleno y luego borde -> el borde tapa el relleno por dentroctx.fill(); ctx.stroke();// B: borde y luego relleno -> el relleno tapa la mitad interior del borde,//    el borde se ve la mitad de gruesoctx.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.

JS
ctx.fillStyle = '#89b4fa';ctx.fillRect(10, 10, 40, 40);       // azulctx.fillRect(60, 10, 40, 40);       // tambien azul: el estado persistectx.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.

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

El bucle de dibujo con guardas

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

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