Renderização perfeita com devicePixelContentBox

Quantos pixels há realmente 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, especialmente no contexto de telas de alta densidade.

Compatibilidade com navegadores

  • Chrome: 84.
  • Edge: 84.
  • Firefox: 93.
  • Safari: não é compatível.

Origem

Contexto: 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 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. Combine isso com o fato de que muitos conteúdos na Web definem as dimensões, incluindo tamanhos de fonte, em px, e você acaba 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 no 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 de qualquer renderização, ocorre uma conversão para pixels físicos.

Como passamos dessa tela 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ê tiver uma tela Retina, seu dPR provavelmente será 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 que você derive 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…

Ela 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. É por isso que um <div style="width: 1cm; height: 1cm"> não mede exatamente 1 cm 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 o dPR informado, fazendo com que tudo seja renderizado de forma maior. Se você marcar devicePixelRatio em um console do DevTools enquanto aumenta o zoom, os valores fracionários vão aparecer.

O DevTools mostra 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 com 40 x 30 pixels. Por padrão, a tela vai usar o atributo width e height para definir o tamanho intrínseco, mas você pode redimensionar a tela arbitrariamente usando todas as propriedades de CSS que conhece. 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 levar a artefatos visuais desagradáveis.

Resumindo: os elementos da tela têm um tamanho definido 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 alcançado, ele será chamado de "pixel-perfect". A renderização perfeita é crucial para a renderização legível de texto, especialmente ao usar a renderização de subpixels ou ao exibir gráficos com linhas de brilho alternado bem alinhadas.

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 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 fracionários de pixel CSS. 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, então ter frações de um pixel é aceitável em teoria, mas como o navegador descobre o mapeamento para pixels físicos? Porque os pixels físicos fracionários não existem.

Alinhamento 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 forma 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. Isso é apenas um único pixel, mas um único pixel pode ser prejudicial para renderizações que precisam ser perfeitas. Isso pode levar a desfoque ou até mesmo a 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 acima, mas a largura e a altura foram reduzidas em um pixel usando a escala bilinear. O padrão emergente é chamado de efeito Moiré.
(Talvez seja necessário abrir essa imagem em uma nova guia para conferir sem escala.)

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 de ResizeObserver. Embora o ResizeObserver tenha suporte em todos os principais navegadores desde o Safari 13.1, a propriedade devicePixelContentBox está disponível apenas no Chrome 84 ou mais recente.

Conforme mencionado em ResizeObserver: é como document.onresize para elementos, a função de callback de um ResizeObserver será chamada antes da pintura e depois do layout. Isso significa que o parâmetro entries para o callback vai conter os tamanhos de todos os elementos observados antes de serem pintados. No contexto do problema da tela descrito acima, podemos usar essa oportunidade para ajustar o número de pixels na tela, garantindo um mapeamento exato 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 quais tamanhos 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é animar o tamanho e a posição da tela (garantindo efetivamente valores fracionários de pixels) e não ver nenhum efeito Moiré na renderização. Se você quiser conferir o efeito Moiré na abordagem usando getBoundingClientRect() e como a nova propriedade ResizeObserver permite evitá-lo, confira a demonstração no Chrome 84 ou mais recente.

Detecção de recursos

Para verificar se o navegador de um usuário tem suporte a 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 tópico 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>. O devicePixelContentBox é compatível com o Chrome 84 e versões mais recentes.