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.
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 normalctx.rotate(Math.PI / 6);ctx.fillRect(20, 20, 60, 40); // MISMAS coordenadas, sale girado y desplazadoEl 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 verticefunction 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 rotarctx.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 trasladarctx.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 horizontalctx.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 2pxEl 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 absolutasctx.lineWidth = 2;ctx.stroke(); // grosor uniformeEse 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.
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.