wandres.dev
TRANSFORMACIONES · La matriz del contexto

La matriz del contexto: transform, setTransform y getTransform

Trabajar directamente con la matriz de transformación, entender sus seis componentes, y usar DOMMatrix para componer, guardar y restaurar estados geométricos.

⏱ 17 min

Debajo de translate, rotate y scale hay una única matriz de seis números que el contexto mantiene y que puedes leer y escribir directamente. Conocerla no es un ejercicio académico: es lo que permite guardar un estado geométrico como dato, interpolar entre dos vistas, aplicar transformaciones inversas y hacer que el hit testing funcione en una escena con zoom.

🎯 Al terminar esta lección sabrás
  • Interpretar los seis componentes de la matriz de transformación 2D.
  • Distinguir transform de setTransform y elegir el correcto en cada caso.
  • Leer la matriz actual con getTransform y trabajar con DOMMatrix.
  • Componer, invertir y aplicar matrices sin depender del estado del contexto.

Seis números

Una transformación afín en 2D se representa con una matriz de 3 por 3, pero como la última fila es siempre (0, 0, 1), basta con seis valores. El canvas los llama a, b, c, d, e, f, en este orden:

| a  c  e |     x' = a·x + c·y + e
| b  d  f |     y' = b·x + d·y + f
| 0  0  1 |

Es decir: a y d son los factores de escala en X e Y, b y c son los de sesgo, y e y f son la traslación. En la nomenclatura moderna de DOMMatrix se llaman m11, m12, m21, m22, m41, m42, y ambas nomenclaturas conviven en el mismo objeto.

Las transformaciones básicas quedan así:

Operación a b c d e f
Identidad 1 0 0 1 0 0
translate(tx, ty) 1 0 0 1 tx ty
scale(sx, sy) sx 0 0 sy 0 0
rotate(θ) cos θ sin θ −sin θ cos θ 0 0

transform frente a setTransform

Esta es la distinción práctica más importante del tema:

ctx.transform(a, b, c, d, e, f);      // MULTIPLICA por la matriz actual
ctx.setTransform(a, b, c, d, e, f);   // SUSTITUYE la matriz actual

transform es una transformación más, del mismo tipo que translate o rotate, solo que arbitraria. Se acumula.

setTransform descarta lo que hubiera. Es la forma de decir “olvídate de todo, la matriz es exactamente esta”. Su uso más frecuente es volver a un estado conocido:

ctx.setTransform(1, 0, 0, 1, 0, 0);   // identidad
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);  // solo la escala de densidad

Ambos aceptan también un DOMMatrix o un objeto con las propiedades, que es mucho más legible:

ctx.setTransform(new DOMMatrix().scale(dpr).translate(camara.x, camara.y));
ctx.setTransform({ a: 2, b: 0, c: 0, d: 2, e: 100, f: 50 });

Una transformación que solo se puede hacer con transform es el sesgo, que no tiene método propio:

function sesgar(ctx, radX, radY) {
  ctx.transform(1, Math.tan(radY), Math.tan(radX), 1, 0, 0);
}

ctx.save();
ctx.translate(60, 60);
sesgar(ctx, -0.3, 0);          // inclinar en el eje X, tipo cursiva
ctx.fillStyle = '#cba6f7';
ctx.fillRect(0, 0, 120, 80);
ctx.restore();

getTransform y DOMMatrix

ctx.getTransform() devuelve un DOMMatrix con la matriz vigente. Es una copia: modificarlo no afecta al contexto.

DOMMatrix tiene una API completa de álgebra que evita escribir la multiplicación a mano:

const m = new DOMMatrix();          // identidad
m.translateSelf(100, 50);           // muta el objeto
m.rotateSelf(30);                   // en GRADOS, ojo
m.scaleSelf(2, 2);

const n = new DOMMatrix()           // version inmutable: devuelve nuevos objetos
  .translate(100, 50)
  .rotate(30)
  .scale(2);

const compuesta = n.multiply(otra);
const inversa = n.inverse();
const punto = n.transformPoint(new DOMPoint(10, 20));

Dos avisos sobre la API. El primero: los métodos de rotación de DOMMatrix toman grados, mientras que ctx.rotate toma radianes. Es una inconsistencia real de la plataforma y la causa de más de un bug. El segundo: los métodos terminados en Self mutan el objeto y devuelven this; los que no llevan sufijo devuelven un objeto nuevo. Mezclarlos produce efectos sorprendentes.

DOMMatrix también parsea la sintaxis de transformación de CSS, lo que resulta muy práctico:

const m = new DOMMatrix('translate(40px, 20px) rotate(15deg) scale(1.5)');
ctx.setTransform(m);

La cámara como matriz

El caso de uso que justifica todo lo anterior es una vista con desplazamiento y zoom. Modelar la cámara como una matriz en lugar de como dos números y un factor simplifica enormemente el código.

