wandres.dev
RESOLUCIÓN Y DPI · El canvas nítido en pantallas retina

Los dos tamaños del canvas y por qué se ve borroso

Separar definitivamente el tamaño del elemento del tamaño del búfer, entender el escalado implícito que el navegador aplica entre ambos y diagnosticar el canvas borroso.

⏱ 17 min

El primer problema que tiene todo el mundo con canvas es que se ve borroso, y la causa es siempre la misma: hay dos tamaños y no coinciden. El elemento ocupa una caja en la página, medida en píxeles CSS; el búfer tiene una rejilla de muestras, medida en píxeles del búfer. Cuando difieren, el navegador estira o encoge el búfer como si fuera una imagen. Entender esa relación resuelve el borrón y también, de paso, el bug de que las coordenadas del ratón no coincidan con lo dibujado.

🎯 Al terminar esta lección sabrás
  • Distinguir con precisión el tamaño CSS del tamaño de búfer y decir quién controla cada uno.
  • Explicar el escalado implícito que el navegador aplica cuando ambos difieren.
  • Diagnosticar un canvas borroso a partir de la relación entre sus dos tamaños.
  • Convertir coordenadas de la página a coordenadas del búfer sin equivocarse.

Dos tamaños, dos dueños

El elemento <canvas> es un elemento reemplazado, de la misma familia que <img> y <video>. Eso significa que tiene un tamaño intrínseco propio y un tamaño de caja que el layout le asigna, y que si no coinciden, el contenido se ajusta.

El tamaño del búfer son los atributos width y height del elemento, o las propiedades del mismo nombre en JavaScript. Es el número real de muestras de color que existen. Su valor por defecto es 300 por 150.

canvas.width  = 800;   // 800 columnas de pixeles en el buffer
canvas.height = 600;   // 600 filas

El tamaño CSS es el que decide el layout: las propiedades width y height de CSS, o el resultado de un contenedor flexible. Se mide en píxeles CSS, que son unidades abstractas de la página.

canvas { width: 400px; height: 300px; }

Con esa combinación tienes un búfer de 800 por 600 mostrado en una caja de 400 por 300 píxeles CSS. El navegador reduce la imagen a la mitad. Y al revés: un búfer de 300 por 150 —el de por defecto— mostrado en una caja de 900 por 450 se amplía al triple, con la borrosidad que eso implica.

// Como leer cada uno
canvas.width;                          // 800  -> buffer
canvas.getBoundingClientRect().width;  // 400  -> caja en la pagina

El escalado implícito y sus tres casos

Cuando ambos tamaños difieren, el navegador aplica exactamente el mismo tratamiento que a una imagen: rasteriza el búfer y lo estira para rellenar la caja de contenido, respetando object-fit si lo has puesto. Hay tres regímenes.

Búfer más pequeño que la caja: borroso. Cada muestra del búfer se reparte entre varios píxeles de pantalla y hay que interpolar. Es el caso más frecuente y el que produce el aspecto característico de “esto parece una imagen de 2004”.

Búfer más grande que la caja: nítido pero caro. El resultado se ve bien, incluso mejor que en el caso ideal, porque hay más información de la necesaria y el filtrado suaviza. El coste es memoria y tiempo de rasterización proporcionales al área del búfer, que no ves.

Proporciones distintas: deformado. Si la relación de aspecto del búfer no coincide con la de la caja, la imagen se deforma. Un búfer cuadrado en una caja rectangular sale estirado.

El caso patológico más común es no poner nada de CSS y suponer que el canvas ocupará su contenedor. Sin reglas, el elemento se muestra a su tamaño intrínseco —el del búfer— y funciona. Pero en cuanto alguien añade width: 100% en una hoja de estilos genérica, el búfer sigue teniendo 300 por 150 y todo se estira.

/* Esta regla, escrita con la mejor intencion, rompe todos los canvas del sitio */
img, canvas, svg, video { max-width: 100%; height: auto; }

Diagnóstico en tres líneas

Ante un canvas borroso, esta comprobación resuelve el noventa por ciento de los casos:

function diagnosticar(canvas) {
  const r = canvas.getBoundingClientRect();
  const dpr = window.devicePixelRatio;
  console.table({
    'buffer':          `${canvas.width} x ${canvas.height}`,
    'caja CSS':        `${r.width.toFixed(1)} x ${r.height.toFixed(1)}`,
    'pixeles fisicos': `${(r.width * dpr).toFixed(1)} x ${(r.height * dpr).toFixed(1)}`,
    'devicePixelRatio': dpr,
    'escala aplicada':  (r.width / canvas.width).toFixed(3),
  });
}

