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

Fondo: 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, fue 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 tendría alrededor de 38 píxeles por cm. Con el tiempo, los monitores aumentaron o se redujeron, 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 cambio, simulan que el usuario tiene una pantalla de 96 DPI. La unidad px en CSS representa el tamaño de un píxel en esta pantalla virtual de 96 DPI. De ahí el nombre "CSS Pixel". 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 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 aproximadamente 96 DPI. Si tienes una pantalla retina, es probable que tu dPR sea 2. En los teléfonos, es común 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 2 píxeles físicos exactamente.

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

Tiene 3440 píxeles de ancho y el área de visualización tiene 79 cm de ancho. De esta manera, se obtiene una resolución de 110 DPI. 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 función de zoom de tu navegador también puede verse afectada por la dPR. Si acercas la imagen, el navegador aumentará la dPR informada, lo que hará que todo se vea más grande. Si marcas devicePixelRatio en la consola de Herramientas para desarrolladores mientras haces zoom, verás que aparecen valores fraccionarios.

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

Agreguemos el elemento <canvas> a la combinación. 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 hemos aprendido hasta ahora, es posible que se te ocurra que esto no será lo ideal en todas las situaciones. Un píxel del lienzo puede terminar cubriendo varios píxeles físicos o solo una fracción de uno físico. Esto puede llevar a artefactos visuales desagradables.

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 de lienzo es completamente independiente del tamaño de visualización del lienzo, el cual se especifica en píxeles CSS. La cantidad de píxeles de CSS no es la misma que la cantidad de píxeles físicos.

Píxeles perfectos

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 parecido posible a un lienzo de píxeles perfectos en la Web, este fue, más o menos, el enfoque preferido:

<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 son una cosa.

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 se indica en el estaño: ajusta los valores de píxeles fraccionarios a valores enteros de píxeles físicos. 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, el píxel físico). Es parte de ResizeObserver. Si bien Cambiar tamañoObserver ahora es compatible con todos los navegadores principales a partir de Safari 13.1, por el momento, la propiedad devicePixelContentBox solo está disponible en Chrome 84 y versiones posteriores.

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 del objeto de opciones de observer.observe() te permite definir qué tamaños deseas observar. Por lo tanto, aunque 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 (lo que garantiza eficazmente valores fraccionarios de píxeles) y no ver ningún efecto de Moiré en la representación. Si deseas ver el efecto de Moiré en el enfoque que usa getBoundingClientRect() y cómo la nueva propiedad ResizeObserver te permite evitarlo, consulta la demostración en Chrome 84 o versiones posteriores.

Detección de funciones

Para verificar si el navegador de un usuario es compatible con devicePixelContentBox, podemos observar cualquier elemento y comprobar 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 de un objeto ResizeObserverEntry te brinda esa información y te permite realizar renderizaciones de píxeles perfectos con <canvas>. devicePixelContentBox es compatible con Chrome 84 y versiones posteriores.