class Camara {
  constructor() { this.m = new DOMMatrix(); }

  aplicar(ctx, dpr = 1) {
    ctx.setTransform(new DOMMatrix().scale(dpr).multiply(this.m));
  }

  /** Zoom manteniendo fijo el punto de pantalla (px, py). */
  zoomEn(px, py, factor) {
    const p = this.aMundo(px, py);
    this.m = new DOMMatrix()
      .translate(px, py)
      .scale(factor)
      .translate(-px, -py)
      .multiply(this.m);
  }

  desplazar(dx, dy) {
    this.m = new DOMMatrix().translate(dx, dy).multiply(this.m);
  }

  /** De coordenadas de pantalla a coordenadas del mundo. */
  aMundo(px, py) {
    const p = this.m.inverse().transformPoint(new DOMPoint(px, py));
    return { x: p.x, y: p.y };
  }

  /** De coordenadas del mundo a coordenadas de pantalla. */
  aPantalla(x, y) {
    const p = this.m.transformPoint(new DOMPoint(x, y));
    return { x: p.x, y: p.y };
  }

  get escala() { return Math.hypot(this.m.a, this.m.b); }
}

Con esa clase, el bucle de dibujo y el manejo de eventos quedan así:

const camara = new Camara();

canvas.addEventListener('wheel', e => {
  e.preventDefault();
  const r = canvas.getBoundingClientRect();
  camara.zoomEn(e.clientX - r.left, e.clientY - r.top,
                e.deltaY < 0 ? 1.1 : 1 / 1.1);
  pintar();
}, { passive: false });

function pintar() {
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  camara.aplicar(ctx, window.devicePixelRatio || 1);
  for (const f of escena) { ctx.fillStyle = f.color; ctx.fillRect(f.x, f.y, f.w, f.h); }
}

El método zoomEn merece atención: la secuencia de trasladar al punto, escalar y volver a trasladar es el patrón universal para transformar alrededor de un pivote, y aquí se aplica a la matriz de la cámara en lugar de al contexto. Es lo que hace que el zoom con la rueda mantenga bajo el cursor el mismo punto del mundo, que es la única forma de zoom que se siente bien.

La matriz acumula error de redondeo, y en una aplicación de larga vida eso se ve

Componer matrices repetidamente sobre la misma matriz —que es exactamente lo que hace una cámara interactiva— acumula error de coma flotante. Cada zoom multiplica la matriz por otra, y cada multiplicación introduce un error del orden del epsilon de la doble precisión. Con unos pocos cientos de operaciones es irrelevante; con decenas de miles, que es lo que produce una sesión larga de trabajo en un editor, empiezan a aparecer efectos visibles: la escala en X y en Y divergen ligeramente, aparece un sesgo minúsculo que no debería estar, y los elementos que deberían estar alineados dejan de estarlo por una fracción de píxel. El síntoma clásico es que después de mucho rato de uso, un usuario reporta que “las cosas están un poco torcidas” y no hay forma de reproducirlo desde cero. La defensa no es aumentar la precisión, que no se puede, sino no acumular: guarda la cámara como parámetros escalares —posición X, posición Y, escala, ángulo— y reconstruye la matriz desde cero en cada uso. Reconstruir cuesta cuatro multiplicaciones y elimina la acumulación por completo, porque el error de cada reconstrucción es independiente y no se suma al anterior. La versión con matriz acumulada es más elegante y la de parámetros escalares es la correcta. Hay un segundo motivo para preferirla: los parámetros escalares se serializan, se interpolan y se depuran; una matriz de seis números no le dice nada a nadie cuando la ves en un log.

Interpolar entre dos vistas

Una ventaja concreta de tener la vista como dato es poder animar entre dos estados. Interpolar matrices componente a componente no funciona bien cuando hay rotación —produce deformaciones intermedias— pero interpolar los parámetros escalares sí:

function interpolarVista(a, b, t) {
  const s = t * t * (3 - 2 * t);       // suavizado
  return {
    x: a.x + (b.x - a.x) * s,
    y: a.y + (b.y - a.y) * s,
    // La escala se interpola en escala logaritmica: un zoom de 1 a 100
    // debe pasar por 10 en la mitad, no por 50.
    escala: Math.exp(Math.log(a.escala) + (Math.log(b.escala) - Math.log(a.escala)) * s),
  };
}

La interpolación logarítmica de la escala es un detalle que separa un zoom que se siente bien de uno que se siente mal. Con interpolación lineal, un zoom de 1× a 100× pasa la mitad del tiempo entre 50× y 100×, es decir, casi todo el movimiento aparente ocurre al principio. Con interpolación logarítmica la velocidad aparente es constante, que es lo que el ojo espera.