Rendu au pixel près avec devicePixelContentBox

Combien de pixels contient réellement un canevas ?

Depuis Chrome 84, ResizeObserver est compatible avec une nouvelle mesure de boîte appelée devicePixelContentBox, qui mesure la dimension de l'élément en pixels physiques. Cela permet d'afficher des graphiques d'une précision optimale, en particulier sur les écrans haute densité.

Browser Support

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

Source

Contexte : pixels CSS, pixels du canevas et pixels physiques

Bien que nous travaillions souvent avec des unités de longueur abstraites telles que em, % ou vh, tout se résume à des pixels. Chaque fois que nous spécifions la taille ou la position d'un élément en CSS, le moteur de mise en page du navigateur finit par convertir cette valeur en pixels (px). Il s'agit de "pixels CSS", qui ont une longue histoire et qui n'ont qu'un lien lâche avec les pixels de votre écran.

Pendant longtemps, il était assez raisonnable d'estimer la densité de pixels de l'écran de n'importe qui à 96 DPI ("dots per inch", points par pouce), ce qui signifie que n'importe quel moniteur donné aurait environ 38 pixels par cm. Au fil du temps, les écrans ont grandi et/ou diminué, ou ont commencé à avoir plus de pixels sur la même surface. Si l'on ajoute à cela le fait que de nombreux contenus sur le Web définissent leurs dimensions, y compris la taille de police, en px, on obtient un texte illisible sur ces écrans haute densité ("HiDPI"). Pour contrer ce problème, les navigateurs masquent la densité de pixels réelle du moniteur et font croire que l'utilisateur dispose d'un écran de 96 DPI. L'unité px en CSS représente la taille d'un pixel sur cet écran virtuel de 96 DPI, d'où le nom "pixel CSS". Cette unité n'est utilisée que pour la mesure et le positionnement. Avant tout rendu réel, une conversion en pixels physiques a lieu.

Comment passer de cet écran virtuel à l'écran réel de l'utilisateur ? Saisissez devicePixelRatio. Cette valeur globale indique le nombre de pixels physiques nécessaires pour former un seul pixel CSS. Si devicePixelRatio (dPR) est défini sur 1, vous travaillez sur un écran avec une résolution d'environ 96 PPP. Si vous avez un écran Retina, votre dPR est probablement de 2. Sur les téléphones, il n'est pas rare de rencontrer des valeurs de dPR plus élevées (et plus étranges) comme 2, 3 ou même 2.65. Il est essentiel de noter que cette valeur est exacte, mais ne vous permet pas de déduire la valeur DPI réelle de l'écran. Un DPR de 2 signifie qu'un pixel CSS correspond exactement à deux pixels physiques.

Exemple
Selon Chrome, mon écran a un dPR de 1

Il a une largeur de 3 440 pixels et une zone d'affichage de 79 cm de large. Cela donne une résolution de 110 DPI. Presque 96, mais pas tout à fait. C'est également la raison pour laquelle un <div style="width: 1cm; height: 1cm"> ne mesurera pas exactement 1 cm sur la plupart des écrans.

Enfin, le DPR peut également être affecté par la fonctionnalité de zoom de votre navigateur. Si vous effectuez un zoom avant, le navigateur augmente le DPR indiqué, ce qui agrandit tous les éléments. Si vous cochez devicePixelRatio dans la console des outils de développement pendant que vous effectuez un zoom, vous pouvez voir des valeurs fractionnaires s'afficher.

Outils de développement affichant une variété de devicePixelRatio fractionnaires en raison du zoom.

Ajoutons l'élément <canvas>. Vous pouvez spécifier le nombre de pixels que le canevas doit comporter à l'aide des attributs width et height. <canvas width=40 height=30> correspond donc à un canevas de 40 x 30 pixels. Toutefois, cela ne signifie pas qu'il sera affiché en 40 x 30 pixels. Par défaut, le canevas utilise les attributs width et height pour définir sa taille intrinsèque, mais vous pouvez le redimensionner arbitrairement à l'aide de toutes les propriétés CSS que vous connaissez et appréciez. Avec tout ce que nous avons appris jusqu'à présent, vous vous êtes peut-être rendu compte que cette approche n'est pas idéale dans tous les scénarios. Un pixel sur le canevas peut finir par couvrir plusieurs pixels physiques ou seulement une fraction d'un pixel physique. Cela peut entraîner des artefacts visuels désagréables.

En résumé, les éléments Canvas ont une taille donnée pour définir la zone sur laquelle vous pouvez dessiner. Le nombre de pixels du canevas est totalement indépendant de la taille d'affichage du canevas, spécifiée en pixels CSS. Le nombre de pixels CSS n'est pas le même que le nombre de pixels physiques.

La perfection Pixel

