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

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

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

対応ブラウザ

  • 84
  • 84
  • 93
  • x

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

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

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

この仮想ディスプレイからユーザーの実際のディスプレイに移行するには、どうすればよいでしょうか。「devicePixelRatio」と入力します。このグローバル値は、1 つの CSS ピクセルを形成するために必要な物理ピクセルの数を示します。devicePixelRatio(dPR)が 1 の場合、約 96 DPI のモニターを使用しています。網膜画面をお持ちの場合、dPR はおそらく 2 です。スマートフォンでは、23、さらには 2.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 をオンにすると、小数値が表示されます。

DevTools で、ズームにより小数点以下の devicePixelRatio がさまざまな形で表示される

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

要約: キャンバス要素には、描画できる領域を定義するために特定のサイズが設定されています。キャンバスのピクセル数は、CSS ピクセルで指定されるキャンバスの表示サイズとはまったく関係ありません。CSS のピクセル数は物理ピクセルの数とは異なります。

ピクセルの完璧さ

状況によっては、キャンバス ピクセルから物理ピクセルへの正確なマッピングが望ましい場合があります。このマッピングが行われたことを「ピクセル パーフェクト」と呼びます。ピクセルパーフェクト レンダリングは、テキストの判読性を高めるうえで非常に重要です。特に、サブピクセル レンダリングを使用する場合や、交互の輝度行をきちんと整列させてグラフィックを表示する場合はなおさらです。

ウェブで可能な限りピクセルパーフェクト キャンバスに近づけるために、多かれ少なかれ、この方法が採用されています。

<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 のピクセルは純粋に仮想であるため、理論的には 1 つのピクセルを分割しても問題ありませんが、ブラウザは物理ピクセルへのマッピングをどのように判断するのでしょうか。物理ピクセルの小数値は影響しないからです。

ピクセル スナップ

単位変換プロセスの中で、要素を物理ピクセルに揃える部分は「ピクセル スナップ」と呼ばれ、これは錫に書かれていることを実行します。つまり、小数ピクセル値を整数の物理ピクセル値にスナップします。実際の動作はブラウザによって異なります。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 以降でサポートされています。