Si la escala aplicada es distinta de 1, hay estiramiento. Si además el ratio de píxeles del dispositivo es mayor que 1, hay un segundo estiramiento encima que no se ve en esa cuenta y que es el tema de la lección siguiente.

La condición de nitidez perfecta es que el búfer tenga exactamente tantas muestras como píxeles físicos ocupa el elemento. Eso significa:

canvas.width  === rect.width  × devicePixelRatio
canvas.height === rect.height × devicePixelRatio

Cualquier desviación de esa igualdad produce interpolación.

El canvas nítido no es el que tiene el búfer más grande, es el que lo tiene alineado

Existe la creencia de que basta con hacer el búfer generoso —el doble, el triple— y dejar que el navegador reduzca, porque reducir se ve bien. Es parcialmente cierto y es una mala idea por dos razones que se suelen pasar por alto. La primera es el coste: un búfer al triple ocupa nueve veces más memoria y cuesta nueve veces más rasterizar, y en un móvil eso es la diferencia entre funcionar y no funcionar. La segunda es más sutil y más interesante: una reducción no entera no produce el mejor resultado posible, produce uno peor que la alineación exacta. Cuando el factor es 2,37, cada píxel de destino promedia una vecindad fraccionaria de muestras de origen, y el filtro que aplica el compositor no es un remuestreo de calidad sino uno rápido. El resultado es una imagen ligeramente sucia, con las líneas finas desiguales: unas se ven más gruesas que otras según dónde caigan. El síntoma delator es una rejilla en la que unas líneas se ven y otras casi desaparecen. La forma correcta no es “más grande”, es exactamente el número de píxeles físicos, redondeado a entero, y esa cifra hay que recalcularla cada vez que cambia el tamaño o la densidad. Todo lo demás es aproximar.

Las coordenadas del ratón, que es el mismo problema

El segundo síntoma de la desalineación entre tamaños no es visual sino de interacción: pulsas sobre una figura y el hit testing falla, o el trazo aparece desplazado del cursor.

La razón es que los eventos de puntero dan coordenadas en píxeles CSS relativas a la ventana, y el canvas dibuja en unidades de su búfer. Convertir de unas a otras exige conocer la posición del elemento y la relación entre ambos tamaños.

function aCoordenadasDelBuffer(canvas, evento) {
  const r = canvas.getBoundingClientRect();
  const escalaX = canvas.width / r.width;
  const escalaY = canvas.height / r.height;
  return {
    x: (evento.clientX - r.left) * escalaX,
    y: (evento.clientY - r.top) * escalaY,
  };
}

Esa función es correcta en todos los casos, incluidos los que la mayoría del código ignora: cuando el canvas está dentro de un contenedor con scroll, cuando tiene bordes o relleno, y cuando un ancestro tiene una transformación CSS de escala. getBoundingClientRect ya devuelve el rectángulo después de aplicar las transformaciones, así que la división por r.width absorbe cualquier escala externa.

Las alternativas que se ven por ahí son peores. offsetX y offsetY dan la posición relativa al elemento, pero en píxeles CSS y sin tener en cuenta la relación con el búfer, así que fallan en cuanto los tamaños difieren. Y canvas.offsetLeft no funciona dentro de contenedores posicionados ni con scroll.

Hay una variante que conviene tener escrita cuando el contexto está escalado, porque entonces querrás las coordenadas en unidades de usuario, no en píxeles del búfer. Eso se resuelve con la matriz inversa del contexto, y tiene su propio sitio en el nivel de transformaciones.

La regla que evita el problema

Todo lo anterior se reduce a una disciplina de proyecto:

Nunca escribas los atributos width y height a mano en el HTML si el canvas es responsivo. Ponlos como valor inicial razonable y calcúlalos siempre desde JavaScript a partir del tamaño real medido.

Nunca pongas width y height de CSS que contradigan lo que calculas. Deja que el layout decida el tamaño de la caja —con porcentajes, con grid, con lo que sea— y limítate a leerlo.

Recalcula el búfer cuando cambie cualquiera de las dos cosas: el tamaño de la caja o la densidad de la pantalla.

Las tres lecciones siguientes son exactamente eso: cómo leer la densidad, cómo hacer el redimensionado bien, y cómo enterarte de que hay que rehacerlo.