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;
⚠️ 以上示例实际上不会得到正确结果!!!
尽管如此,结果已经很接近,可能对你的需求来说已经足够。如果你不要求屏幕上完美的一对一像素渲染,可以使用上述方案。
有两个原因可以说明上述代码不能提供准确答案:
-
devicePixelRatio不是整数
如果你在 Firefox、Edge 或 Chrome 中按缩放键,很容易看到小数devicePixelRatio值。 -
元素本身的尺寸也不是整数
如前所述,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 个核心优化要点
-
使用
ResizeObserver与devicePixelContentBoxSize实现像素完美
避免使用clientWidth或getBoundingClientRect乘以devicePixelRatio的方式手动计算设备像素尺寸,因为元素尺寸和devicePixelRatio都可能为非整数,导致画布像素与显示像素不匹配。优先使用ResizeObserver的devicePixelContentBoxSize,并在不可用时回退到contentBoxSize,以确保所有符合标准的浏览器上获得最佳渲染质量。 -
始终限制画布尺寸不超过设备最大纹理尺寸
device.limits.maxTextureDimension2D是 WebGPU 设备支持的最大 2D 纹理尺寸。在调整画布大小时,必须用Math.min限制宽高,否则 WebGPU 会因尝试创建超大纹理而报错。同时确保尺寸至少为 1,避免零尺寸错误。 -
权衡渲染分辨率与性能,考虑限制
devicePixelRatio
高 DPI 渲染会显著增加像素填充率,导致性能下降。在高 DPR(如 4)设备上,绘制 16 倍像素可能使帧时间增加 16 倍。建议根据应用需求限制最大 DPR(例如Math.min(2, devicePixelRatio)),或允许用户手动调整渲染分辨率,以平衡画质与速度。
专业术语检查与对照
| 英文术语 | 中文翻译 | 说明 |
|---|---|---|
| Drawing buffer | 绘图缓冲区 | 画布实际存储像素的缓冲区,其尺寸由 width / height 属性设置。 |
| Display size | 显示尺寸 | CSS 布局尺寸,决定画布在页面上的显示大小。 |
| CSS pixels | CSS 像素 | 与设备无关的逻辑像素单位,用于 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 有明显区别,请勿混淆。
脚注:
- 除了 Safari 🤬
- 有关这些顶点位置的原因,请参阅此文。
以上即为《WebGPU Resizing the Canvas》的深度解读编译,核心代码已保留,并提炼了三个优化要点,同时检查了相关专业术语。希望对你理解 WebGPU 画布尺寸调整有所帮助。
