WebGPU 画布尺寸调整(Resizing the Canvas)

英文原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-resizing-the-canvas.html


在 WebGPU 基础 一文中,我们搭建了一个基本结构,用于将画布的分辨率设置为与其显示尺寸相匹配。本文深入探讨画布尺寸调整的细节。

每个画布都有两种尺寸:

  • 绘图缓冲区(drawing buffer):画布内部实际包含的像素数量。
  • 显示尺寸(display size):CSS 决定画布在页面上的显示尺寸。

绘图缓冲区的大小可以通过两种方式设置。一种是使用 HTML:

<canvas id="c" width="400" height="300"></canvas>

另一种是使用 JavaScript:

<canvas id="c"></canvas>
const canvas = document.querySelector("#c");
canvas.width = 400;
canvas.height = 300;

至于画布的显示尺寸,如果没有任何 CSS 影响,显示尺寸将与绘图缓冲区相同。因此,以上两个示例中,绘图缓冲区为 400×300,显示尺寸也为 400×300。

下面是一个绘图缓冲区为 10×15 像素、却以 400×300 像素显示在页面上的例子:

<canvas id="c" width="10" height="15"></canvas>

或者这样:

<style>
#c {
  width: 400px;
  height: 300px;
}
</style>
<canvas id="c" width="10" height="15"></canvas>

如果在这样的画布上绘制一条旋转的单像素线,会看到类似这样的效果:

(示例 iframe 已省略,可点击链接查看原始示例)

为什么这么模糊?因为浏览器将 10×15 像素的画布拉伸到 400×300 像素,并在拉伸时进行了过滤(filtering)。

那么,如果我们希望画布填满整个窗口,该怎么做?首先,我们可以用 CSS 让浏览器将画布拉伸至窗口大小,例如:

<html>
  <head>
    <style>
    html, body {
      margin: 0;       /* 移除默认边距 */
      height: 100%;    /* 让 html 和 body 填满页面 */
    }
    #c {
      display: block;  /* 让画布像块级元素一样 */
      width: 100%;     /* 让画布填满其容器 */
      height: 100%;
    }
    </style>
  </head>
  <body>
    <canvas id="c"></canvas>
  </body>
</html>

接下来,我们需要让绘图缓冲区匹配浏览器拉伸后的画布尺寸。这比预想的要复杂一些。下面介绍几种不同的方法。

使用 ResizeObserver

我们在 WebGPU 基础文章中已经介绍过这种方法。这是现代方式,所有支持 WebGPU 的浏览器也都支持 ResizeObserver。

创建一个 ResizeObserver 并传入一个函数,每当被观察元素尺寸变化时该函数就会被调用。然后告诉它要观察哪些元素:

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    const width = entry.contentBoxSize[0].inlineSize;
    const height = entry.contentBoxSize[0].blockSize;
    const canvas = entry.target;
    canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
    canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
  }
});
observer.observe(canvas);

上述代码遍历所有条目,但因为我们只观察一个画布,所以通常只有一个条目。我们需要将画布尺寸限制在设备支持的最大值内,否则 WebGPU 会因尝试创建过大的纹理而报错。同时还要确保尺寸不为零,否则也会报错。

如果只按需渲染,可以将渲染函数调用放在上述代码中。如果使用 requestAnimationFrame 循环(rAF 循环)或其他方式持续动画,那么在下一次调用 context.getCurrentTexture() 时,纹理尺寸就会匹配我们设置的画布尺寸。

注意:inlineSize 和 blockSize 不是整数。

使用 clientWidth 和 clientHeight

在 ResizeObserver 出现之前,常用 clientWidth 和 clientHeight。这是每个 HTML 元素都具有的属性,表示元素在 CSS 像素中的尺寸。

注意:clientRect 包含 CSS 内边距,因此如果使用 clientWidth 和 clientHeight,最好不要在画布元素上设置内边距。

