El patrón correcto de redimensionado
Escribir de una vez la función que dimensiona un canvas a los píxeles físicos exactos, escala el contexto y deja el resto del código trabajando en unidades lógicas.
Con los dos tamaños entendidos y la densidad detectable, queda escribir la función que los une. Es una función de quince líneas que casi todo el mundo escribe mal la primera vez, porque tiene cuatro detalles que no son evidentes: cuándo redondear, dónde escalar, por qué comprobar antes de asignar, y qué se rompe si vuelves a llamarla.
- Escribir la función de redimensionado que alinea el búfer con los píxeles físicos.
- Explicar por qué hay que escalar el contexto y no las coordenadas.
- Evitar el reseteo innecesario del búfer comprobando antes de asignar.
- Integrar el redimensionado en un ciclo de dibujo sin duplicar trabajo.
La función, y después el porqué
/**
* Ajusta el buffer del canvas a los pixeles fisicos que ocupa su caja
* y escala el contexto para que el resto del codigo trabaje en pixeles CSS.
* Devuelve true si el buffer cambio de tamano.
*/
function ajustar(canvas, ctx, dpr = window.devicePixelRatio || 1) {
const rect = canvas.getBoundingClientRect();
const anchoDeseado = Math.max(1, Math.round(rect.width * dpr));
const altoDeseado = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width === anchoDeseado && canvas.height === altoDeseado) {
return false; // ya esta bien, no tocar nada
}
canvas.width = anchoDeseado; // esto BORRA el canvas y resetea el estado
canvas.height = altoDeseado;
// A partir de aqui una unidad del contexto es un pixel CSS
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return true;
}
Cuatro decisiones, una por línea significativa.
getBoundingClientRect y no clientWidth. El rectángulo devuelve valores fraccionarios y ya incluye las transformaciones CSS de los ancestros; clientWidth devuelve un entero redondeado y no las incluye. Con un layout de porcentajes o de grid, el tamaño real casi nunca es entero, y redondear en el sitio equivocado desalinea la rejilla.
Math.round y no Math.floor ni nada. El búfer solo puede tener un número entero de muestras. Redondear al más cercano minimiza el error de escalado; truncar lo sesga siempre a la baja y acumula un desajuste visible en canvas grandes.
La comprobación antes de asignar. Escribir en canvas.width siempre borra el búfer y resetea el estado del contexto, aunque asignes el mismo valor que ya tenía. Sin esa guarda, llamar a la función en cada fotograma destruye el canvas sesenta veces por segundo, con un coste enorme y con la pérdida de cualquier estado que hubieras configurado.
setTransform y no scale. scale multiplica la matriz actual; setTransform la sustituye. Como la asignación de width acaba de resetear la matriz a la identidad, en este caso concreto las dos harían lo mismo, pero setTransform es idempotente y no se rompe si el orden cambia. Es la elección que no te va a morder dentro de seis meses.
Por qué escalar el contexto
Podrías no escalar y multiplicar cada coordenada por el ratio al dibujar. Es una idea terrible por tres razones.
La primera es que el ratio se cuela en toda la aplicación. Cada función de dibujo pasa a necesitar el ratio como parámetro, y cada olvido produce un elemento en el sitio equivocado.
La segunda es que algunas cosas no se pueden multiplicar a mano fácilmente: el tamaño de fuente en la cadena de font, el radio de un arco, el desplazamiento de una sombra, la longitud de los guiones de una línea discontinua. Escalar el contexto las escala todas a la vez y de forma coherente.
La tercera es que el código deja de ser portable. Una función que dibuja en unidades lógicas sirve igual para la pantalla, para un canvas de impresión a 4×, para un canvas de miniatura a 0,25× y para un test. Una función que multiplica por el ratio sirve solo para la pantalla.
// Sin escalar el contexto: el ratio contamina todo
function dibujarMal(ctx, dpr) {
ctx.font = `${16 * dpr}px system-ui`;
ctx.fillText('Hola', 20 * dpr, 40 * dpr);
ctx.lineWidth = 2 * dpr;
ctx.setLineDash([6 * dpr, 4 * dpr]);
}
// Con el contexto escalado: el codigo no sabe que existe el ratio
function dibujarBien(ctx) {
ctx.font = '16px system-ui';
ctx.fillText('Hola', 20, 40);
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
}
Integrarlo en el ciclo
El redimensionado tiene que ocurrir antes de dibujar y solo cuando haga falta. El patrón que funciona es llamar a ajustar al principio de cada pintado: como la función comprueba antes de tocar nada, el coste cuando no hay cambios es una lectura del rectángulo, que es barata pero no gratis.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
function pintar() {
ajustar(canvas, ctx);
// A partir de aqui trabajamos en pixeles CSS
const w = canvas.width / (window.devicePixelRatio || 1);
const h = canvas.height / (window.devicePixelRatio || 1);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#89b4fa';
ctx.fillRect(20, 20, w - 40, h - 40);
ctx.fillStyle = '#11111b';
ctx.font = '16px system-ui';
ctx.fillText(`${w.toFixed(0)} x ${h.toFixed(0)} CSS`, 36, 48);
}
Ese cálculo de w y h repetido es feo y es fácil de equivocar. Merece la pena que la función de ajuste devuelva las dimensiones lógicas, y de paso guardarlas:
function ajustar(canvas, ctx, dpr = window.devicePixelRatio || 1) {
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
const cambio = canvas.width !== w || canvas.height !== h;
if (cambio) {
canvas.width = w;
canvas.height = h;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { cambio, ancho: w / dpr, alto: h / dpr, dpr };
}
Fíjate en que ahora setTransform se llama siempre, no solo cuando hay cambio. Es deliberado: garantiza que la matriz esté en el estado esperado al empezar a pintar, aunque algo la haya modificado antes, y cuesta prácticamente nada.
Hay un fallo de este patrón que aparece en cuanto el canvas vive dentro de un contenedor flexible y que desconcierta durante horas porque el síntoma es que el canvas crece sin parar o parpadea. La causa es un bucle de realimentación de layout. El canvas es un elemento reemplazado: su tamaño intrínseco es el del búfer, y en muchos contextos de layout el tamaño intrínseco participa en el cálculo del tamaño de la caja. Entonces ocurre esto: mides la caja, pones el búfer a ese tamaño por el ratio, el tamaño intrínseco crece, el layout recalcula y la caja crece un poco, vuelves a medir, el búfer crece más. En un flex container sin min-width: 0 o en un grid con columnas de tamaño automático, la realimentación es inmediata y visible. La defensa es romper el vínculo entre el tamaño intrínseco y el layout, y hay dos formas fiables. La primera es fijar el tamaño CSS del canvas explícitamente en la misma pasada, con canvas.style.width = ancho + 'px', de modo que el tamaño intrínseco deje de importar. La segunda, más limpia, es sacar al canvas del flujo: envolverlo en un contenedor con position: relative y darle al canvas position: absolute; inset: 0; width: 100%; height: 100%. Así el canvas mide siempre lo que mide el contenedor y su búfer no puede influir en nada. Ese envoltorio es feo y es la razón de que casi todas las bibliotecas de gráficos lo usen. Cuando veas un canvas que crece solo, no busques el error en tu aritmética: busca quién está leyendo el tamaño intrínseco.
El envoltorio que evita el bucle
Por su importancia, este es el marcado completo que hay que usar para un canvas responsivo. Funciona pegado tal cual.
<!doctype html>
<meta charset="utf-8">
<style>
html, body { margin: 0; height: 100%; background: #1e1e2e; }
.lienzo { position: relative; width: 100%; height: 60vh; }
.lienzo > canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
</style>
<div class="lienzo"><canvas id="c">Un rectángulo azul.</canvas></div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
function ajustar() {
const dpr = window.devicePixelRatio || 1;
const r = canvas.getBoundingClientRect();
const w = Math.max(1, Math.round(r.width * dpr));
const h = Math.max(1, Math.round(r.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ancho: w / dpr, alto: h / dpr };
}
function pintar() {
const { ancho, alto } = ajustar();
ctx.clearRect(0, 0, ancho, alto);
ctx.strokeStyle = '#89b4fa';
ctx.lineWidth = 1;
for (let x = 0.5; x < ancho; x += 24) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, alto); ctx.stroke();
}
ctx.fillStyle = '#cdd6f4';
ctx.font = '16px system-ui';
ctx.fillText(`${ancho.toFixed(0)} × ${alto.toFixed(0)} CSS · dpr ${window.devicePixelRatio}`, 16, 28);
}
addEventListener('resize', pintar);
pintar();
</script>
Cambia el zoom del navegador y observa: la rejilla sigue siendo de un píxel nítido y el texto sigue afilado, porque el búfer se reconstruye a la nueva densidad. Ese es todo el objetivo del nivel.
El addEventListener('resize', ...) de este ejemplo funciona porque el contenedor depende del tamaño de la ventana. En cuanto el canvas viva dentro de un panel redimensionable, de un acordeón o de un contenedor cuyo tamaño no dependa de la ventana, ese evento no se dispara y hace falta otra cosa. Eso es lo siguiente.