Rendu au pixel près avec devicePixelContentBox

Combien de pixels y a-t-il vraiment dans 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'obtenir un rendu graphique parfait au pixel, en particulier sur les écrans haute densité.

Navigateurs pris en charge

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

Source

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

Bien que nous utilisions souvent 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 finira par convertir cette valeur en pixels (px). Il s'agit de "pixels CSS", qui disposent d'une grande quantité d'historique et n'ont qu'une relation vague avec les pixels affichés à l'écran.

Pendant longtemps, il a été assez raisonnable d'estimer la densité de pixels de n'importe quel écran à 96 DPI ("points par pouce"), ce qui signifie que tout moniteur aurait environ 38 pixels par cm. Au fil du temps, les écrans ont augmenté et/ou diminué de taille, ou ont commencé à comporter plus de pixels sur la même surface. De plus, de nombreux contenus sur le Web définissent leurs dimensions, y compris la taille de la police, dans px, et le texte se retrouve illisible sur ces écrans haute densité ("HiDPI"). Pour contrerattaquer, les navigateurs masquent la densité de pixels réelle de l'écran et prétendent que l'utilisateur dispose d'un écran 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, une conversion en pixels physiques est effectuée.

Comment passer de cet écran virtuel à l'écran réel 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 défini sur 1, cela signifie que vous utilisez un moniteur dont la résolution est d'environ 96 DPI. Si vous avez un écran Retina, la rétinence de l'appareil 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) telles que 2, 3 ou même 2.65. Il est essentiel de noter que cette valeur est exacte, mais ne vous permet pas d'obtenir la valeur PPP réelle de l'écran. Une dPR de 2 signifie qu'un pixel CSS sera mappé exactement sur 2 pixels physiques.

Exemple
D'après Chrome, mon écran a une DPR de 1

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

Enfin, la fonction de zoom de votre navigateur peut également modifier la fonction de réaffichage permanent. Si vous effectuez un zoom avant, le navigateur augmente la valeur dPR signalée, ce qui a pour effet d'agrandir tous les éléments. Si vous cochez devicePixelRatio dans la console DevTools lorsque vous faites un zoom, des valeurs fractionnaires s'affichent.

Outils de développement affichant diverses devicePixelRatio fractionnaires en raison du zoom

Ajoutons l'élément <canvas>. 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> correspond donc à un canevas de 40 x 30 pixels. Toutefois, cela ne signifie pas qu'il sera affiché à 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 redimensionner le canevas de manière arbitraire à 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 peut vous arriver que ce ne soit pas idéal dans tous les scénarios. Un pixel sur le canevas peut finir par couvrir plusieurs pixels physiques, ou juste une fraction d'un pixel physique. Cela peut entraîner des artefacts visuels peu agréables.

Pour résumer : les éléments de 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 près

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

Pour obtenir un canevas le plus proche possible d'un canevas au pixel près sur le Web, l'approche suivante était généralement utilisé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>

Le lecteur perspicace se demande peut-être ce qui se passe lorsque dPR n'est pas une valeur entière. C'est une bonne question, et c'est là que réside l'essentiel du problème. En outre, 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 converties en valeurs de pixels CSS fractionnaires. Un élément comportant margin-left: 33% peut se retrouver avec un rectangle comme ceci:

DevTools affichant des valeurs de pixel fractionnaires à la suite d'un appel getBoundingClientRect().

Les pixels CSS sont purement virtuels. En théorie, il est donc acceptable d'avoir des fractions de pixel. Mais comment le navigateur détermine-t-il la mise en correspondance avec les pixels physiques ? Parce que les pixels physiques fractionnaires ne sont pas une chose.

Ancrage au pixel

La partie du processus de conversion d'unités qui s'occupe d'aligner les éléments sur les pixels physiques s'appelle "pixel snapping" (alignement sur les pixels). Elle fait ce qu'elle dit : elle aligne les valeurs de pixels fractionnaires sur des valeurs de pixels physiques entières. La procédure exacte varie d'un navigateur à l'autre. Si l'un de nos éléments a une largeur de 791.984px sur un écran où le DPR est de 1, il est possible qu'un navigateur affiche l'élément à 792px pixels physiques, tandis qu'un autre navigateur l'affiche à 791px. Il ne s'agit que d'un seul pixel, mais un seul pixel peut être préjudiciable aux rendus qui doivent être au pixel près. Cela peut entraîner un floutage 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 identique à celle ci-dessus, mais sa largeur et sa hauteur ont été réduites d'un pixel à l'aide d'un étalonnage bilinéaire. Le motif émergent s'appelle l'effet "Moiré".
(Vous devrez peut-être ouvrir cette image dans un nouvel onglet pour l'afficher sans appliquer de mise à l'échelle.)

devicePixelContentBox

devicePixelContentBox génère la zone de contenu d'un élément en unités de pixels de l'appareil (c'est-à-dire des pixels physiques). Il fait partie de ResizeObserver. Bien que ResizeObserver est désormais compatible avec tous les principaux navigateurs depuis Safari 13.1, la propriété devicePixelContentBox n'est pour l'instant disponible que dans Chrome 84 et versions ultérieures.

Comme indiqué dans ResizeObserver : c'est comme document.onresize pour les éléments, la fonction de rappel d'un ResizeObserver est appelée avant la peinture et après la mise en page. Cela signifie que le paramètre entries du rappel contient la taille de tous les éléments observés juste avant leur peinture. Dans le contexte du problème de canevas décrit ci-dessus, nous pouvons profiter de cette opportunité pour ajuster le nombre de pixels sur notre canevas, en nous assurant d'obtenir un mappage exact "un pour un" 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 dans l'objet d'options pour observer.observe() vous permet de définir les tailles que vous souhaitez observer. Ainsi, bien que chaque ResizeObserverEntry fournisse toujours borderBoxSize, contentBoxSize et devicePixelContentBoxSize (à condition que le navigateur soit compatible), le rappel n'est appelé que si l'une des métriques de zone observée change.

Avec cette nouvelle propriété, nous pouvons même animer la taille et la position de notre canevas (ce qui garantit efficacement des valeurs de pixel fractionnaires) et ne voir aucun 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, vous ne pouviez pas 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 au pixel près avec <canvas>. devicePixelContentBox est compatible avec Chrome 84 et versions ultérieures.