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

画布中实际有多少像素?

从 Chrome 84 开始,ResizeObserver 支持一种名为 devicePixelContentBox 的新盒子测量方法,该方法以实际像素衡量元素的尺寸。这有助于渲染像素级精确的图形,尤其是在高密度屏幕的情况下。

浏览器支持

  • Chrome:84。
  • Edge:84。
  • Firefox:93.
  • Safari:不受支持。

来源

背景:CSS 像素、画布像素和实际像素

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

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

如何从此虚拟显示屏切换到用户的真实显示屏?输入 devicePixelRatio。此全局值可让您了解需要多少个实体像素才能构成单个 CSS 像素。如果 devicePixelRatio (dPR) 为 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,导致所有内容都呈现得更大。如果您在缩放时在 DevTools 控制台中选中 devicePixelRatio,则会看到出现小数值。

由于缩放,DevTools 显示了各种小数 devicePixelRatio

我们来添加 <canvas> 元素。您可以使用 widthheight 属性指定画布应具有的像素数。因此,<canvas width=40 height=30> 将是一个尺寸为 40 x 30 像素的画布。不过,这并不意味着它会以 40 x 30 像素的尺寸显示。默认情况下,画布将使用 widthheight 属性来定义其固有尺寸,但您可以使用自己熟悉的所有 CSS 属性来任意调整画布的大小。通过我们到目前为止学到的所有内容,您可能会发现,这种方法并不适用于所有场景。画布上的某个像素最终可能会覆盖多个实体像素,也可能只覆盖实体像素的一小部分。这可能会导致不美观的视觉伪影。

总结一下:画布元素具有给定大小,用于定义可绘制区域。画布像素数完全独立于以 CSS 像素为单位指定的画布的显示大小。CSS 像素数与实际像素数不同。

像素精致

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

为了在 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% 的元素最终会生成一个矩形,如下所示:

DevTools 显示了 getBoundingClientRect() 调用导致的部分像素值。

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 参数将包含所有被观察元素在绘制之前的大小。在上面概述的画布问题中,我们可以利用以下机会调整画布上的像素数,确保最终在画布像素和实际像素之间实现完全的一对一映射。

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