使用 JavaScript 可以检查元素当前的显示尺寸,然后调整绘图缓冲区大小以匹配:

  // 获取浏览器当前以 CSS 像素显示的画布尺寸
  const width = canvas.clientWidth;
  const height = canvas.clientHeight;
  canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
  canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));

在调用 context.getCurrentTexture() 之前使用这段代码。

这种方法略显过时,但你可能会在各种旧示例中见到它。

使用 getBoundingClientRect

另一种方法是调用 getBoundingClientRect:

  // 获取浏览器当前以 CSS 像素显示的画布尺寸
  const rect = canvas.getBoundingClientRect();
  const width = rect.width; 
  const height = rect.height; 
  canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
  canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));

clientWidth / clientHeight 与 getBoundingClientRect 的区别在于:getBoundingClientRect 返回的宽高不一定是整数,而 clientWidth 和 clientHeight 的值是整数。

为什么宽高不是整数?见下文。

使用 window.innerWidth 和 window.innerHeight

我经常看到这种做法,但这确实是一种反模式(anti-pattern)。原因是它不够灵活。前两种方法适用于任何场景,而使用 window.innerWidth 和 window.innerHeight 只在特定情况下有效——即你想让画布填满页面时。我们已经展示了前两种方法也能填满页面,而且它们在其他情况下同样有效。

例如画布不必填满页面:文章中的图示,或者带工具栏的编辑器。

使用前两种方法并不更费力,所以使用这种不太有用的方法显得不明智。不幸的是,“复制粘贴”的力量非常强大 😂

处理 devicePixelRatio 和缩放

为什么还没结束?事情从这里开始变得复杂。

首先要理解,浏览器中的大多数尺寸都使用 CSS 像素单位。这是为了让尺寸与设备无关。例如文章开头我们设置画布显示尺寸为 400×300 CSS 像素。根据用户使用的是 HD-DPI 显示器、浏览器缩放级别、操作系统缩放级别,这些 CSS 像素在显示器上对应的实际像素数会不同。

devicePixelRatio 通常告诉我们显示器上实际像素与 CSS 像素的比率。例如,你浏览器当前的设置如下:

devicePixelRatio = (动态值)

如果你在桌面或笔记本上,尝试按 ctrl++ 和 ctrl+- 进行缩放(Mac 上为 ⌘++ 和 ⌘+-)。在 Firefox、Chrome、Edge 中该数值会改变(Safari 除外)。

因此,如果我们希望画布中的像素数与实际用于显示的像素数一致,显而易见的方案是将之前查到的值乘以 devicePixelRatio,例如:

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
-    const width = entry.contentBoxSize[0].inlineSize;
-    const height = entry.contentBoxSize[0].blockSize;
+    const width = entry.contentBoxSize[0].inlineSize * devicePixelRatio;
+    const height = entry.contentBoxSize[0].blockSize * devicePixelRatio;

或者:

-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
+ const width = canvas.clientWidth * devicePixelRatio;
+ const height = canvas.clientHeight * devicePixelRatio;

或者:

  const rect = canvas.getBoundingClientRect();
-  const width = rect.width; 
-  const height = rect.height; 
+  const width = rect.width * devicePixelRatio; 
+  const height = rect.height * devicePixelRatio; 

⚠️ 以上示例实际上不会得到正确结果!!!

尽管如此,结果已经很接近,可能对你的需求来说已经足够。如果你不要求屏幕上完美的一对一像素渲染,可以使用上述方案。

有两个原因可以说明上述代码不能提供准确答案:

  1. devicePixelRatio 不是整数
    如果你在 Firefox、Edge 或 Chrome 中按缩放键,很容易看到小数 devicePixelRatio 值。

  2. 元素本身的尺寸也不是整数
    如前所述,ResizeObserver 和 getBoundingClientRect 返回的元素尺寸可能是非整数。

为了具体展示这个问题,我们可以创建一个 div,包含 3 个子元素,每个都设置为父元素宽度的 33%:

<div id="parent">
  <div id="left">`left</div>`
  <div id="middle">`middle</div>`
  <div id="right">`right</div>`
</div>
#parent {
  display: flex;
  width: 299px;
  height: 40px;
  align-items: stretch;
  background-color: red;
}
#parent>* {
  flex: 1 1 33%;
}
#left { background-color: #A44; }
#middle { background-color: #4A4; }
#right { background-color: #66C; }

在一台机器上,使用默认(未缩放)浏览器窗口,得到如下结果:

devicePixelRatio: 2
--------------- #left ---------------
                 inlineSize: 99.65625
                clientWidth: 100
getBoundingClientRect.width: 99.6640625
--------------- #middle ---------------
                 inlineSize: 99.65625
                clientWidth: 100
getBoundingClientRect.width: 99.6640625
--------------- #right ---------------
                 inlineSize: 99.65625
                clientWidth: 100
getBoundingClientRect.width: 99.6640625
--------------- #parent ---------------
                 inlineSize: 299
                clientWidth: 299
getBoundingClientRect.width: 299

首先注意:三个子元素的数值完全相同! 但父元素宽度为 299 CSS 像素。将其乘以 devicePixelRatio=2 得到 598 实际像素。我们有 3 个子元素,598 / 3 = 199.333...,无法拥有 199.333 个实际像素。如果四舍五入到 199,则 199+199+199=597,而父元素实际是 598。为了达到 598,其中一个元素必须多一个像素,但在所有信息都相同的情况下,哪个元素应该多出这一像素?

devicePixelContentBoxSize

解决方案是 ResizeObserver 提供的 devicePixelContentBoxSize。

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
-    const width = entry.contentBoxSize[0].inlineSize;
-    const height = entry.contentBoxSize[0].blockSize;
+    const width = entry.devicePixelContentBoxSize[0].inlineSize;
+    const height = entry.devicePixelContentBoxSize[0].blockSize;

如果将该测量添加到示例中,就能得到实际答案:

(示例 iframe 已省略,可点击链接查看原始示例)

在同一台机器上,得到:

devicePixelRatio: 2
--------------- #left ---------------
                          inlineSize: 99.65625
devicePixelContentBoxSize.inlineSize: 199    <=====
                         clientWidth: 100
         getBoundingClientRect.width: 99.6640625
--------------- #middle ---------------
                          inlineSize: 99.65625
devicePixelContentBoxSize.inlineSize: 200    <=====
                         clientWidth: 100
         getBoundingClientRect.width: 99.6640625
--------------- #right ---------------
                          inlineSize: 99.65625
devicePixelContentBoxSize.inlineSize: 199    <=====
                         clientWidth: 100
         getBoundingClientRect.width: 99.6640625
--------------- #parent ---------------
                          inlineSize: 299
devicePixelContentBoxSize.inlineSize: 598    <=====
                         clientWidth: 299
         getBoundingClientRect.width: 299

可以看到,浏览器将额外的像素分配给了中间元素——它宽 200 个设备像素,而另外两个各为 199。

这个问题不仅限于此例,它只是展示无法通过其他方式获取这一信息的最简单案例。关键在于,如果你追求像素完美,就不能只是将某个测量值乘以 devicePixelRatio,而必须使用 ResizeObserver 和 devicePixelContentBoxSize。

注意:截至 2023 年 11 月,Safari 不支持 devicePixelContentBoxSize,而且 Safari 在缩放时不改变 devicePixelRatio。这意味着在 Safari 上无法实现 1×1 像素完美的画布。

content-box 与 device-pixel-content-box

调用 ResizeObserver.observe 时,可以指定观察两种盒子尺寸之一。默认观察 content-box 尺寸,即元素的 CSS 尺寸。例如,无论缩放级别如何,父元素始终为 299 CSS 像素,子元素为 33%,始终为 99.666...(或计算出的类似值)。但如果元素占满整个页面,其 CSS 尺寸会随缩放而改变。¹