Dans certains scénarios, il est souhaitable d'avoir un mappage exact des pixels du canevas vers les pixels physiques. Si ce mappage est réalisé, on parle de "pixel perfect". Le rendu parfait au pixel près est essentiel pour que le texte soit lisible, en particulier lorsque vous utilisez le rendu de sous-pixels ou lorsque vous affichez des graphiques avec des lignes étroitement alignées de luminosité alternée.

Pour obtenir un canevas aussi proche que possible d'un canevas parfait au pixel près sur le Web, l'approche suivante a été plus ou moins la solution de référence :

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

Le lecteur attentif se demandera peut-être ce qui se passe lorsque le DPR n'est pas une valeur entière. C'est une bonne question, car c'est précisément là que réside le cœur du problème. De plus, si vous spécifiez la position ou la taille d'un élément à l'aide de pourcentages, de vh ou d'autres valeurs indirectes, il est possible qu'elles soient résolues en valeurs de pixels CSS fractionnaires. Un élément avec margin-left: 33% peut se retrouver avec un rectangle comme celui-ci :

Outils pour les développeurs affichant des valeurs de pixels fractionnaires à la suite d'un appel getBoundingClientRect().

Les pixels CSS sont purement virtuels. Il est donc théoriquement possible d'avoir des fractions de pixel. Mais comment le navigateur détermine-t-il le mappage avec les pixels physiques ? Les pixels physiques fractionnaires n'existent pas.

Alignement sur les pixels

La partie du processus de conversion d'unité qui permet d'aligner les éléments sur les pixels physiques est appelée "alignement sur les pixels". Elle fait ce qu'elle dit : elle aligne les valeurs de pixels fractionnaires sur les valeurs de pixels physiques entières. La façon dont cela se produit exactement varie d'un navigateur à l'autre. Si nous avons un élément d'une largeur de 791.984px sur un écran où le DPR est de 1, un navigateur peut afficher l'élément à 792px pixels physiques, tandis qu'un autre navigateur peut l'afficher à 791px. Il ne s'agit que d'un seul pixel, mais cela peut nuire aux rendus qui doivent être parfaits. Cela peut entraîner un flou ou des artefacts encore plus visibles, comme l'effet moiré.

L'image du haut est un raster de pixels de différentes couleurs. L'image du bas est identique à celle du haut, mais sa largeur et sa hauteur ont été réduites d'un pixel à l'aide de la mise à l'échelle bilinéaire. Ce motif est appelé "effet de moiré".
(Vous devrez peut-être ouvrir cette image dans un nouvel onglet pour la voir sans mise à l'échelle.)

devicePixelContentBox

devicePixelContentBox vous donne la boîte de contenu d'un élément en unités de pixels de l'appareil (c'est-à-dire en pixels physiques). Il fait partie de ResizeObserver. Bien que ResizeObserver soit désormais compatible avec tous les principaux navigateurs depuis Safari 13.1, la propriété devicePixelContentBox n'est disponible que dans Chrome 84 et versions ultérieures pour le moment.

Comme indiqué dans ResizeObserver : c'est comme document.onresize pour les éléments, la fonction de rappel d'un ResizeObserver sera appelée avant la peinture et après la mise en page. Cela signifie que le paramètre entries du rappel contiendra les tailles de tous les éléments observés juste avant leur affichage. Dans le contexte du problème de canevas décrit ci-dessus, nous pouvons profiter de cette occasion pour ajuster le nombre de pixels sur notre canevas, en veillant à obtenir un mappage exact entre les pixels du canevas et les pixels physiques.

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 propriété box de l'objet d'options pour observer.observe() vous permet de définir les tailles que vous souhaitez observer. Ainsi, même si chaque ResizeObserverEntry fournit toujours borderBoxSize, contentBoxSize et devicePixelContentBoxSize (à condition que le navigateur le prenne en charge), le rappel n'est appelé que si l'une des métriques de boîte observées change.

Grâce à cette nouvelle propriété, nous pouvons même animer la taille et la position de notre canevas (en garantissant effectivement des valeurs de pixels fractionnaires) sans voir d'effets de moiré sur le rendu. Si vous souhaitez voir l'effet Moiré sur l'approche utilisant getBoundingClientRect() et comment la nouvelle propriété ResizeObserver vous permet de l'éviter, consultez la démonstration dans Chrome 84 ou version ultérieure.

Détection de caractéristiques

Pour vérifier si le navigateur d'un utilisateur est compatible avec devicePixelContentBox, nous pouvons observer n'importe quel élément et vérifier si la propriété est présente sur 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
}

Conclusion

Les pixels sont un sujet étonnamment complexe sur le Web. Jusqu'à présent, vous n'aviez aucun moyen de connaître le nombre exact de pixels physiques qu'un élément occupe sur l'écran de l'utilisateur. La nouvelle propriété devicePixelContentBox sur un ResizeObserverEntry vous fournit cette information et vous permet d'effectuer des rendus parfaits au pixel près avec <canvas>. devicePixelContentBox est compatible avec Chrome 84 et versions ultérieures.