Saltar al contenido

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.

La función, y después el porqué

JS
/** * 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.

JS
// Sin escalar el contexto: el ratio contamina todofunction 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 ratiofunction 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.

JS
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:

JS
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.

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.

HTML
<!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.