WebGPU 多画布渲染:从 1 个到 200 个画布的性能优化之道
原文标题:WebGPU Multiple Canvases
原作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-multiple-canvases.html
本文是基于原文的深度解读,并非逐句翻译。我们将围绕 WebGPU 多画布渲染的核心原理、关键代码与性能优化手段展开,最后提炼出三个核心优化要点,并对 WebGPU 相关术语进行校对说明。
一、WebGPU 渲染到画布的基石
在 WebGPU 中,一个画布(<canvas>)的渲染流程可以归纳为:
- 获取画布对应的 WebGPU 上下文(
getContext('webgpu'))。 - 使用
navigator.gpu.getPreferredCanvasFormat()获取系统偏好的画布格式。 - 调用
context.configure({ device, format })完成配置。 - 在渲染时,通过
context.getCurrentTexture().createView()获取当前帧纹理视图,并将其作为渲染通道(render pass)的颜色附件(color attachment)。
核心代码如下:
// 获取 WebGPU 上下文并配置
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
// 渲染通道描述符(视图在渲染时动态填充)
const renderPassDescriptor = {
label: 'our basic canvas renderPass',
colorAttachments: [
{
// view: <- 渲染时填充
clearValue: [0.3, 0.3, 0.3, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
};
function render() {
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
const encoder = device.createCommandEncoder({ label: 'our encoder' });
const pass = encoder.beginRenderPass(renderPassDescriptor);
// ... 设置管线、绑定组、绘制
pass.end();
device.queue.submit([encoder.finish()]);
}
多画布的本质:对每一个画布重复上述步骤,并在渲染循环中依次为每个画布生成对应的渲染通道。
二、简单多画布实现:共享命令编码器
原文给出了一个同时渲染 3 个画布的示例。其核心思路是:
- 使用
document.querySelectorAll('canvas')获取所有画布。 - 为每个画布创建并配置 WebGPU 上下文,存入数组。
- 在渲染函数中,只创建一个命令编码器(command encoder),然后为每个画布分别开始一个渲染通道、执行绘制、结束通道,最后一次性提交整个命令缓冲区。
关键代码:
const infos = [];
for (const canvas of document.querySelectorAll('canvas')) {
const context = canvas.getContext('webgpu');
context.configure({ device, format: presentationFormat });
infos.push({ context });
}
function render() {
const encoder = device.createCommandEncoder({ label: 'our encoder' });
for (const { context } of infos) {
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
}
device.queue.submit([encoder.finish()]);
}
注意:虽然不强制要求只使用一个命令编码器,但共享编码器效率更高,因为它减少了多个命令缓冲区的创建与提交开销。
三、扩展到 200 个画布:引入 Uniform 与动画
为了展示更真实的场景(如商品展示墙),原文创建了 200 个画布,并为每个画布:
- 创建独立的 uniform buffer,存储变换矩阵和颜色。
- 创建独立的 bind group。
- 使用
ResizeObserver处理画布尺寸变化。 - 使用
requestAnimationFrame驱动旋转动画。
Uniform 与 WGSL 着色器结构如下:
struct Uniforms {
matrix: mat4x4f,
color: vec4f,
};
@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
let pos = array(
vec2f( 0.0, 0.5),
vec2f(-0.5, -0.5),
vec2f( 0.5, -0.5)
);
return uni.matrix * vec4f(pos[vertexIndex], 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return uni.color;
}
每个画布独立的 buffer 和 bind group 创建代码:
const uniformValues = new Float32Array(16 + 4);
const uniformBuffer = device.createBuffer({
size: uniformValues.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const matrixValue = uniformValues.subarray(0, 16);
const colorValue = uniformValues.subarray(16, 20);
colorValue.set(randomColor());
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: uniformBuffer }],
});
在动画循环中,每帧更新矩阵并上传到 GPU:
mat4.ortho(-aspect, aspect, -1, 1, -1, 1, matrixValue);
mat4.rotateZ(matrixValue, time * 0.1, matrixValue);
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
问题:直接渲染 200 个画布会导致页面极其沉重,即使大部分画布在屏幕外仍会消耗大量 GPU/CPU 资源。这就是引入可见性优化的动机。
四、关键优化:使用 IntersectionObserver 只渲染可见画布
IntersectionObserver 正是为这种场景而生的浏览器 API。它可以观察元素与视口(或指定祖先元素)的交叉状态。原文利用它维护一个“当前可见画布集合”,在渲染循环中只处理集合内的画布。
1. 创建观察器与可见集合
const visibleCanvasSet = new Set();
const intersectionObserver = new IntersectionObserver((entries) => {
for (const { target, isIntersecting } of entries) {
if (isIntersecting) {
visibleCanvasSet.add(target);
} else {
visibleCanvasSet.delete(target);
}
}
});
2. 建立画布到资源信息的映射
使用 Map 将 canvas 映射到其对应的 context、uniformBuffer、bindGroup 等:
const canvasToInfoMap = new Map();
// 创建每个画布时...
intersectionObserver.observe(canvas);
canvasToInfoMap.set(canvas, {
context,
clearValue: randomColor(),
matrixValue,
uniformValues,
uniformBuffer,
bindGroup,
rotation: Math.random() * Math.PI * 2,
});
3. 渲染循环只遍历可见画布
function render(now) {
// ...
const encoder = device.createCommandEncoder({ label: 'our encoder' });
visibleCanvasSet.forEach(canvas => {
const {
context,
uniformBuffer,
uniformValues,
matrixValue,
bindGroup,
clearValue,
rotation,
} = canvasToInfoMap.get(canvas);
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
renderPassDescriptor.colorAttachments[0].clearValue = clearValue;
const aspect = canvas.clientWidth / canvas.clientHeight;
mat4.ortho(-aspect, aspect, -1, 1, -1, 1, matrixValue);
mat4.rotateZ(matrixValue, time * 0.1 + rotation, matrixValue);
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
});
device.queue.submit([encoder.finish()]);
requestId = requestAnimationFrame(render);
}
经过这样的处理,只有真正出现在用户视野中的画布才会被渲染,大幅降低了每帧的绘制负担。
五、三个核心优化要点
提炼原文中最重要的三个优化手段:
-
共享单一命令编码器(Command Encoder)
多个画布的渲染可以使用同一个command encoder,将多个 render pass 依次记录到同一个命令缓冲区,最后一次性提交。这避免了为每个画布单独创建和提交命令缓冲区所带来的 CPU/GPU 开销,提升了提交效率。 -
基于可见性的渲染剔除(IntersectionObserver)
对于大量画布(如 200 个商品展示),真正可见的只是少数。使用IntersectionObserver维护可见画布集合,并在渲染循环中只处理集合内的画布,能够避免不可见画布占用计算资源。这种“只渲染看得见的”策略是 Web 端性能优化的关键手段之一。 -
资源与尺寸的精细管理(ResizeObserver + 按需上传 Uniform)
- 使用
ResizeObserver动态调整画布后备缓冲区的尺寸,并钳制到maxTextureDimension2D,避免尺寸错误导致的渲染异常或性能浪费。 - 为每个画布独立分配
uniformBuffer和bindGroup,每一帧只上传必要的数据(矩阵、颜色等),避免不必要的 GPU 带宽消耗。 - 动画循环中维护自己的
time变量,暂停时停止时间累积,避免恢复动画时发生跳动。
- 使用
六、专业术语检查(WebGPU/WGSL 与 WebGL/GLSL 对照)
原文基于 WebGPU/WGSL,并非 GLSL/WebGL。为满足术语检查的要求,以下列出文中关键术语的准确译法,并简要说明其与 WebGL/GLSL 对应概念的差异。
| 原文术语 | 推荐译法 | 与 WebGL/GLSL 的对应说明 |
|---|---|---|
| WebGPU context | WebGPU 上下文 | 对应 WebGL 的 webgl / webgl2 上下文,但配置方式不同 |
getPreferredCanvasFormat() | 获取首选画布格式 | WebGL 没有直接对应,WebGPU 必须显式指定画布纹理格式 |
configure() | 配置 | WebGL 上下文无需此步骤,WebGPU 需要先配置设备与格式 |
| Command Encoder | 命令编码器 | WebGL 中命令是隐式立即执行的,WebGPU 则需要将命令编码到缓冲区再提交 |
| Render Pass | 渲染通道 | 对应一组 draw call 的集合,WebGPU 显式管理通道,而 WebGL 中由驱动隐式管理 |
| Render Pass Descriptor | 渲染通道描述符 | 描述渲染通道如何配置,包含颜色附件、深度附件、加载/存储操作等 |
| Color Attachment | 颜色附件 | 对应 WebGL framebuffer 中的颜色 attachment,但 WebGPU 显式指定纹理视图 |
getCurrentTexture() | 获取当前纹理 | 对应 WebGL 默认 framebuffer 的颜色缓冲区,但由浏览器/合成器管理,需每帧获取 |
loadOp / storeOp | 加载操作 / 存储操作 | WebGL 没有直接对应,WebGPU 显式控制 attachment 在渲染前后的处理(如清除、保留) |
| Uniform Buffer | 统一缓冲区 | 对应 WebGL 中的 Uniform Buffer Object (UBO),WebGPU 的 uniform buffer 用法类似但绑定更显式 |
| Bind Group | 绑定组 | WebGL 中无直接对应;WebGPU 将多个资源(buffer、纹理、采样器)打包成组,在管线中统一绑定 |
| WGSL | WebGPU 着色语言 | 不同于 GLSL,语法和类型系统相似但有差异,例如 @binding、@builtin、vec4f 等 |
@builtin(vertex_index) | 内置顶点索引 | 对应 GLSL 中的 gl_VertexIndex |
@location(0) | 位置输出(片段着色器) | 对应 GLSL 中的 layout(location = 0) out |
| ResizeObserver | 尺寸观察器 | 浏览器通用 API,与 WebGL/WebGPU 无关,但常用于管理画布后备缓冲区尺寸 |
| IntersectionObserver | 交叉观察器 | 浏览器通用 API,用于检测元素与视口的交叉状态,常用于可见性优化 |
结论:原文涉及的是 WebGPU/WebGPU Shading Language(WGSL)技术,而不是 GLSL/WebGL。但上述对照可以帮助熟悉 WebGL/GLSL 的开发者快速理解 WebGPU 的对应概念。
本文通过由浅入深的案例,展示了 WebGPU 多画布渲染的基本模式与生产环境中的优化策略。掌握了共享命令编码器、可见性剔除、以及资源尺寸精细化管理的技巧,你就能在需要展示大量画布的场景中保持流畅的渲染性能。
