Rendering perfetto per i pixel con devicePixelContentBox

Quanti pixel ci sono realmente in una tela?

A partire da Chrome 84, ResizeObserver supporta una nuova misurazione di riquadri chiamata devicePixelContentBox, che misura le dimensioni dell'elemento in pixel fisici. In questo modo è possibile eseguire il rendering di grafica con una risoluzione perfetta, in particolare nel contesto di schermi ad alta densità.

Supporto dei browser

  • Chrome: 84.
  • Edge: 84.
  • Firefox: 93.
  • Safari: non supportato.

Origine

Spesso lavoriamo con unità di misura astratte della lunghezza come em, % o vh, ma alla fine si tratta sempre di pixel. Ogni volta che specifichiamo la dimensione o la posizione di un elemento in CSS, il motore di layout del browser lo convertirà in pixel (px). Si tratta di "Pixel CSS", che hanno una lunga cronologia e hanno una relazione generica con i pixel presenti sullo schermo.

Per molto tempo è stato abbastanza ragionevole stimare la densità dei pixel dello schermo di chiunque con 96 DPI ("punti per pollice"), il che significa che qualsiasi monitor avrebbe avuto circa 38 pixel per cm. Nel tempo, i monitor sono aumentati e/o diminuiti di dimensioni o hanno iniziato ad avere più pixel sulla stessa area. Se si combina questo aspetto con il fatto che molti contenuti sul web definiscono le proprie dimensioni, comprese le dimensioni dei caratteri, in px e si finisce con il testo illeggibile su queste schermate ad alta densità ("HiDPI"). Come contromisura, i browser nascondono l'effettiva densità dei pixel del monitor e simulano invece che l'utente disponga di un display a 96 DPI. L'unità px in CSS rappresenta la dimensione di un pixel su questo display virtuale 96 DPI, da cui il nome "Pixel CSS". Questa unità viene utilizzata solo per la misurazione e il posizionamento. Prima che avvenga il rendering effettivo, avviene una conversione in pixel fisici.

Come passiamo da questo display virtuale al display reale dell'utente? Inserisci devicePixelRatio. Questo valore globale indica quanti pixel fisici sono necessari per formare un singolo pixel CSS. Se devicePixelRatio (dPR) è 1, stai lavorando su un monitor con circa 96 DPI. Se hai uno schermo retina, il tuo dPR è probabilmente 2. Sugli smartphone non è raro trovare valori dPR più elevati (e più strani) come 2, 3 o persino 2.65. È essenziale notare che questo valore è esatto, ma non ti consente di ricavare il valore DPI effettivo del monitor. Un dPR di 2 indica che un pixel CSS verrà mappato a esattamente 2 pixel fisici.

Ha una larghezza di 3440 pixel e l'area di visualizzazione è larga 79 cm. Ciò corrisponde a una risoluzione di 110 DPI. Quasi 96, ma non proprio. Questo è anche il motivo per cui un <div style="width: 1cm; height: 1cm"> non misura esattamente 1 cm sulla maggior parte dei display.

Infine, il dPR può essere influenzato anche dalla funzionalità di zoom del browser. Se aumenti lo zoom, il browser aumenta il dPR segnalato, determinando un rendering più grande. Se controlli devicePixelRatio in una console DevTools durante lo zoom, puoi visualizzare i valori frazionari.

DevTools mostra una serie di valori frazionari devicePixelRatio a causa dello zoom.

Aggiungiamo l'elemento <canvas> al mix. Puoi specificare quanti pixel vuoi che contenga il canvas utilizzando gli attributi width e height. Pertanto, <canvas width=40 height=30> sarà una tela con 40 x 30 pixel. Tuttavia, ciò non significa che verrà visualizzato a 40 x 30 pixel. Per impostazione predefinita, la tela utilizzerà gli attributi width e height per definire le sue dimensioni intrinseche, ma puoi ridimensionarla in modo arbitrario utilizzando tutte le proprietà CSS che conosci. Con tutto ciò che abbiamo appreso finora, potresti pensare che questa non sia la soluzione ideale in ogni scenario. Un pixel sulla tela potrebbe coprire più pixel fisici o solo una frazione di un pixel fisico. Ciò può comportare artefatti visivi sgradevoli.

Riassumendo: gli elementi del canvas hanno una specifica dimensione per definire l'area su cui puoi disegnare. Il numero di pixel del canvas è completamente indipendente dalle dimensioni di visualizzazione del canvas, specificate in pixel CSS. Il numero di pixel CSS non corrisponde al numero di pixel fisici.

Pixel perfetti

