devicePixelContentBox を使用したピクセル完璧なレンダリング

キャンバスには実際には何個のピクセルがあるのでしょうか?

Chrome 84 以降、ResizeObserverdevicePixelContentBox という新しいボックス測定をサポートしています。これは、要素の寸法を物理ピクセルで測定します。これにより、特に高密度画面において、ピクセル単位で完璧なグラフィックのレンダリングが可能になります。

Browser Support

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

Source

背景: CSS ピクセル、キャンバス ピクセル、物理ピクセル

em%vh などの抽象的な長さの単位を使用することが多いですが、最終的にはピクセルに変換されます。CSS で要素のサイズや位置を指定すると、ブラウザのレイアウト エンジンは最終的にその値をピクセル(px)に変換します。これは「CSS ピクセル」と呼ばれ、長い歴史があり、画面上のピクセルとは緩やかな関係しかありません。

長い間、96 DPI(1 インチあたりのドット数)で画面のピクセル密度を推定することはかなり妥当でした。つまり、どのモニターでも 1 cm あたり約 38 ピクセルでした。時間の経過とともに、モニターは大きくなったり小さくなったり、同じ表面積でより多くのピクセルを持つようになりました。ウェブ上の多くのコンテンツでは、フォントサイズなどの寸法を px で定義しているため、高密度(「HiDPI」)の画面ではテキストが読みにくくなります。対策として、ブラウザはモニターの実際のピクセル密度を隠し、代わりにユーザーが 96 DPI のディスプレイを使用していると見なします。CSS の px 単位は、この仮想 96 DPI ディスプレイの 1 ピクセルのサイズを表します。これが「CSS ピクセル」という名前の由来です。この単位は測定と位置特定にのみ使用されます。実際のレンダリングが行われる前に、物理ピクセルへの変換が行われます。

この仮想ディスプレイからユーザーの実際のディスプレイにどのように移行するのでしょうか?「devicePixelRatio」と入力します。このグローバル値は、1 つの CSS ピクセルを形成するために必要な物理ピクセルの数を示します。devicePixelRatio(dPR)が 1 の場合、約 96 DPI のモニターで作業しています。Retina ディスプレイの場合、dPR は 2 になる可能性があります。スマートフォンでは、232.65 などの高い(奇妙な)dPR 値に遭遇することは珍しくありません。この値は正確ですが、モニターの実際の DPI 値を導き出すことはできません。dPR が 2 の場合、1 CSS ピクセルは 2 物理ピクセルに正確にマッピングされます。

Chrome によると、モニターの dPR は 1 です。

幅は 3,440 ピクセルで、表示領域の幅は 79 cm です。これにより、解像度は 110 DPI になります。96 に近いですが、96 ではありません。また、ほとんどのディスプレイで <div style="width: 1cm; height: 1cm"> のサイズが正確に 1 cm にならないのも、このためです。

最後に、dPR はブラウザのズーム機能の影響も受けます。ズームインすると、ブラウザは報告された dPR を増やし、すべてが大きくレンダリングされます。ズーム中に DevTools コンソールで devicePixelRatio を確認すると、小数値が表示されます。

ズームによりさまざまな分数 devicePixelRatio が表示されている DevTools。

<canvas> 要素を追加してみましょう。width 属性と height 属性を使用して、キャンバスのピクセル数を指定できます。つまり、<canvas width=40 height=30> は 40 × 30 ピクセルのキャンバスになります。ただし、40 × 30 ピクセルで表示されるわけではありません。デフォルトでは、キャンバスは width 属性と height 属性を使用して固有のサイズを定義しますが、CSS プロパティを使用してキャンバスのサイズを任意に変更できます。これまでの説明から、この方法がすべてのシナリオで理想的ではないことに気づかれたかもしれません。キャンバス上の 1 ピクセルが複数の物理ピクセルをカバーしたり、物理ピクセルのほんの一部をカバーしたりすることがあります。これにより、不快な視覚的アーティファクトが発生する可能性があります。

まとめると、キャンバス要素には描画可能な領域を定義するサイズが指定されています。キャンバスのピクセル数は、CSS ピクセルで指定されたキャンバスの表示サイズとは完全に独立しています。CSS ピクセルの数は物理ピクセルの数と同じではありません。

Pixel の完璧さ

