Rendu au pixel près avec devicePixelContentBox

Combien de pixels y a-t-il vraiment dans un canevas ?

Depuis Chrome 84, ResizeObserver accepte une nouvelle mesure de zone appelée devicePixelContentBox, qui mesure la dimension de l'élément en pixels physiques. Cela permet d'obtenir des graphismes au pixel près, en particulier sur les écrans haute densité.

Navigateurs pris en charge

  • 84
  • 84
  • 93
  • x

Arrière-plan: pixels CSS, pixels de canevas et pixels physiques

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

Pendant longtemps, il était assez raisonnable d'estimer la densité de pixels de n'importe quel utilisateur avec une résolution de 96 DPI ("points par pouce"), ce qui signifie qu'un écran donné aurait environ 38 pixels par cm. Au fil du temps, les écrans ont augmenté et/ou diminué ou ont commencé à avoir plus de pixels sur la même surface. Si l'on ajoute cela au fait que de nombreux contenus sur le Web définissent leurs dimensions, y compris la taille des polices, dans px, le texte est illisible sur ces écrans haute densité ("HiDPI"). À titre de contre-mesure, les navigateurs masquent la densité réelle en pixels de l'écran et prétendent que l'utilisateur dispose d'un écran de 96 PPP. L'unité px en CSS représente la taille d'un pixel sur cet écran virtuel de 96 PPP, d'où le nom "CSS Pixel". Cette unité n'est utilisée que pour les mesures et le positionnement. Avant qu'un rendu ne se produise, une conversion en pixels physiques a lieu.

Comment passer de cette vitrine virtuelle à celle de l'utilisateur ? Saisissez devicePixelRatio. Cette valeur globale indique le nombre de pixels physiques dont vous avez besoin pour former un seul pixel CSS. Si devicePixelRatio (dPR) est 1, cela signifie que vous travaillez sur un écran d'environ 96 PPP. Si vous avez un écran rétinien, votre dPR est probablement de 2. Sur les téléphones, il n'est pas rare de rencontrer des valeurs dPR plus élevées (et plus étranges) comme 2, 3 ou même 2.65. Il est important de noter que cette valeur est exacte, mais qu'elle ne vous permet pas de déterminer la valeur PPP réelle de l'écran. Une dPR de 2 signifie qu'un pixel CSS correspond exactement à deux pixels physiques.

Exemple
D'après Chrome, mon écran a un dPR de 1...

Sa largeur est de 3 440 pixels, et sa zone d'affichage de 79 cm. Cela donne une résolution de 110 PPP. Près de 96, mais pas tout à fait. C'est aussi pour cette raison que la taille d'une <div style="width: 1cm; height: 1cm"> ne mesure pas exactement 1 cm sur la plupart des écrans.

Enfin, la fonctionnalité de zoom de votre navigateur peut également avoir un impact sur les règles de précision réparatrices (dPR). Si vous faites un zoom avant, le navigateur augmente la valeur dPR rapportée, ce qui agrandit l'affichage. Si vous cochez devicePixelRatio dans une console d'outils de développement lors d'un zoom, vous verrez des valeurs fractionnaires s'afficher.

Les outils de développement affichent plusieurs devicePixelRatio fractionnaires en raison du zoom.

Ajoutons l'élément <canvas> au mix. Vous pouvez spécifier le nombre de pixels que vous souhaitez pour le canevas à l'aide des attributs width et height. <canvas width=40 height=30> est donc un canevas de 40 x 30 pixels. Toutefois, cela ne signifie pas qu'elle s'affichera avec une résolution de 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, il est possible que ce ne soit pas idéal dans tous les scénarios. Un pixel sur le canevas peut finir par recouvrir 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 du canevas ont une taille donnée pour définir la zone sur laquelle vous pouvez dessiner. Le nombre de pixels du canevas est complètement indépendant de la taille d'affichage du canevas, spécifiée en pixels CSS. Le nombre de pixels CSS est différent du nombre de pixels physiques.

