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

キャンバスには実際にピクセルがいくつあるのでしょうか。

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

対応ブラウザ

  • Chrome: 84。
  • Edge: 84。
  • Firefox: 93.
  • Safari: サポートされていません。

ソース

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

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

長い間、画面のピクセル密度を 96DPI(「1 インチあたりのドット数」)で推定することはかなり合理的でした。つまり、任意のモニターのピクセル密度は約 38 ピクセル/cm でした。時間の経過とともに、モニターのサイズが拡大または縮小されたり、同じ表面積にピクセルが増えたりしてきました。ウェブ上の多くのコンテンツで、フォントサイズを含むサイズが px で定義されているという事実と組み合わせると、高密度(HiDPI)画面でテキストが判読できなくなります。対策として、ブラウザはモニターの実際のピクセル密度を隠し、ユーザーが 96 DPI のディスプレイを使用していると想定します。CSS の px 単位は、この仮想 96 DPI ディスプレイの 1 ピクセルのサイズを表すため、「CSS Pixel」と呼ばれます。この単位は測定と配置にのみ使用されます。実際のレンダリングが行われる前に、物理ピクセルに変換されます。

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

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

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

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

ズームにより、さまざまな小数値の devicePixelRatio が DevTools に表示されます。

<canvas> 要素を追加しましょう。width 属性と height 属性を使用して、キャンバスのピクセル数を指定できます。したがって、<canvas width=40 height=30> は 40 x 30 ピクセルのキャンバスになります。ただし、これは 40 x 30 ピクセルで表示されるという意味ではありません。デフォルトでは、キャンバスは width 属性と height 属性を使用して固有のサイズを定義しますが、使い慣れたすべての CSS プロパティを使用してキャンバスのサイズを変更できます。これまで学んできたことを踏まえると、これがすべてのシナリオに適しているわけではないことに気が付くかもしれません。キャンバス上の 1 つのピクセルが複数の物理ピクセルを覆ったり、物理ピクセルのごく一部を覆ったりすることがあります。これにより、視覚的なアーティファクトが好ましくなくなる可能性があります。

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

Google Pixel の完璧さ

キャンバス ピクセルから物理ピクセルに正確にマッピングすることが望ましい場合があります。このマッピングが達成されると、「pixel-perfect」と呼ばれます。ピクセル パーフェクト レンダリングは、テキストの判読性に優れたレンダリングに不可欠です。サブピクセル レンダリングを使用する場合や、明るさが交互に並ぶように配置されたグラフィックを表示する場合に特に重要です。

ウェブ上で可能な限りピクセル パーフェクトなキャンバスを実現するために、これまでは次のようなアプローチが一般的でした。

<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 ピクセルは完全に仮想的であるため、理論上はピクセルの小数値を使用できますが、ブラウザは物理ピクセルにどのようにマッピングするかを判断します。小数値の物理ピクセルは重要ではないからです。

Pixel のスナップ

単位変換プロセスの中で、要素を物理ピクセルと整列させる処理は「ピクセル スナップ」と呼ばれ、基準値と同じ処理を行います。つまり、小数点以下のピクセル値を整数の物理ピクセル値にスナップします。具体的な処理方法はブラウザによって異なります。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() の options オブジェクトの 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 以降でサポートされています。