translate, rotate y scale: el orden lo es todo
Usar las tres transformaciones básicas del canvas entendiendo que se acumulan sobre la matriz actual, y por qué el orden en que las escribes cambia el resultado.
Las tres transformaciones básicas del canvas parecen sencillas hasta que combinas dos y el resultado no está donde debería. La causa siempre es la misma: no son operaciones sobre la forma, son modificaciones acumulativas del sistema de coordenadas, y el sistema de coordenadas que modifican es el que dejó la operación anterior. Entender eso convierte la trigonometría a mano en tres líneas legibles.
- Explicar cada transformación como una modificación del sistema de coordenadas, no de la forma.
- Predecir el resultado de una secuencia de transformaciones sin ejecutarla.
- Rotar y escalar alrededor de un punto arbitrario con el patrón de tres pasos.
- Reconocer los efectos secundarios de
scalesobre grosores, fuentes y guiones.
No transforman la forma, transforman el espacio
La primera corrección de modelo mental: ctx.rotate(0.3) no gira nada. Modifica la matriz del contexto de modo que todas las coordenadas que uses a partir de ese momento se interpreten en un sistema girado.
ctx.fillRect(20, 20, 60, 40); // rectangulo normal
ctx.rotate(Math.PI / 6);
ctx.fillRect(20, 20, 60, 40); // MISMAS coordenadas, sale girado y desplazado
El segundo rectángulo usa las mismas coordenadas y aparece en otro sitio, porque el punto (20, 20) del sistema girado no es el mismo punto de la pantalla que el (20, 20) del sistema original.
De ahí se deduce el hecho más importante: rotate gira alrededor del origen del sistema actual, no alrededor de la forma. Lo mismo vale para scale. Y como el origen suele estar en la esquina superior izquierda del canvas, una rotación ingenua manda las cosas lejos.
El patrón de tres pasos
Para transformar alrededor de un punto concreto, el patrón universal es: trasladar el origen a ese punto, transformar, y dibujar en coordenadas relativas.
function rectanguloGirado(ctx, cx, cy, w, h, angulo) {
ctx.save();
ctx.translate(cx, cy); // 1. el origen pasa a ser el centro
ctx.rotate(angulo); // 2. giramos ese sistema
ctx.fillRect(-w / 2, -h / 2, w, h); // 3. dibujamos centrado en el origen
ctx.restore();
}
El paso 3 es la clave: dibujas la forma como si estuviera en el origen, con coordenadas simétricas. La colocación y la orientación ya las resolvió la matriz.
Compara con la alternativa sin transformaciones, que exige calcular las cuatro esquinas a mano:
// Lo mismo sin transformaciones: trigonometria en cada vertice
function rectanguloGiradoAMano(ctx, cx, cy, w, h, a) {
const c = Math.cos(a), s = Math.sin(a);
const p = [[-w/2,-h/2],[w/2,-h/2],[w/2,h/2],[-w/2,h/2]].map(([x, y]) =>
[cx + x * c - y * s, cy + x * s + y * c]);
ctx.beginPath();
ctx.moveTo(p[0][0], p[0][1]);
for (let i = 1; i < 4; i++) ctx.lineTo(p[i][0], p[i][1]);
ctx.closePath();
ctx.fill();
}
Funciona, es más largo, y no escala: en cuanto la forma tiene una curva o un texto, calcular los puntos a mano deja de ser viable. Las transformaciones existen exactamente para no tener que hacer esto.
El orden
Las transformaciones se componen con la matriz actual, y como la composición de matrices no es conmutativa, el orden importa.
// A: trasladar y luego rotar
ctx.save();
ctx.translate(150, 100);
ctx.rotate(Math.PI / 4);
ctx.fillStyle = '#89b4fa';
ctx.fillRect(-30, -20, 60, 40);
ctx.restore();
// B: rotar y luego trasladar
ctx.save();
ctx.rotate(Math.PI / 4);
ctx.translate(150, 100);
ctx.fillStyle = '#f38ba8';
ctx.fillRect(-30, -20, 60, 40);
ctx.restore();
El caso A pone el rectángulo en (150, 100) y lo gira sobre sí mismo. El caso B gira el sistema primero, de modo que el desplazamiento de 150 unidades ocurre en la dirección girada, y el rectángulo acaba en un sitio completamente distinto.
La regla mnemotécnica que funciona: lee las transformaciones de arriba abajo como instrucciones para mover el sistema de coordenadas. Primero muevo el origen aquí, después giro esto que acabo de mover, después escalo esto que acabo de girar. Cada una opera sobre el resultado de las anteriores.
Hay una lectura alternativa —de abajo arriba, como transformaciones aplicadas a la forma— que también es correcta matemáticamente, pero es más difícil de sostener cuando hay cuatro o cinco operaciones anidadas.
scale y sus efectos secundarios
scale(sx, sy) multiplica el sistema de coordenadas. Los factores pueden ser distintos por eje y pueden ser negativos, lo que produce reflexiones:
ctx.translate(x, 0);
ctx.scale(-1, 1); // espejo horizontal
ctx.drawImage(sprite, 0, 0);
Lo que sorprende es todo lo que arrastra consigo. scale no solo afecta a las coordenadas: afecta a todo lo que se mide en unidades.
lineWidthse escala. Un grosor de 2 conscale(3, 3)sale de 6 píxeles.- El tamaño de fuente se escala. Una fuente de
16pxsale a 48. - Los guiones de
setLineDashse escalan. - El desenfoque y el desplazamiento de sombra se escalan.
- Los gradientes y patrones se escalan, porque sus coordenadas son del espacio actual.
Casi siempre es lo que quieres: es lo que hace que el patrón de la densidad de pantalla funcione sin tocar nada más. Pero hay un caso en que no lo es: el escalado no uniforme deforma los trazos.
ctx.scale(4, 1);
ctx.lineWidth = 2;
ctx.strokeRect(10, 10, 30, 60);
// Los lados verticales salen de 8px de grosor y los horizontales de 2px
El trazo se calcula en el espacio transformado, así que un escalado desigual produce un contorno de grosor variable. Si quieres un contorno uniforme sobre una forma estirada, tienes que trazar fuera del escalado, lo que exige construir la ruta ya deformada o restaurar la matriz antes de trazar.
ctx.save();
ctx.scale(4, 1);
ctx.beginPath();
ctx.rect(10, 10, 30, 60);
ctx.restore(); // la ruta ya esta construida en coordenadas absolutas
ctx.lineWidth = 2;
ctx.stroke(); // grosor uniforme
Ese truco funciona porque la ruta se almacena en coordenadas del dispositivo: cada punto se transforma con la matriz vigente en el momento de añadirlo, no en el momento de pintar. Es un detalle del modelo que casi nunca se documenta y que resuelve varios problemas.
El punto anterior merece desarrollo porque es de los pocos detalles de la especificación del canvas que abren posibilidades reales. Cuando llamas a ctx.lineTo(10, 20), el punto se transforma inmediatamente por la matriz actual y se guarda ya en el espacio del dispositivo. La ruta no recuerda las coordenadas que escribiste ni la matriz que había. Consecuencia uno: puedes construir una ruta cambiando la matriz entre llamadas, y cada segmento queda donde estaba cuando lo añadiste. Eso permite componer una forma a partir de piezas colocadas con transformaciones distintas, en una sola ruta y un solo fill, que es mucho más rápido que un fill por pieza. Consecuencia dos: cambiar la matriz después de construir la ruta y antes de pintarla no mueve la ruta. Es lo que hace posible el truco del trazo uniforme sobre una forma estirada. Consecuencia tres, la que muerde: isPointInPath y isPointInStroke sí aplican la matriz actual a las coordenadas del punto que les pasas, porque interpretan ese punto en el espacio de usuario vigente. Es decir, la ruta está en coordenadas de dispositivo y el punto que preguntas está en coordenadas de usuario, y el navegador los reconcilia usando la matriz del momento de la consulta. Si construiste la ruta con una matriz y consultas con otra, el resultado es incorrecto y no hay ningún aviso. La disciplina que lo evita es consultar siempre con la misma matriz con la que se pintó, lo que en la práctica significa hacer el hit testing dentro del mismo bloque save/restore que el dibujo, o —mucho mejor— restaurar la identidad y consultar en coordenadas de dispositivo.
Un ejemplo que junta las tres
<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="520" height="320" style="background:#1e1e2e"></canvas>
<script>
const ctx = document.getElementById('c').getContext('2d');
function engranaje(ctx, dientes, radio, color) {
ctx.beginPath();
for (let i = 0; i < dientes; i++) {
const a = (i * 2 * Math.PI) / dientes;
const b = ((i + 0.5) * 2 * Math.PI) / dientes;
ctx.lineTo(radio * 1.15 * Math.cos(a), radio * 1.15 * Math.sin(a));
ctx.lineTo(radio * Math.cos(b), radio * Math.sin(b));
}
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, radio * 0.3, 0, Math.PI * 2);
ctx.fillStyle = '#1e1e2e';
ctx.fill();
}
let t = 0;
function marco() {
ctx.clearRect(0, 0, 520, 320);
// Engranaje grande
ctx.save();
ctx.translate(200, 160);
ctx.rotate(t);
engranaje(ctx, 16, 90, '#89b4fa');
ctx.restore();
// Engranaje pequeno: gira al reves y mas rapido, escalado a la mitad
ctx.save();
ctx.translate(340, 160);
ctx.rotate(-t * 2 - Math.PI / 16);
ctx.scale(0.5, 0.5);
engranaje(ctx, 8, 90, '#f9e2af');
ctx.restore();
t += 0.008;
requestAnimationFrame(marco);
}
marco();
</script>
Fíjate en que la función engranaje dibuja siempre en el origen y sin rotación. Toda la colocación está en las transformaciones de fuera. Esa separación es lo que permite reutilizar la misma función para los dos engranajes con tamaños, posiciones y velocidades distintos, sin un solo parámetro de posición.