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.
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 actualctx.setTransform(a, b, c, d, e, f); // SUSTITUYE la matriz actualtransform 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); // identidadctx.setTransform(dpr, 0, 0, dpr, 0, 0); // solo la escala de densidadAmbos 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 cursivactx.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(); // identidadm.translateSelf(100, 50); // muta el objetom.rotateSelf(30); // en GRADOS, ojom.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.
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.