Renderização perfeita com devicePixelContentBox

Quantos pixels realmente existem em uma tela?

Desde o Chrome 84, o ResizeObserver aceita 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, especialmente no contexto de telas de alta densidade.

Compatibilidade com navegadores

  • Chrome: 84.
  • Borda: 84.
  • Firefox: 93.
  • Safari: incompatível.

Origem

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 no CSS, o mecanismo de layout do navegador converte esse valor em pixels (px). Esses são os "pixels CSS", que têm muito histórico e apenas uma relação vaga com os pixels que você tem na tela.

Por muito tempo, era bastante 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 cerca de 38 pixels por cm. Com o tempo, os monitores aumentaram e/ou encolheram ou começaram a ter mais pixels na mesma área da superfície. Combinamos isso com o fato de que muito conteúdo na Web define suas dimensões, incluindo tamanhos de fonte, em px, o que resulta em 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 no CSS representa o tamanho de um pixel nessa tela virtual de 96 DPI. Por isso, recebe o nome "CSS Pixel". Esta unidade é usada apenas para medição e posicionamento. Antes de qualquer renderização, ocorre uma conversão para pixels físicos.

Como passamos dessa exibição virtual para a tela real do usuário? Digite devicePixelRatio. Esse valor global informa quantos pixels físicos você precisa para formar um único pixel de CSS. Se devicePixelRatio (dPR) for 1, você está trabalhando em um monitor com aproximadamente 96 DPI. Se você tem uma tela de retina, sua dPR provavelmente está 2. Em smartphones, não é incomum encontrar valores de dPR mais altos (e mais estranhos), como 2, 3 ou até mesmo 2.65. É essencial 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.

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

Por fim, a dPR também pode ser afetada pelo recurso de zoom do navegador. Se você aumentar o zoom, o navegador vai aumentar a dPR informada, fazendo com que tudo seja renderizado ainda mais. Se você marcar devicePixelRatio em um console do DevTools enquanto aumenta o zoom, os valores fracionários vão aparecer.

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

Vamos adicionar o elemento <canvas> à mistura. É possível especificar quantos pixels você quer que a tela tenha usando os atributos width e height. Portanto, <canvas width=40 height=30> seria uma tela com 40 x 30 pixels. No entanto, isso não significa que ele será exibido em 40 por 30 pixels. Por padrão, a tela usa os atributos width e height para definir o tamanho intrínseco dela, mas é possível redimensionar arbitrariamente a tela usando todas as propriedades CSS que você conhece e adora. Com tudo o que aprendemos até agora, você pode pensar 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 resultar em artefatos visuais desagradáveis.

Para resumir: os elementos da tela têm um determinado tamanho 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.

Perfeição

Em alguns cenários, é desejável ter um mapeamento exato dos pixels da tela para os pixels físicos. Se esse mapeamento for realizado, ele será chamado de "pixel perfeito". A renderização de pixels perfeito é essencial para a renderização legível de textos, especialmente ao usar a renderização de subpixels ou ao exibir gráficos com linhas bem alinhadas de brilho alternado.

Para conseguir algo o mais próximo possível de uma tela com pixels perfeitos na Web, esta é mais ou menos a abordagem recomendada:

<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 perspicaz 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 convertidos em valores de pixel CSS fracionários. Um elemento com margin-left: 33% pode ficar com um retângulo como este:

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

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

Ajuste de pixels

A parte do processo de conversão de unidade que cuida de alinhar elementos com pixels físicos é chamada de "pixel snapping" e faz o que diz na lata: ela fixa valores de pixels fracionários em valores de pixels inteiros e físicos. A maneira exata como isso acontece varia de acordo com o navegador. Se tivermos um elemento com uma largura de 791.984px em uma tela em que a dPR é 1, um navegador pode renderizar o elemento em 792px pixels físicos, enquanto outro pode renderizar em 791px. É apenas um pixel de diferença, mas um único pixel pode ser prejudicial à renderização que precisa ser perfeita. Isso pode causar desfoque ou artefatos mais visíveis, como o efeito Moiré.

A imagem superior é uma varredura de pixels de cores diferentes. A imagem de baixo é a mesma acima, mas a largura e a altura foram reduzidas em um pixel usando o dimensionamento bilinear. O padrão emergente é chamado de efeito Moiré.
Talvez seja necessário abrir essa imagem em uma nova guia para vê-la sem nenhuma escala aplicada.

devicePixelContentBox

devicePixelContentBox fornece uma caixa de conteúdo de elemento em unidades de pixel do dispositivo (ou seja, pixels físicos). Ele faz parte de 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 mais recente por enquanto.

Como mencionado em ResizeObserver, ele é parecido com um document.onresize para elementos, a função de callback de um ResizeObserver será chamada antes da pintura e depois da pintura do layout. Isso significa que o parâmetro entries do callback conterá os tamanhos de todos os elementos observados logo antes de eles serem pintados. No contexto do problema de tela descrito acima, podemos usar esta oportunidade para ajustar o número de pixels na tela, garantindo que tenhamos um mapeamento exato de um para um entre os pixels da tela e os 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 ofereça suporte), o callback só será invocado se alguma das métricas da caixa observada mudar.

Com essa nova propriedade, podemos até mesmo animar o tamanho e a posição da tela, garantindo efetivamente valores de pixel fracionários, e não ver nenhum efeito Moiré na renderização. Se você quiser ver o efeito Moiré na abordagem usando getBoundingClientRect() e como a nova propriedade ResizeObserver permite evitá-lo, consulte a demonstração no Chrome 84 ou mais recente.

Detecção de recursos

Para verificar se o navegador do usuário é compatível com 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

Os pixels são um tema 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 oferece essa informação e permite que você faça renderizações perfeitas com <canvas>. devicePixelContentBox é compatível com o Chrome 84 ou mais recente.