Renderización perfecta de píxeles con devicePixelContentBox

¿Cuántos píxeles hay en realidad 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

  • 84
  • 84
  • 93
  • x

Fondo: Píxeles CSS, píxeles de lienzo y píxeles físicos

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

Durante mucho tiempo, era bastante razonable estimar la densidad de píxeles de la pantalla de un usuario con 96 DPI ("puntos por pulgada"), lo que significa que cualquier monitor tendría unos 38 píxeles por centímetro. Con el tiempo, los monitores aumentaron o se redujeron, o comenzaron a tener más píxeles en la misma área de superficie. Si lo combinas con el hecho de que una gran cantidad de contenido en la Web define sus dimensiones en px, incluidos los tamaños de fuente, obtendremos texto ilegible en estas pantallas de alta densidad ("HiDPI"). Como contramedida, los navegadores ocultan la densidad de píxeles real del monitor y 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í que se llama "Píxeles CSS". Esta unidad solo se utiliza para la medición y el posicionamiento. Antes de que ocurra una 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 único píxel CSS. Si devicePixelRatio (dPR) es 1, significa que estás trabajando en un monitor con aproximadamente 96 DPI. Si tienes una pantalla retina, la dPR probablemente 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 derivar 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. Eso lleva a una resolución de 110 DPI. Cerca de 96, pero no exactamente. Esa es la razón por la que un objeto <div style="width: 1cm; height: 1cm"> no medirá exactamente 1 cm de tamaño 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 informada, lo que hace que todo se muestre más grande. Si revisas devicePixelRatio en la consola de Herramientas para desarrolladores mientras haces zoom, podrás ver que aparecen valores fraccionarios.

DevTools muestra una variedad de devicePixelRatio fraccionarios debido al zoom.

Agreguemos el elemento <canvas> a la mezcla. Puedes especificar la cantidad de píxeles que 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á a 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 manera arbitraria usando todas las propiedades de CSS que conoces y te encantan. Con todo lo que aprendimos hasta ahora, puede ocurrir que se te ocurra que esto no sea ideal en todas las situaciones. Un píxel del lienzo puede terminar cubriendo varios píxeles físicos o solo una fracción de un píxel físico. Esto puede conducir a artefactos visuales desagradables.

En resumen, los elementos de lienzo tienen un tamaño determinado para definir el área sobre la que puedes dibujar. El número de píxeles del lienzo es completamente independiente del tamaño de visualización del lienzo, especificado 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 algunos casos, es conveniente tener una asignación exacta de píxeles de lienzo a píxeles físicos. Si se logra esta asignación, se denomina "píxel perfecto". 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 estrechamente alineadas de brillo alternativo.

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

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

Es posible que el lector astuto se esté preguntando qué sucede cuando la dPR no es un valor entero. Esa es una buena pregunta, y es exactamente donde se encuentra el punto crucial 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 fraccionarios de CSS. Un elemento con margin-left: 33% puede terminar con un rectángulo como el siguiente:

DevTools muestra valores de píxeles fraccionarios como resultado de una llamada getBoundingClientRect().

Los píxeles CSS son puramente virtuales, por lo que tener fracciones de píxel está permitido 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.

Ajustes de píxeles

La parte del proceso de conversión de unidades que se encarga de alinear los elementos con píxeles físicos se llama "ajuste de píxeles" y hace lo que dice en el estaño: reajusta los valores de píxeles fraccionarios a valores enteros de píxeles físicos. La forma exacta en que sucede esto 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 podría hacerlo en 791px. Equivale a un solo píxel, pero puede ser perjudicial para las renderizaciones que deben ser perfectas en píxeles. lo que puede generar desenfoque o incluso artefactos más visibles, como el efecto Moiré.

La imagen superior es una trama de píxeles de colores diferentes. La imagen inferior es la misma que la de arriba, pero el ancho y la altura se redujeron un píxel con la escala bilineal. El patrón emergente se denomina efecto Moiré.
(Es posible que debas abrir esta imagen en una pestaña nueva para verla sin ninguna escala).

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, por el momento, la propiedad devicePixelContentBox solo está disponible en Chrome 84 y versiones posteriores.

Como se mencionó en ResizeObserver: es como document.onresize para los 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 descrito anteriormente, podemos aprovechar esta oportunidad para ajustar la cantidad de píxeles en nuestro lienzo y asegurarnos de obtener una asignación uno a uno exacta 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 los tamaños que deseas observar. Por lo tanto, si bien cada ResizeObserverEntry siempre proporcionará borderBoxSize, contentBoxSize y devicePixelContentBoxSize (siempre que el navegador lo admita), solo se invocará la devolución de llamada 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 de manera efectiva valores de píxeles fraccionarios) y no ver ningún efecto Moiré en la renderización. Si deseas ver el efecto 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 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 era posible saber la cantidad exacta de píxeles físicos que un elemento ocupa en la pantalla del usuario. La nueva propiedad devicePixelContentBox de un objeto ResizeObserverEntry te proporciona esa información y te permite realizar renderizaciones de píxeles perfectos con <canvas>. devicePixelContentBox es compatible con Chrome 84 y versiones posteriores.