你也可以观察 device-pixel-content-box,即元素实际占用的设备像素数量。当缩放级别变化时,该值会改变¹。如果元素在设备像素中的尺寸没有实际变化(例如元素始终填满整个页面),则不会触发变化。

要告诉 ResizeObserver 观察哪种尺寸,在调用 observe 时传入:

resizeObserver.observe(someElement1, {box: 'device-pixel-content-box'});
resizeObserver.observe(someElement2, {box: 'content-box'});

不幸的是,Safari 仍然不支持,如果尝试传入 'device-pixel-content-box' 会抛出异常。

实际像素——最终解决方案

截至 2023 年 11 月,获取实际像素数量的解决方案是同时请求上述两种盒子,捕获 Safari 的不兼容问题,并在 devicePixelContentBoxSize 不可用时回退到 contentBoxSize。

以下是更新后的画布尺寸调整样板代码,可在所有符合标准的浏览器上实现像素完美渲染¹:

  const observer = new ResizeObserver(entries => {
    for (const entry of entries) {
      const width = entry.devicePixelContentBoxSize?.[0].inlineSize ||
                    entry.contentBoxSize[0].inlineSize * devicePixelRatio;
      const height = entry.devicePixelContentBoxSize?.[0].blockSize ||
                     entry.contentBoxSize[0].blockSize * devicePixelRatio;
      const canvas = entry.target;
      canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
      canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
      // 重新渲染
      render();
    }
  });
  try {
    observer.observe(canvas, { box: 'device-pixel-content-box' });
  } catch {
    observer.observe(canvas, { box: 'content-box' });
  }

我们可以通过绘制一个图案来测试:如果渲染不是像素完美,就会出现莫尔条纹(moiré pattern)。在阶段间变量一文中我们绘制过类似图案。

将画布尺寸调整代码替换为上述片段,并将图案改为品红、绿、白、黑棋盘格:

  @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
-    let red = vec4f(1, 0, 0, 1);
-    let cyan = vec4f(0, 1, 1, 1);
-    return select(red, cyan, checker);

+    let hv = vec2f(floor(fsInput.position.xy % 2));
+    return vec4f(1, 0, 1, 1) * hv.x +
+           vec4f(0, 1, 0, 1) * hv.y;
  }

同时将三角形顶点调大以覆盖整个画布²:

    let pos = array(
-      vec2f( 0.0,  0.5),  // top center
-      vec2f(-0.5, -0.5),  // bottom left
-      vec2f( 0.5, -0.5)   // bottom right
+      vec2f(-1.0,  3.0),
+      vec2f( 3.0, -1.0),
+      vec2f(-1.0, -1.0),
    );

(示例 iframe 已省略,可点击链接查看原始示例)

在新窗口打开并缩放,你应看到几乎像纯色的单调图案,不随缩放级别变化——除非在 Safari 上,缩放时可能出现莫尔条纹,表明无法达到像素完美。

注意:如果你想礼貌地为 Safari 支持 devicePixelContentBox 发声,可以添加至此处 bug 报告,以及关于 Safari 缩放时不改变 devicePixelRatio 的 bug 报告 此处。Bug 的优先级通常取决于关注度,请添加你的声音。

是否需要使用 devicePixelRatio?

绘制到更高分辨率比低分辨率更慢。并非总是需要使用 devicePixelRatio。即使决定支持,许多手机的 devicePixelRatio 高达 4,意味着每个 CSS 像素对应 16 个实际像素。绘制 16 倍像素最多可能比绘制 1 倍慢 16 倍。因此你可以考虑限制 devicePixelRatio 的使用,例如 dpr = Math.min(2, devicePixelRatio)。

此外,游戏在运行缓慢时体验较差,你可以考虑让用户选择倍率,这正是许多原生电脑游戏在图形设置中所做的。用户可以在分辨率与速度之间做出选择。