La perfection au Pixel

Dans certains cas, il est souhaitable d'établir un mappage exact entre les pixels du canevas et les pixels physiques. Si cette mise en correspondance est effectuée, on parle de "pixel-perfect". Le rendu impeccable au pixel est essentiel pour que le texte soit lisible, en particulier avec le rendu par sous-pixel ou avec des éléments graphiques dont les lignes sont fortement alignées et qui alternent une luminosité alternée.

Pour obtenir un canevas au pixel près possible sur le Web, cette approche a plus ou moins été la plus adaptée:

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

Un lecteur astucieux se demande peut-être ce qui se passe lorsque la dPR n'est pas un nombre entier. C'est une bonne question et c'est précisément le nœud 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 se traduisent par des valeurs fractionnaires de pixels CSS. Un élément avec margin-left: 33% peut se terminer par un rectangle comme celui-ci:

Les outils de développement affichent des valeurs de pixels fractionnaires à la suite d'un appel getBoundingClientRect().

Les pixels CSS sont purement virtuels. Il est donc acceptable en théorie d'utiliser des fractions de pixel, mais comment le navigateur détermine-t-il le mappage avec les pixels physiques ? Parce que les pixels physiques fractionnaires ne sont pas une chose.

Ancrage de pixel

La partie du processus de conversion d'unités qui consiste à aligner les éléments sur les pixels physiques est appelée "ancrage des pixels". Elle fait ce qu'elle dit sur l'étain: elle convertit les valeurs de pixels fractionnaires en valeurs de pixels physiques entières. La procédure à suivre diffère d'un navigateur à l'autre. Si l'un de nos éléments présente une largeur de 791.984px sur un écran où la dPR est égale à 1, un navigateur peut afficher l'élément avec 792px pixels physiques, tandis qu'un autre navigateur peut l'afficher avec 791px. Un seul pixel peut nuire aux rendus qui doivent être parfaits. Cela peut entraîner une image floue ou des artefacts encore plus visibles, comme l'effet Moiré.

L'image du haut est une trame de pixels de couleurs différentes. L'image du bas est la même que ci-dessus, mais la largeur et la hauteur ont été réduites d'un pixel par mise à l'échelle bilinéaire. Le motif émergent s'appelle l'effet Moiré.
(Vous devrez peut-être ouvrir cette image dans un nouvel onglet pour la voir sans mise à l'échelle.)

devicePixelContentBox

devicePixelContentBox indique la zone de contenu d'un élément en pixels de l'appareil (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: comme document.onresize pour les éléments, la fonction de rappel d'un ResizeObserver est appelée avant "Pain" et après "Layout". Cela signifie que le paramètre entries du rappel contiendra les tailles de tous les éléments observés juste avant qu'ils ne soient peints. Dans le contexte de notre problème de canevas décrit ci-dessus, nous pouvons profiter de cette opportunité pour ajuster le nombre de pixels sur notre canevas, en veillant à obtenir un mappage parfait 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 de observer.observe() vous permet de définir les tailles à observer. Ainsi, même si chaque ResizeObserverEntry fournit toujours borderBoxSize, contentBoxSize et devicePixelContentBoxSize (à condition que le navigateur les accepte), le rappel ne sera invoqué que si l'une des métriques de zone observées change.

Avec cette nouvelle propriété, nous pouvons même animer la taille et la position de notre canevas (ce qui garantit effectivement des valeurs de pixels fractionnaires) sans observer d'effet 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, regardez la démonstration dans Chrome 84 ou version ultérieure.

Détection de fonctionnalités

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, il n'existait aucun moyen de connaître le nombre exact de pixels physiques occupés par un élément sur l'écran de l'utilisateur. La nouvelle propriété devicePixelContentBox sur un ResizeObserverEntry vous fournit cette information et vous permet d'obtenir des rendus parfaits avec <canvas>. devicePixelContentBox est compatible avec Chrome 84 et versions ultérieures.