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

画布中实际有多少个像素?

从 Chrome 84 开始,ResizeObserver 支持一种名为 devicePixelContentBox 的新框测量,以测量元素的尺寸(以物理像素为单位)。这样可以渲染像素完美的图形,尤其是在高密度屏幕环境中。

浏览器支持

  • 84
  • 84
  • 93
  • x

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

虽然我们经常使用 em%vh 等抽象长度单位,但这一切都可以归结为像素。每当我们在 CSS 中指定元素的大小或位置时,浏览器的布局引擎最终都会将该值转换为像素 (px)。这些像素就是“CSS 像素”,这些像素有着大量的历史记录,只与屏幕上的像素存在松散关系。

长久以来,用 96DPI(“每英寸的点数”)估算任何人的屏幕像素密度是相当合理的,这意味着任何给定显示器的每厘米大约有 38 像素。随着时间的推移,显示器的同一表面区域出现像素变大和/或缩小,或像素数量开始增多。再加上网络上的许多内容都会以 px 定义尺寸,包括字体大小,最终会导致这些高密度(“HiDPI”)屏幕上的文字难以辨认。作为应对措施,浏览器会隐藏显示器的实际像素密度,改为假定用户的显示屏分辨率为 96 DPI。CSS 中的 px 单位表示此虚拟 96 DPI 屏幕上的一个像素大小,因此名称为“CSS Pixel”。该单位仅用于测量和定位。在实际呈现之前,会先转换为物理像素。

如何从虚拟显示屏转到用户的真实显示屏?输入 devicePixelRatio。通过该全局值,您可以确定单个 CSS 像素需要多少个物理像素。如果 devicePixelRatio (dPR) 为 1,则表示您正在使用的是具有约 96DPI 的显示器。如果您有视网膜屏幕,您的 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 像素数与物理像素数不同。

像素完美

在某些情况下,希望画布像素与物理像素具有精确映射。如果实现此映射,则称为“完美像素”。像素完美的渲染对于文本的清晰渲染至关重要,尤其是在使用亚像素渲染或以交替亮度紧密对齐的线条显示图形时。

为了在网络上实现像素尽可能完美的画布,以下还是常用方法:

<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 像素纯粹是虚拟的,因此在理论上,一个像素的小数部分是没问题的,但浏览器如何计算到物理像素的映射呢?因为小数“物理像素”并不存在,

像素贴靠

单位转换流程中负责将元素与物理像素对齐的部分称为“像素贴靠”,其作用是将部分像素值贴靠到整数物理像素值。具体展示方式因浏览器而异。如果屏幕上有一个宽度为 791.984px 的元素,并且 dPR 为 1,则一个浏览器可能会以 792px 物理像素渲染该元素,而另一个浏览器可能会以 791px 渲染该元素。这只是一个像素关闭,但对于需要完美像素呈现的渲染而言,单个像素可能是不利的。这可能会导致模糊,甚至可能导致更明显的伪影,例如莫尔效应

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

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() 的选项对象中的 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> 实现像素级完美渲染。Chrome 84 及更高版本支持 devicePixelContentBox