wandres.dev
TRANSFORMACIONES · La matriz del contexto

La pila de estado: save, restore y lo que de verdad guardan

Conocer la lista completa de lo que save y restore preservan, lo que no preservan, y los patrones que evitan el desbalanceo de la pila.

⏱ 16 min

Casi todo el mundo usa save y restore para las transformaciones y cree que eso es lo que hacen. En realidad guardan veinte propiedades más, incluida la región de recorte, y no guardan dos cosas que la gente da por hechas: la ruta actual y los píxeles. Esa asimetría explica varios bugs recurrentes y también un par de técnicas útiles.

🎯 Al terminar esta lección sabrás
  • Enumerar qué guarda exactamente la pila de estado y qué queda fuera.
  • Explicar por qué el recorte solo se puede deshacer con restore.
  • Detectar y evitar el desbalanceo de la pila en código anidado.
  • Elegir entre save/restore y la asignación explícita según el coste.

Qué se guarda

ctx.save() mete una copia del estado de dibujo en una pila; ctx.restore() saca la copia de arriba y la aplica. El estado de dibujo incluye:

  • La matriz de transformación completa.
  • La región de recorte vigente.
  • strokeStyle, fillStyle, globalAlpha, globalCompositeOperation.
  • lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset y el patrón de guiones.
  • shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor.
  • font, textAlign, textBaseline, direction, y los ajustes tipográficos adicionales del contexto.
  • imageSmoothingEnabled, imageSmoothingQuality.
  • filter.

Y no incluye dos cosas fundamentales:

  • La ruta actual. Un save seguido de construir una ruta y un restore deja la ruta construida. La ruta no es estado de dibujo.
  • El contenido del búfer. restore no deshace nada de lo que hayas pintado.
flowchart TB
base[Estado base] -->|save| p1[Pila con 1 copia]
p1 -->|save| p2[Pila con 2 copias]
p2 -->|restore| v1[Vuelve al estado de la copia 2]
v1 -->|restore| v0[Vuelve al estado base]
p2 --> guarda[Guarda matriz recorte estilos sombras fuente y suavizado]
p2 --> noguarda[No guarda la ruta actual ni los pixeles ya pintados]
style base fill:#89b4fa,color:#11111b
style p1 fill:#94e2d5,color:#11111b
style p2 fill:#94e2d5,color:#11111b
style v1 fill:#a6e3a1,color:#11111b
style v0 fill:#a6e3a1,color:#11111b
style guarda fill:#f9e2af,color:#11111b
style noguarda fill:#f38ba8,color:#11111b

El recorte: la razón por la que save es obligatorio

De todas las propiedades del estado, la región de recorte es especial porque no hay forma de quitarla directamente. No existe ctx.unclip(). Y clip() solo puede reducir la región vigente: llamarlo dos veces da la intersección de ambas, nunca la unión.

Eso deja exactamente dos maneras de volver a un canvas sin recorte: restore() desde un save() anterior al recorte, o reset(), que además destruye todo lo demás.

