Renderização perfeita com devicePixelContentBox

Quantos pixels realmente há em uma tela?

Desde o Chrome 84, o ResizeObserver oferece suporte a uma nova medição de caixa chamada devicePixelContentBox, que mede a dimensão do elemento em pixels físicos. Isso permite renderizar gráficos perfeitos em pixels, principalmente no contexto de telas de alta densidade.

Browser Support

  • Chrome: 84.
  • Edge: 84.
  • Firefox: 93.
  • Safari: not supported.

Source

Segundo plano: pixels CSS, pixels da tela e pixels físicos

Embora muitas vezes trabalhemos com unidades abstratas de comprimento, como em, % ou vh, tudo se resume a pixels. Sempre que especificamos o tamanho ou a posição de um elemento em CSS, o mecanismo de layout do navegador acaba convertendo esse valor em pixels (px). Esses são os "pixels CSS", que têm muita história e apenas uma relação solta com os pixels na tela.

Por muito tempo, foi razoável estimar a densidade de pixels da tela de qualquer pessoa com 96 DPI ("pontos por polegada"), o que significa que qualquer monitor teria aproximadamente 38 pixels por cm. Com o tempo, os monitores cresceram e/ou diminuíram ou começaram a ter mais pixels na mesma área de superfície. Combine isso com o fato de que muito conteúdo na Web define as dimensões, incluindo tamanhos de fonte, em px, e acabamos com texto ilegível nessas telas de alta densidade ("HiDPI"). Como contramedida, os navegadores ocultam a densidade de pixels real do monitor e fingem que o usuário tem uma tela de 96 DPI. A unidade px em CSS representa o tamanho de um pixel nessa tela virtual de 96 DPI, daí o nome "pixel CSS". Essa unidade é usada apenas para medição e posicionamento. Antes que qualquer renderização real aconteça, uma conversão para pixels físicos é realizada.

Como passamos dessa tela virtual para a tela real do usuário? Digite devicePixelRatio. Esse valor global informa quantos pixels físicos são necessários para formar um único pixel CSS. Se devicePixelRatio (dPR) for 1, você estará trabalhando em um monitor com aproximadamente 96 DPI. Se você tiver uma tela Retina, seu dPR provavelmente será 2. Em smartphones, é comum encontrar valores de dPR mais altos (e mais estranhos), como 2, 3 ou até mesmo 2.65. É importante observar que esse valor é exato, mas não permite derivar o valor de DPI real do monitor. Uma dPR de 2 significa que um pixel CSS será mapeado para exatamente dois pixels físicos.

Exemplo
Meu monitor tem um dPR de 1 de acordo com o Chrome…

Ele tem 3.440 pixels de largura e a área de exibição tem 79 cm de largura. Isso resulta em uma resolução de 110 DPI. Quase 96, mas não. Esse também é o motivo pelo qual um <div style="width: 1cm; height: 1cm"> não mede exatamente 1 cm na maioria das telas.

Por fim, o dPR também pode ser afetado pelo recurso de zoom do navegador. Se você aumentar o zoom, o navegador vai aumentar o dPR informado, fazendo com que tudo seja renderizado maior. Se você verificar devicePixelRatio em um console do DevTools ao ampliar, verá valores fracionários aparecerem.

DevTools mostrando uma variedade de devicePixelRatio fracionários devido ao zoom.

Vamos adicionar o elemento <canvas> a esse cenário. Você pode especificar quantos pixels quer que a tela tenha usando os atributos width e height. Assim, <canvas width=40 height=30> seria uma tela com 40 por 30 pixels. No entanto, isso não significa que ele será mostrado em 40 por 30 pixels. Por padrão, a tela usa os atributos width e height para definir o tamanho intrínseco, mas é possível redimensionar arbitrariamente a tela usando todas as propriedades de CSS que você conhece e adora. Com tudo o que aprendemos até agora, talvez você perceba que isso não é ideal em todos os cenários. Um pixel na tela pode acabar cobrindo vários pixels físicos ou apenas uma fração de um pixel físico. Isso pode levar a artefatos visuais desagradáveis.

Resumindo: os elementos de tela têm um tamanho determinado para definir a área em que você pode desenhar. O número de pixels da tela é completamente independente do tamanho de exibição da tela, especificado em pixels CSS. O número de pixels CSS não é o mesmo que o número de pixels físicos.

Pixel Perfect

