Renderización perfecta de píxeles con devicePixelContentBox

¿Cuántos píxeles hay realmente en un lienzo?

A partir de Chrome 84, ResizeObserver admite una nueva medición de cuadro llamada devicePixelContentBox, que mide la dimensión del elemento en píxeles físicos. Esto permite renderizar gráficos de píxeles perfectos, especialmente en el contexto de pantallas de alta densidad.

Navegadores compatibles

  • Chrome: 84.
  • Edge: 84.
  • Firefox: 93.
  • Safari: No se admite.

Origen

Antecedentes: píxeles CSS, píxeles de lienzo y píxeles físicos

Si bien a menudo trabajamos con unidades abstractas de longitud, como em, % o vh, todo se reduce a píxeles. Cada vez que especificamos el tamaño o la posición de un elemento en CSS, el motor de diseño del navegador eventualmente convertirá ese valor en píxeles (px). Estos son "píxeles CSS", que tienen mucho historial y solo tienen una relación vaga con los píxeles que tienes en la pantalla.

Durante mucho tiempo, era bastante razonable estimar la densidad de píxeles de la pantalla de cualquier persona con 96 DPI ("puntos por pulgada"), lo que significa que cualquier monitor determinado tendría aproximadamente 38 píxeles por cm. Con el tiempo, los monitores aumentaron o disminuyeron de tamaño, o comenzaron a tener más píxeles en la misma área de superficie. Si combinamos esto con el hecho de que mucho contenido en la Web define sus dimensiones, incluidos los tamaños de fuente, en px, terminamos con texto ilegible en estas pantallas de alta densidad ("HiDPI"). Como contramedida, los navegadores ocultan la densidad de píxeles real del monitor y, en su lugar, fingen que el usuario tiene una pantalla de 96 ppp. La unidad px en CSS representa el tamaño de un píxel en esta pantalla virtual de 96 DPI, de ahí el nombre "Píxel de CSS". Esta unidad solo se usa para la medición y el posicionamiento. Antes de que se realice la renderización real, se produce una conversión a píxeles físicos.

¿Cómo pasamos de esta pantalla virtual a la pantalla real del usuario? Ingresa devicePixelRatio. Este valor global te indica cuántos píxeles físicos necesitas para formar un solo píxel de CSS. Si devicePixelRatio (dPR) es 1, significa que estás trabajando en un monitor con alrededor de 96 DPI. Si tienes una pantalla retina, es probable que tu dPR sea 2. En los teléfonos, no es raro encontrar valores de dPR más altos (y más extraños), como 2, 3 o incluso 2.65. Es fundamental tener en cuenta que este valor es exacto, pero no te permite obtener el valor de DPI real del monitor. Una dPR de 2 significa que 1 píxel de CSS se asignará a exactamente 2 píxeles físicos.

Ejemplo
Mi monitor tiene una dPR de 1 según Chrome…

Tiene 3440 píxeles de ancho y el área de visualización es de 79 cm de ancho. Esto genera una resolución de 110 ppp. Cerca de 96, pero no exactamente. Esa es también la razón por la que un <div style="width: 1cm; height: 1cm"> no medirá exactamente 1 cm en la mayoría de las pantallas.

Por último, la dPR también puede verse afectada por la función de zoom de tu navegador. Si acercas la imagen, el navegador aumenta la dPR registrada, lo que hace que todo se renderice más grande. Si marcas devicePixelRatio en la consola de DevTools mientras haces zoom, puedes ver que aparecen valores fraccionarios.

Herramientas para desarrolladores que muestran una variedad de devicePixelRatio fraccionarios debido al zoom.

Agreguemos el elemento <canvas> a la mezcla. Puedes especificar cuántos píxeles quieres que tenga el lienzo con los atributos width y height. Por lo tanto, <canvas width=40 height=30> sería un lienzo de 40 por 30 píxeles. Sin embargo, esto no significa que se mostrará en 40 por 30 píxeles. De forma predeterminada, el lienzo usará los atributos width y height para definir su tamaño intrínseco, pero puedes cambiar el tamaño del lienzo de forma arbitraria con todas las propiedades de CSS que conoces y te encantan. Con todo lo que aprendimos hasta ahora, es posible que pienses que esto no será ideal en todas las situaciones. Un píxel en el lienzo puede terminar cubriendo varios píxeles físicos o solo una fracción de un píxel físico. Esto puede generar artefactos visuales poco atractivos.

En resumen, los elementos del lienzo tienen un tamaño determinado para definir el área en la que puedes dibujar. La cantidad de píxeles del lienzo es completamente independiente del tamaño de visualización del lienzo, especificado en píxeles de CSS. La cantidad de píxeles de CSS no es la misma que la cantidad de píxeles físicos.

Pixel Perfect

En algunas situaciones, es conveniente tener una asignación exacta de píxeles del lienzo a píxeles físicos. Si se logra esta asignación, se denomina "pixel perfect". La renderización de píxeles perfectos es fundamental para la renderización legible del texto, especialmente cuando se usa la renderización de subpíxeles o cuando se muestran gráficos con líneas alineadas de brillo alterno.

Para lograr algo lo más cercano posible a un lienzo de píxeles perfectos en la Web, este ha sido más o menos el enfoque de referencia:

<style>
  /* … styles that affect the canvas' size … */
