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

一张画布中实际上有多少像素?

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

浏览器支持

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

来源

虽然我们经常使用 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 个物理像素。

它宽度为 3440 像素,显示区域宽度为 79 厘米。因此,其分辨率为 110 DPI。接近 96,但不完全正确。 这也是在大多数显示屏上,<div style="width: 1cm; height: 1cm"> 的尺寸不会精确测量为 1 厘米的原因。

最后,dPR 还会受到浏览器的缩放功能的影响。如果放大,浏览器会增加报告的 dPR,导致所有内容变大。如果您在缩放时在开发者工具控制台中查看 devicePixelRatio,可以看到显示的小数值。

DevTools 因缩放而显示各种小数 devicePixelRatio

让我们向组合中添加 <canvas> 元素。您可以使用 widthheight 属性指定所需的画布像素数量。因此 <canvas width=40 height=30> 将是大小为 40 x 30 像素的画布。不过,这并不意味着该图片将以 40x30 像素显示。默认情况下,画布将使用 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 个像素也可能造成不利影响。这可能会导致画面模糊,甚至造成更明显的伪影,例如波纹效应

顶部的图片是颜色不同的像素的光栅。下面的图片与上图相同,但其宽度和高度通过双线性缩放减少了一个像素。出现的图案称为“莫尔埃效应”。
(您可能需要在新标签页中打开此图片,才能在不应用任何缩放的情况下查看。)

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
}

总结

像素在网络世界中是一个非常复杂的主题,到目前为止,您还无法知道某个元素在用户屏幕上所占的确切物理像素数。ResizeObserverEntry 上的新 devicePixelContentBox 属性可为您提供这项信息,并允许您使用 <canvas> 进行像素级渲染。Chrome 84 及更高版本支持 devicePixelContentBox