Em alguns cenários, é recomendável ter um mapeamento exato de pixels da tela para pixels físicos. Se esse mapeamento for alcançado, ele será chamado de "pixel perfeito". A renderização perfeita em pixels é crucial para a renderização legível de texto, especialmente ao usar a renderização de subpixels ou ao mostrar gráficos com linhas bem alinhadas de brilho alternado.

Para alcançar algo o mais próximo possível de um canvas perfeito em pixels na Web, esta tem sido mais ou menos a abordagem padrão:

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

O leitor atento pode estar se perguntando o que acontece quando o dPR não é um valor inteiro. Essa é uma boa pergunta e exatamente onde está o cerne de todo esse problema. Além disso, se você especificar a posição ou o tamanho de um elemento usando porcentagens, vh ou outros valores indiretos, é possível que eles sejam resolvidos em valores de pixels CSS fracionários. Um elemento com margin-left: 33% pode resultar em um retângulo como este:

O DevTools mostra valores de pixels fracionários como resultado de uma chamada getBoundingClientRect().

Os pixels CSS são puramente virtuais. Portanto, ter frações de um pixel é aceitável na teoria, mas como o navegador descobre o mapeamento para pixels físicos? Porque pixels físicos fracionários não existem.

Ajuste de pixels

A parte do processo de conversão de unidades que cuida do alinhamento de elementos com pixels físicos é chamada de "ajuste de pixels". Ela faz exatamente o que o nome sugere: ajusta valores de pixels fracionários para valores de pixels físicos inteiros. A forma exata como isso acontece varia de navegador para navegador. Se tivermos um elemento com uma largura de 791.984px em uma tela em que o dPR é 1, um navegador poderá renderizar o elemento em 792px pixels físicos, enquanto outro navegador poderá renderizar em 791px. Isso é apenas um pixel de diferença, mas um único pixel pode prejudicar renderizações que precisam ser perfeitas. Isso pode causar desfoque ou artefatos mais visíveis, como o efeito moiré.

A imagem de cima é um raster de pixels de cores diferentes. A imagem de baixo é a mesma de cima, mas a largura e a altura foram reduzidas em um pixel usando o escalonamento bilinear. O padrão emergente é chamado de efeito moiré.
(Talvez seja necessário abrir esta imagem em uma nova guia para vê-la sem dimensionamento.)

devicePixelContentBox

devicePixelContentBox fornece a caixa de conteúdo de um elemento em unidades de pixel do dispositivo (ou seja, pixel físico). Ele faz parte do ResizeObserver. Embora o ResizeObserver seja compatível com todos os principais navegadores desde o Safari 13.1, a propriedade devicePixelContentBox está disponível apenas no Chrome 84 ou versões mais recentes por enquanto.

Como mencionado em ResizeObserver: é como document.onresize para elementos, a função de callback de um ResizeObserver será chamada antes da renderização e depois do layout. Isso significa que o parâmetro entries do callback vai conter os tamanhos de todos os elementos observados antes de serem renderizados. No contexto do problema de tela descrito acima, podemos aproveitar essa oportunidade para ajustar o número de pixels na tela, garantindo que tenhamos um mapeamento exato de um para um entre pixels da tela e pixels 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']});

A propriedade box no objeto de opções para observer.observe() permite definir os tamanhos que você quer observar. Portanto, embora cada ResizeObserverEntry sempre forneça borderBoxSize, contentBoxSize e devicePixelContentBoxSize (desde que o navegador seja compatível), o callback só será invocado se alguma das métricas de caixa observadas mudar.

Com essa nova propriedade, podemos até animar o tamanho e a posição da tela (garantindo valores de pixels fracionários) sem ver efeitos de moiré na renderização. Se quiser ver o efeito moiré na abordagem usando getBoundingClientRect() e como a nova propriedade ResizeObserver permite evitar isso, confira a demonstração no Chrome 84 ou em versões mais recentes.

Detecção de recursos

Para verificar se o navegador de um usuário tem suporte para devicePixelContentBox, podemos observar qualquer elemento e verificar se a propriedade está presente no 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
}

Conclusão

Pixels são um assunto surpreendentemente complexo na Web, e até agora não havia como saber o número exato de pixels físicos que um elemento ocupa na tela do usuário. A nova propriedade devicePixelContentBox em um ResizeObserverEntry fornece essa informação e permite fazer renderizações perfeitas em pixels com <canvas>. O devicePixelContentBox é compatível com o Chrome 84 ou mais recente.