ctx.save();                              // imprescindible
ctx.beginPath();
ctx.arc(150, 150, 100, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(foto, 0, 0, 300, 300);     // recortada en circulo
ctx.restore();                            // unica forma de recuperar el canvas

Olvidar ese save es un error del que no se sale: todo lo que dibujes a partir de ahí queda confinado al círculo, para siempre, y el síntoma —“mi canvas dejó de pintar la mitad de las cosas”— apunta a cualquier sitio menos al recorte de cien líneas más arriba.

El desbalanceo

Cada save necesita su restore. Si sobran save, la pila crece sin control y el estado se va acumulando; si sobran restore, las llamadas de más se ignoran silenciosamente cuando la pila está vacía, sin error.

En código sencillo el balance es fácil de mantener. En código con retornos tempranos, excepciones o condicionales, no:

function pintarFigura(ctx, f) {
  ctx.save();
  ctx.translate(f.x, f.y);
  if (!f.visible) return;      // FUGA: sale sin restaurar
  ctx.fillRect(0, 0, f.w, f.h);
  ctx.restore();
}

Una fuga por fotograma en un bucle de animación deja la pila con miles de entradas en un minuto, y el estado acumulado produce un canvas que va derivando: cada figura aparece un poco más desplazada que la anterior.

Las dos defensas que funcionan:

// 1. try/finally: correcto siempre, incluso con excepciones
function conEstado(ctx, fn) {
  ctx.save();
  try { fn(ctx); } finally { ctx.restore(); }
}

conEstado(ctx, c => {
  c.translate(100, 50);
  c.rotate(0.4);
  c.fillRect(-20, -20, 40, 40);
});

// 2. Comprobacion en desarrollo: contar profundidad
let profundidad = 0;
const _save = ctx.save.bind(ctx), _restore = ctx.restore.bind(ctx);
ctx.save = () => { profundidad++; _save(); };
ctx.restore = () => { profundidad--; _restore(); };
// Al final de cada fotograma:
if (profundidad !== 0) console.error('Pila desbalanceada:', profundidad);

Ese contador de profundidad en desarrollo cuesta nada y detecta la fuga el día que se introduce, en lugar de tres semanas después.

save y restore no son gratis, y en el bucle caliente se nota

La disciplina de envolver cada figura en save/restore es correcta y hay un punto en el que deja de serlo. Cada save copia veinte y pico propiedades y una referencia a la región de recorte, y cada restore las restituye. Medido en aislamiento, un par cuesta del orden de decenas de nanosegundos, lo que parece despreciable. En un bucle que dibuja cincuenta mil elementos, son cien mil llamadas por fotograma, y a sesenta fotogramas por segundo son seis millones de operaciones de copia de estado por segundo dedicadas exclusivamente a preservar propiedades que en la mayoría de los casos nadie ha tocado. En escenas densas he visto ese par consumir entre un diez y un veinte por ciento del presupuesto del fotograma. La alternativa correcta no es quitar save/restore y rezar: es cambiar de estrategia en el bucle interior. Si las figuras solo difieren en posición y color, no necesitas ni transformaciones ni pila: asigna fillStyle y dibuja en coordenadas absolutas, agrupando por color. Si necesitas transformaciones por figura, setTransform con una matriz precalculada es más barato que save + translate + rotate + restore, porque sustituye la matriz de una vez en lugar de componer y copiar. Y si necesitas de verdad aislar el estado, hazlo una vez por grupo en lugar de por elemento. La regla resumida: save/restore por capa o por grupo, no por elemento; y en el bucle más interno, ninguno. Eso sí, esto es una optimización que solo se aplica después de medir: en una escena de cien elementos, envolver cada uno es lo correcto porque la legibilidad vale más que unos microsegundos.

El patrón de capas

El uso idiomático de la pila no es por elemento sino por capa: cada bloque del dibujo se aísla, configura lo que necesita y devuelve el contexto como estaba.

function pintar(ctx, escena, vista) {
  // Estado base: matriz de densidad, nada mas
  ctx.setTransform(vista.dpr, 0, 0, vista.dpr, 0, 0);
  ctx.clearRect(0, 0, vista.ancho, vista.alto);

  ctx.save();                       // capa: mundo
  ctx.translate(vista.x, vista.y);
  ctx.scale(vista.zoom, vista.zoom);
  pintarRejilla(ctx, vista);
  pintarFiguras(ctx, escena);
  ctx.restore();

  ctx.save();                       // capa: interfaz, sin zoom
  pintarMinimapa(ctx, escena, vista);
  pintarBarraEstado(ctx, vista);
  ctx.restore();
}

Dos pares de save/restore para toda la escena, y cada función interior sabe en qué espacio trabaja. La capa de interfaz no hereda el zoom del mundo, lo que es exactamente lo que quieres: los textos y los controles deben tener el mismo tamaño con cualquier nivel de zoom.

Restaurar sin pila

Hay una alternativa a la pila que conviene conocer para casos concretos: asignar explícitamente todo el estado al principio de cada bloque, sin depender de lo que hubiera.

function configurarEstadoBase(ctx, dpr) {
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.globalAlpha = 1;
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = '#000';
  ctx.strokeStyle = '#000';
  ctx.lineWidth = 1;
  ctx.lineCap = 'butt';
  ctx.lineJoin = 'miter';
  ctx.miterLimit = 10;
  ctx.setLineDash([]);
  ctx.lineDashOffset = 0;
  ctx.shadowBlur = 0;
  ctx.shadowColor = 'rgba(0,0,0,0)';
  ctx.font = '10px sans-serif';
  ctx.textAlign = 'start';
  ctx.textBaseline = 'alphabetic';
  ctx.imageSmoothingEnabled = true;
  ctx.filter = 'none';
}

Esa función es fea, larga y tiene un uso muy concreto: garantizar un estado conocido al arrancar un fotograma sin usar reset(), que además borraría el búfer. Es útil en aplicaciones donde varios módulos comparten el mismo contexto y no confías en que todos restauren. Y lo único que no puede restaurar es el recorte, que sigue necesitando restore o reset.