In alcuni scenari, è preferibile avere una mappatura esatta dei pixel della tela ai pixel fisici. Se questa mappatura viene ottenuta, si chiama "pixel-perfect". Il rendering con pixel perfetti è fondamentale per la leggibilità del testo, in particolare quando si utilizza il rendering a livello di subpixel o quando si mostrano immagini con linee strettamente allineate di luminosità alternata.

Per ottenere qualcosa di più simile possibile a una tela perfetta per i pixel sul web, questo è stato l'approccio più o meno adatto:

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

Il lettore più attento potrebbe chiedersi cosa succede quando il valore di dPR non è un numero intero. Questa è una buona domanda e il punto cruciale di tutto il problema. Inoltre, se specifichi la posizione o la dimensione di un elemento utilizzando percentuali, vh o altri valori indiretti, è possibile che si risolvano in valori di pixel CSS frazionari. Un elemento con margin-left: 33% può terminare con un rettangolo come questo:

DevTools che mostra valori di pixel frazionari a seguito di una chiamata getBoundingClientRect().

I pixel CSS sono puramente virtuali, quindi avere frazioni di pixel va bene in teoria, ma come fa il browser a capire la mappatura ai pixel fisici? Perché i pixel fisici frazionari non esistono.

Snap dei pixel

La parte del processo di conversione delle unità che si occupa di allineare gli elementi con i pixel fisici è chiamata "aggancio di pixel" e fa ciò che viene detto sullo stagno: allinea i valori dei pixel frazionari a valori di pixel interi e fisici. La modalità esatta varia da un browser all'altro. Se abbiamo un elemento con larghezza 791.984px su un display in cui dPR è 1, un browser potrebbe eseguire il rendering dell'elemento a 792px pixel fisici, mentre un altro browser potrebbe eseguire il rendering a 791px. Si tratta di un solo pixel fuori, ma un solo pixel può essere dannoso per i rendering che devono essere perfetti. Ciò può causare sfocature o artefatti ancora più visibili, come l'effetto moiré.

L'immagine in alto è un raster di pixel di colori diversi. L'immagine in basso è la stessa dell'immagine precedente, ma larghezza e altezza sono state ridotte di un pixel utilizzando il ridimensionamento bilineare. Questo motivo è chiamato effetto Moiré.
(Potrebbe essere necessario aprire questa immagine in una nuova scheda per vederla senza applicare ridimensionazioni.)

devicePixelContentBox

devicePixelContentBox fornisce la casella dei contenuti di un elemento in unità pixel del dispositivo (ovvero pixel fisici). Fa parte di ResizeObserver. Sebbene RidimensionaObserver è ora supportato in tutti i principali browser a partire da Safari 13.1, per il momento la proprietà devicePixelContentBox è disponibile solo in Chrome 84 e versioni successive.

Come accennato in ResizeObserver: è simile a document.onresize per gli elementi, la funzione di callback di un ResizeObserver viene chiamata prima del disegno e dopo il layout. Ciò significa che il parametro entries per il callback conterrà le dimensioni di tutti gli elementi osservati appena prima della relativa colorazione. Nel contesto del problema della tela descritto sopra, possiamo sfruttare questa opportunità per regolare il numero di pixel sulla tela, assicurandoci di ottenere una mappatura uno a uno esatta tra i pixel della tela e i pixel fisici.

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 proprietà box nell'oggetto opzioni per observer.observe() consente di definire le dimensioni da osservare. Pertanto, anche se ogni ResizeObserverEntry fornirà sempre borderBoxSize, contentBoxSize e devicePixelContentBoxSize (a condizione che il browser lo supporti), il callback verrà invocato solo se una delle metriche della casella osservata cambia.

Con questa nuova proprietà, possiamo persino animare le dimensioni e la posizione del canvas (garantendo effettivamente valori di pixel frazionari) e non vedere alcun effetto Moiré sul rendering. Se vuoi vedere l'effetto moiré nell'approccio che utilizza getBoundingClientRect() e come la nuova proprietà ResizeObserver ti consente di evitarlo, dai un'occhiata alla demo in Chrome 84 o versioni successive.

Rilevamento delle caratteristiche

Per verificare se il browser di un utente supporta devicePixelContentBox, possiamo osservare qualsiasi elemento e verificare se la proprietà è presente in 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
}

Conclusione

I pixel sono un argomento estremamente complesso sul web e fino a oggi non era possibile conoscere il numero esatto di pixel fisici occupati da un elemento sullo schermo di un utente. La nuova proprietà devicePixelContentBox su un ResizeObserverEntry ti fornisce queste informazioni e ti consente di eseguire rendering perfetti con i pixel con <canvas>. devicePixelContentBox è supportato in Chrome 84 e versioni successive.