Renderização perfeita com devicePixelContentBox

Quantos pixels realmente há em uma tela?

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

Compatibilidade com navegadores

  • 84
  • 84
  • 93
  • x

Plano de fundo: pixels CSS, pixels de tela e pixels físicos

Muitas vezes, trabalhamos com unidades abstratas de comprimento, como em, % ou vh, mas 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 converterá esse valor em pixels (px). Eles são "pixels CSS", que têm muito histórico e têm uma relação inflexível com os pixels na tela.

Por muito tempo, foi bastante razoável estimar a densidade de pixels da tela de alguém com 96 DPI ("pontos por polegada"), o que significa que qualquer monitor teria cerca de 38 pixels por cm. Com o tempo, os monitores cresceram e/ou foram reduzidos ou começaram a ter mais pixels na mesma área da superfície. Combine isso com o fato de que grande parte do conteúdo na Web define dimensões, incluindo tamanhos de fonte, em px, e acabamos com texto ilegível nessas telas de alta densidade ("HiDPI"). Como medida de prevenção, os navegadores ocultam a densidade de pixels real do monitor e, em vez disso, 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 o nome "Pixel CSS". Essa unidade é usada apenas para medição e posicionamento. Antes de qualquer renderização real, ocorre uma conversão em pixels físicos.

Como passamos dessa tela virtual para a tela real do usuário? Digite devicePixelRatio. Este valor global informa quantos pixels físicos você precisa para formar um único pixel CSS. Se devicePixelRatio (dPR) for 1, você está trabalhando em um monitor com aproximadamente 96 DPI. Se você tem uma tela retina, seu dPR provavelmente é 2. Em smartphones, é comum 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 real de DPI do monitor. Um dPR de 2 significa que 1 pixel CSS será mapeado exatamente 2 pixels físicos.

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

Ele tem 3.440 pixels de largura e 79 cm de largura da área de exibição. Isso leva a uma resolução de 110 DPI. Quase 96, mas não exatamente. Essa também é a razão pela qual um <div style="width: 1cm; height: 1cm"> não tem exatamente 1 cm de tamanho 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 aumentará o dPR informado, fazendo com que tudo seja renderizado maior. Se você conferir devicePixelRatio em um Console do DevTools enquanto aumenta o zoom, verá os valores fracionários.

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 por 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 você pode redimensionar arbitrariamente usando todas as propriedades CSS que conhece e adora. Com tudo o que aprendemos até agora, você pode pensar que isso não é 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 causar artefatos visuais desagradáveis.

Para resumir: os elementos de 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 de pixels físicos.

Perfeição pixelada

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 perfeito". A renderização perfeita em pixels é essencial para a renderização legível do texto, especialmente ao usar a renderização de subpixel ou ao exibir gráficos com linhas bem alinhadas de brilho alternado.

Para conseguir algo o mais próximo possível de uma tela de pixel perfeita na web, esta tem sido mais ou menos a abordagem 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>

O leitor astuto pode estar se perguntando o que acontece quando dPR não é um valor inteiro. Essa é uma boa pergunta e é exatamente a parte central 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 pixels CSS fracionários. Um elemento com margin-left: 33% pode ter um retângulo como este:

DevTools mostrando 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 são nada.

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", e ela faz o que diz no formato: ela ajusta valores de pixels fracionários a valores inteiros de pixels físicos. A maneira como isso acontece é diferente de um navegador para outro. Se tivermos um elemento com largura de 791.984px em uma tela em que o dPR for 1, um navegador poderá renderizar o elemento em 792px pixels físicos, enquanto outro navegador poderá renderizá-lo em 791px. Tem apenas um pixel de diferença, mas um único pixel pode ser prejudicial às renderizações que precisam estar perfeitas. Isso pode fazer com que a imagem fique desfocada ou tenha objetos ainda mais visíveis, como o efeito Moiré.

A imagem de cima é uma varredura de pixels de cores diferentes. A imagem inferior é igual à imagem 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 a imagem em uma nova guia para vê-la sem nenhum redimensionamento.

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+ por enquanto.

Como mencionado em ResizeObserver: como document.onresize para elementos, a função de callback de um ResizeObserver é 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 logo antes da pintura. No contexto do problema de tela descrito acima, podemos usar essa oportunidade para ajustar o número de pixels na tela, garantindo 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 da observer.observe() permite definir quais tamanhos você quer observar. 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, efetivamente, valores de pixels 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, confira a demonstração no Chrome 84 ou posterior.

Detecção de recursos

Para verificar se o navegador de um usuário oferece 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 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 de pixels com <canvas>. devicePixelContentBox é compatível com o Chrome 84 e versões mais recentes.