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.
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 buffercanvas.height = 600; // 600 filasEl 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 unocanvas.width; // 800 -> buffercanvas.getBoundingClientRect().width; // 400 -> caja en la paginaEl 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 × devicePixelRatiocanvas.height === rect.height × devicePixelRatioCualquier desviación de esa igualdad produce interpolación.
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.