</style>
<canvas id="myCanvas"></canvas>
<script>
  const cvs = document.querySelector('#myCanvas');
  // Get the canvas' size in CSS pixels
  const rectangle = cvs.getBoundingClientRect();
  // Convert it to real pixels. Ish.
  cvs.width = rectangle.width * devicePixelRatio;
  cvs.height = rectangle.height * devicePixelRatio;
  // Start drawing…
</script>

El lector perspicaz podría preguntarse qué sucede cuando dPR no es un valor entero. Esa es una buena pregunta y es exactamente donde reside la clave de todo este problema. Además, si especificas la posición o el tamaño de un elemento con porcentajes, vh o algún otro valor indirecto, es posible que se resuelvan en valores de píxeles de CSS fraccionarios. Un elemento con margin-left: 33% puede terminar con un rectángulo como el siguiente:

Herramientas para desarrolladores que muestran valores de píxeles fraccionarios como resultado de una llamada a getBoundingClientRect().

Los píxeles CSS son puramente virtuales, por lo que tener fracciones de un píxel está bien en teoría, pero ¿cómo determina el navegador la asignación a píxeles físicos? Porque los píxeles físicos fraccionarios no existen.

Ajuste de píxeles

La parte del proceso de conversión de unidades que se encarga de alinear los elementos con los píxeles físicos se denomina "ajuste de píxeles" y hace lo que dice en la lata: ajusta los valores de píxeles fraccionarios a valores de píxeles físicos enteros. La forma en que esto sucede exactamente difiere de un navegador a otro. Si tenemos un elemento con un ancho de 791.984px en una pantalla en la que la dPR es 1, un navegador podría renderizar el elemento en 792px píxeles físicos, mientras que otro navegador podría renderizarlo en 791px. Es solo un píxel de diferencia, pero un solo píxel puede ser perjudicial para las renderizaciones que deben ser perfectas en términos de píxeles. Esto puede provocar imágenes borrosas o incluso artefactos más visibles, como el efecto moiré.

La imagen de la parte superior es un raster de píxeles de diferentes colores. La imagen de la parte inferior es la misma que la anterior, pero el ancho y la altura se redujeron en un píxel con el escalamiento bilinear. El patrón emergente se denomina efecto moiré.
(Es posible que debas abrir esta imagen en una pestaña nueva para verla sin aplicarle ningún escalamiento).

devicePixelContentBox

devicePixelContentBox te proporciona el cuadro de contenido de un elemento en unidades de píxeles del dispositivo (es decir, píxeles físicos). Es parte de ResizeObserver. Si bien ResizeObserver ahora es compatible con todos los navegadores principales desde Safari 13.1, la propiedad devicePixelContentBox solo está disponible en Chrome 84 y versiones posteriores por el momento.

Como se menciona en ResizeObserver: es como document.onresize para elementos, se llamará a la función de devolución de llamada de un ResizeObserver antes de la pintura y después del diseño. Eso significa que el parámetro entries de la devolución de llamada contendrá los tamaños de todos los elementos observados justo antes de que se pinten. En el contexto del problema del lienzo que se describió anteriormente, podemos aprovechar esta oportunidad para ajustar la cantidad de píxeles en el lienzo y asegurarnos de obtener una asignación exacta uno a uno entre los píxeles del lienzo y los píxeles físicos.

const observer = new ResizeObserver((entries) => {
  const entry = entries.find((entry) => entry.target === canvas);
  canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
  canvas.height = entry.devicePixelContentBoxSize[0].blockSize;

  /* … render to canvas … */
});
observer.observe(canvas, {box: ['device-pixel-content-box']});

La propiedad box en el objeto de opciones para observer.observe() te permite definir qué tamaños deseas observar. Por lo tanto, si bien cada ResizeObserverEntry siempre proporcionará borderBoxSize, contentBoxSize y devicePixelContentBoxSize (siempre que el navegador lo admita), la devolución de llamada solo se invocará si cambia alguna de las métricas del cuadro observado.

Con esta nueva propiedad, incluso podemos animar el tamaño y la posición de nuestro lienzo (garantizando de manera eficaz los valores de píxeles fraccionarios) y no ver ningún efecto moiré en la renderización. Si quieres ver el efecto moiré en el enfoque con getBoundingClientRect() y cómo la nueva propiedad ResizeObserver te permite evitarlo, consulta la demo en Chrome 84 o versiones posteriores.

Detección de atributos

Para verificar si el navegador de un usuario admite devicePixelContentBox, podemos observar cualquier elemento y verificar si la propiedad está presente en ResizeObserverEntry:

function hasDevicePixelContentBox() {
  return new Promise((resolve) => {
    const ro = new ResizeObserver((entries) => {
      resolve(entries.every((entry) => 'devicePixelContentBoxSize' in entry));
      ro.disconnect();
    });
    ro.observe(document.body, {box: ['device-pixel-content-box']});
  }).catch(() => false);
}

if (!(await hasDevicePixelContentBox())) {
  // The browser does NOT support devicePixelContentBox
}

Conclusión

Los píxeles son un tema sorprendentemente complejo en la Web y, hasta ahora, no había forma de saber la cantidad exacta de píxeles físicos que ocupa un elemento en la pantalla del usuario. La nueva propiedad devicePixelContentBox en un ResizeObserverEntry te brinda esa información y te permite realizar renderizaciones perfectas a nivel de píxeles con <canvas>. devicePixelContentBox es compatible con Chrome 84 y versiones posteriores.