場合によっては、キャンバス ピクセルから物理ピクセルへの正確なマッピングが望ましいことがあります。このマッピングが実現されると、「ピクセル パーフェクト」と呼ばれます。ピクセル パーフェクト レンダリングは、テキストを読みやすくレンダリングするために不可欠です。特に、サブピクセル レンダリングを使用する場合や、明るさが交互に変化する線が密に並んだグラフィックを表示する場合に重要です。

ウェブ上でピクセル パーフェクトなキャンバスにできるだけ近いものを実現するには、これまでほぼ次のようなアプローチが採用されてきました。

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

賢明な読者は、dPR が整数値でない場合にどうなるのか疑問に思うかもしれません。これは良い質問であり、この問題の核心はまさにそこにあります。また、要素の位置やサイズをパーセンテージ、vh、その他の間接的な値で指定すると、CSS ピクセル値の端数に解決される可能性があります。margin-left: 33% を含む要素は、次のような長方形になります。

getBoundingClientRect() 呼び出しの結果として、DevTools に小数値のピクセル値が表示されている。

CSS ピクセルは純粋に仮想的なものなので、理論的にはピクセルの端数があっても問題ありませんが、ブラウザは物理ピクセルへのマッピングをどのように判断するのでしょうか?小数値の物理ピクセルは存在しないためです。

ピクセル スナップ

単位変換プロセスで要素を物理ピクセルに合わせる部分は「ピクセル スナップ」と呼ばれ、その名のとおり、小数ピクセル値を整数値の物理ピクセル値にスナップします。この処理の具体的な方法はブラウザによって異なります。dPR が 1 のディスプレイに幅が 791.984px の要素がある場合、あるブラウザでは要素が 792px 物理ピクセルでレンダリングされ、別のブラウザでは 791px でレンダリングされることがあります。これは 1 ピクセルずれているだけですが、1 ピクセルでもずれていると、ピクセル単位で正確なレンダリングが必要な場合に問題が生じることがあります。これにより、画像が不鮮明になったり、モアレ効果などのアーティファクトが目立つようになったりすることがあります。

上の画像は、さまざまな色のピクセルのラスタです。下の画像は上の画像と同じですが、バイリニア スケーリングを使用して幅と高さを 1 ピクセルずつ縮小しています。このパターンはモアレ効果と呼ばれます。
(この画像は、拡大縮小なしで表示するために新しいタブで開く必要がある場合があります)。

devicePixelContentBox

devicePixelContentBox は、デバイス ピクセル(物理ピクセル)単位で要素のコンテンツ ボックスを取得します。ResizeObserver の一部です。Safari 13.1 以降、ResizeObserver はすべての主要なブラウザでサポートされていますが、devicePixelContentBox プロパティは今のところ Chrome 84 以降でのみ使用できます。

ResizeObserver: 要素の document.onresize のようなもので説明したように、ResizeObserver のコールバック関数はペイントの前、レイアウトの後に呼び出されます。つまり、コールバックの entries パラメータには、観測されたすべての要素のサイズが、描画される直前に含まれます。上記のキャンバスの問題のコンテキストでは、この機会を利用してキャンバスのピクセル数を調整し、キャンバスのピクセルと物理ピクセルが 1 対 1 で正確にマッピングされるようにすることができます。

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']});

observer.observe() のオプション オブジェクトの box プロパティを使用すると、監視するサイズを定義できます。そのため、各 ResizeObserverEntry は常に borderBoxSizecontentBoxSizedevicePixelContentBoxSize を提供しますが(ブラウザがサポートしている場合)、コールバックは、監視対象のボックス指標のいずれかが変更された場合にのみ呼び出されます。

この新しいプロパティを使用すると、キャンバスのサイズと位置をアニメーション化しても、レンダリングにモアレ効果は発生しません(事実上、ピクセル値の端数が保証されます)。getBoundingClientRect() を使用したアプローチでモアレ効果が発生する様子と、新しい ResizeObserver プロパティでそれを回避する方法については、Chrome 84 以降のデモをご覧ください。

特徴検出

ユーザーのブラウザが devicePixelContentBox をサポートしているかどうかを確認するには、任意の要素を監視し、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
}

まとめ

ピクセルはウェブ上で驚くほど複雑なトピックであり、これまで、要素がユーザーの画面上で占める物理ピクセルの正確な数を把握する方法はありませんでした。ResizeObserverEntry の新しい devicePixelContentBox プロパティを使用すると、その情報を取得し、<canvas> でピクセル単位で完璧なレンダリングを行うことができます。devicePixelContentBox は Chrome 84 以降でサポートされています。