3 个核心优化要点

  1. 使用 ResizeObserver 与 devicePixelContentBoxSize 实现像素完美
    避免使用 clientWidth 或 getBoundingClientRect 乘以 devicePixelRatio 的方式手动计算设备像素尺寸,因为元素尺寸和 devicePixelRatio 都可能为非整数,导致画布像素与显示像素不匹配。优先使用 ResizeObserver 的 devicePixelContentBoxSize,并在不可用时回退到 contentBoxSize,以确保所有符合标准的浏览器上获得最佳渲染质量。

  2. 始终限制画布尺寸不超过设备最大纹理尺寸
    device.limits.maxTextureDimension2D 是 WebGPU 设备支持的最大 2D 纹理尺寸。在调整画布大小时,必须用 Math.min 限制宽高,否则 WebGPU 会因尝试创建超大纹理而报错。同时确保尺寸至少为 1,避免零尺寸错误。

  3. 权衡渲染分辨率与性能,考虑限制 devicePixelRatio
    高 DPI 渲染会显著增加像素填充率,导致性能下降。在高 DPR(如 4)设备上,绘制 16 倍像素可能使帧时间增加 16 倍。建议根据应用需求限制最大 DPR(例如 Math.min(2, devicePixelRatio)),或允许用户手动调整渲染分辨率,以平衡画质与速度。


专业术语检查与对照

英文术语中文翻译说明
Drawing buffer绘图缓冲区画布实际存储像素的缓冲区,其尺寸由 width / height 属性设置。
Display size显示尺寸CSS 布局尺寸,决定画布在页面上的显示大小。
CSS pixelsCSS 像素与设备无关的逻辑像素单位,用于 CSS 布局。
devicePixelRatio (DPR)设备像素比实际物理像素与 CSS 像素的比率,可能为非整数。
ResizeObserver尺寸观察器监听元素尺寸变化的 API。
contentBoxSize内容盒尺寸CSS 内容区域的尺寸,可能为非整数。
devicePixelContentBoxSize设备像素内容盒尺寸元素实际占用的设备像素数量,可能为非整数。
maxTextureDimension2D最大 2D 纹理尺寸WebGPU 设备限制之一,表示 2D 纹理的最大边长。
requestAnimationFrame (rAF)请求动画帧浏览器动画循环 API。
getCurrentTexture()获取当前纹理WebGPU 上下文方法,返回当前帧的渲染目标纹理。
moiré effect / pattern莫尔条纹 / 波纹图案因采样或分辨率不匹配导致的干涉图案。
WGSL (WebGPU Shading Language)WebGPU 着色语言WebGPU 使用的着色语言,类似 GLSL 但不同。
@fragment fn片段着色器函数WGSL 中片段着色器入口点。
vec4f四维浮点向量WGSL 数据类型。
select()选择函数WGSL 内置函数,根据布尔值选择两个值之一。
floor()向下取整WGSL 内置函数。
inlineSize / blockSize行内尺寸 / 块级尺寸对应水平/垂直方向尺寸(逻辑属性)。
Anti-pattern反模式编程中应避免的常见但不良的解决方案。
Texture纹理GPU 中存储图像数据的对象。

注:本文涉及的是 WebGPU / WGSL 技术栈,而非传统 WebGL / GLSL,但上述术语在 WebGPU 语境下准确,部分概念与 WebGL 通用。代码中出现的 vec4f、@fragment 等为 WGSL 语法,与 GLSL 的 vec4、gl_FragColor 有明显区别,请勿混淆。


脚注:

  1. 除了 Safari 🤬
  2. 有关这些顶点位置的原因,请参阅此文。

以上即为《WebGPU Resizing the Canvas》的深度解读编译,核心代码已保留,并提炼了三个优化要点,同时检查了相关专业术语。希望对你理解 WebGPU 画布尺寸调整有所帮助。