使用 devicePixelContentBox 实现像素完美的渲染

画布中实际有多少像素?

自 Chrome 84 起,ResizeObserver 支持一种新的盒子测量方式 devicePixelContentBox,用于以物理像素为单位测量元素的尺寸。这有助于渲染像素级精准的图形,尤其是在高密度屏幕上。

Browser Support

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

Source

背景:CSS 像素、画布像素和物理像素

虽然我们经常使用抽象的长度单位(例如 em%vh),但最终都归结为像素。每当我们在 CSS 中指定元素的大小或位置时,浏览器的布局引擎最终都会将该值转换为像素 (px)。这些是“CSS 像素”,它们具有悠久的历史,并且与屏幕上的像素只有松散的关系。

在很长一段时间内,使用 96DPI(每英寸的点数)来估计任何人的屏幕像素密度都是相当合理的,这意味着任何给定的显示器每厘米大约有 38 个像素。随着时间的推移,显示器尺寸变大和/或变小,或者开始在相同的表面积上显示更多像素。再加上网页上的许多内容都以 px 为单位定义其尺寸(包括字体大小),最终导致这些高密度 (“HiDPI”) 屏幕上的文字难以辨认。作为一种对策,浏览器会隐藏显示器的实际像素密度,而是假装用户使用的是 96 DPI 的显示屏。CSS 中的 px 单位表示此虚拟 96 DPI 显示屏上一个像素的大小,因此称为“CSS 像素”。此单位仅用于测量和定位。在进行任何实际渲染之前,系统会先转换为物理像素。

如何从这个虚拟显示屏过渡到用户的实际显示屏?输入 devicePixelRatio。此全局值用于告知您需要多少个物理像素才能形成一个 CSS 像素。如果 devicePixelRatio(设备像素比)为 1,则您使用的是大约 96DPI 的显示器。如果您使用的是 Retina 屏幕,则 dPR 可能为 2。在手机上,遇到更高的(也更奇怪的)dPR 值(例如 23 甚至 2.65)并不罕见。请务必注意,此值是确切值,但无法由此推导出显示器的实际 DPI 值。dPR 为 2 表示 1 个 CSS 像素将精确映射到 2 个物理像素。

示例
根据 Chrome 的信息,我的显示器的 dPR 为 1

它的宽度为 3440 像素,显示区域宽度为 79 厘米。 这会带来 110 DPI 的分辨率。接近 96,但不是。 这也是为什么在大多数显示屏上,<div style="width: 1cm; height: 1cm"> 的大小不会正好是 1 厘米的原因。

最后,dPR 还可能会受到浏览器缩放功能的影响。如果您放大,浏览器会增加报告的 dPR,从而使所有内容都以更大的尺寸呈现。如果您在缩放时在开发者工具控制台中检查 devicePixelRatio,则会看到显示的小数值。

开发者工具显示了因缩放而产生的各种分数 devicePixelRatio

我们来添加 <canvas> 元素。您可以使用 widthheight 属性指定画布所需的像素数。因此,<canvas width=40 height=30> 将是一个 40x30 像素的画布。不过,这并不意味着它会以 40x30 像素的尺寸显示。默认情况下,画布将使用 widthheight 属性来定义其固有大小,但您可以使用熟悉且喜爱的所有 CSS 属性来任意调整画布的大小。根据我们目前所学的内容,您可能会意识到,这种方法并非在所有情况下都是理想的。画布上的一个像素最终可能会覆盖多个物理像素,或者仅覆盖一部分物理像素。这可能会导致令人不悦的视觉伪影。

总结来说:画布元素具有指定的大小,用于定义可供您绘制的区域。画布像素数完全独立于以 CSS 像素为单位指定的画布显示尺寸。CSS 像素的数量与物理像素的数量不同。

Pixel 完美体验

在某些情况下,最好能实现画布像素与物理像素之间的精确映射。如果实现了这种映射,则称为“像素级完美”。像素级精确渲染对于清晰地渲染文字至关重要,尤其是在使用子像素渲染或显示亮度交替的紧密对齐线条的图形时。

为了在 Web 上实现尽可能接近像素级完美的画布,我们或多或少会采用以下方法:

<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() 调用产生的小数像素值。

CSS 像素纯粹是虚拟的,因此理论上可以有像素分数,但浏览器如何确定到物理像素的映射?因为不存在小数形式的物理像素。

像素贴合

单位转换过程中负责将元素与物理像素对齐的部分称为“像素贴靠”,顾名思义,它会将小数像素值贴靠到整数物理像素值。具体实现方式因浏览器而异。如果我们在 dPR 为 1 的显示屏上有一个宽度为 791.984px 的元素,一个浏览器可能会以 792px 物理像素呈现该元素,而另一个浏览器可能会以 791px 物理像素呈现该元素。这只是一个像素的偏差,但对于需要像素级精确的渲染来说,一个像素的偏差可能会造成严重影响。这可能会导致模糊,甚至出现更明显的伪影,例如莫尔条纹效应

上图是不同颜色像素的栅格。下图与上图相同,但宽度和高度已使用双线性缩放减少了一个像素。这种新出现的图案称为莫尔效应。
(您可能需要在新标签页中打开此图片,才能看到未应用任何缩放效果的图片。)

devicePixelContentBox

devicePixelContentBox 以设备像素(即物理像素)为单位提供元素的内容框。它是 ResizeObserver 的一部分。虽然自 Safari 13.1 以来,所有主要浏览器都支持 ResizeObserver,但 devicePixelContentBox 属性目前仅在 Chrome 84 及更高版本中可用。

ResizeObserver:元素就像 document.onresize 一样,ResizeObserver 的回调函数将在绘制之前和布局之后调用。这意味着,回调的 entries 参数将包含所有被观测元素在绘制之前的尺寸。在上述画布问题的情境下,我们可以利用此机会调整画布上的像素数,确保最终实现画布像素与物理像素之间的一对一精确映射。

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
}

总结

像素是 Web 上一个非常复杂的主题,但直到现在,您都无法知道元素在用户屏幕上占据的实际像素数。ResizeObserverEntry 中的新 devicePixelContentBox 属性可提供该信息,并允许您使用 <canvas> 进行像素级精确渲染。Chrome 84 及更高版本支持 devicePixelContentBox