WebGPU 多画布渲染:从 1 个到 200 个画布的性能优化之道

原文标题:WebGPU Multiple Canvases
原作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-multiple-canvases.html

本文是基于原文的深度解读,并非逐句翻译。我们将围绕 WebGPU 多画布渲染的核心原理、关键代码与性能优化手段展开,最后提炼出三个核心优化要点,并对 WebGPU 相关术语进行校对说明。


一、WebGPU 渲染到画布的基石

在 WebGPU 中,一个画布(<canvas>)的渲染流程可以归纳为:

  1. 获取画布对应的 WebGPU 上下文(getContext('webgpu'))。
  2. 使用 navigator.gpu.getPreferredCanvasFormat() 获取系统偏好的画布格式。
  3. 调用 context.configure({ device, format }) 完成配置。
  4. 在渲染时,通过 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);
}

经过这样的处理,只有真正出现在用户视野中的画布才会被渲染,大幅降低了每帧的绘制负担。


五、三个核心优化要点

提炼原文中最重要的三个优化手段:

  1. 共享单一命令编码器(Command Encoder)
    多个画布的渲染可以使用同一个 command encoder,将多个 render pass 依次记录到同一个命令缓冲区,最后一次性提交。这避免了为每个画布单独创建和提交命令缓冲区所带来的 CPU/GPU 开销,提升了提交效率。

  2. 基于可见性的渲染剔除(IntersectionObserver)
    对于大量画布(如 200 个商品展示),真正可见的只是少数。使用 IntersectionObserver 维护可见画布集合,并在渲染循环中只处理集合内的画布,能够避免不可见画布占用计算资源。这种“只渲染看得见的”策略是 Web 端性能优化的关键手段之一。

  3. 资源与尺寸的精细管理(ResizeObserver + 按需上传 Uniform)

    • 使用 ResizeObserver 动态调整画布后备缓冲区的尺寸,并钳制到 maxTextureDimension2D,避免尺寸错误导致的渲染异常或性能浪费。
    • 为每个画布独立分配 uniformBuffer 和 bindGroup,每一帧只上传必要的数据(矩阵、颜色等),避免不必要的 GPU 带宽消耗。
    • 动画循环中维护自己的 time 变量,暂停时停止时间累积,避免恢复动画时发生跳动。

六、专业术语检查(WebGPU/WGSL 与 WebGL/GLSL 对照)

原文基于 WebGPU/WGSL,并非 GLSL/WebGL。为满足术语检查的要求,以下列出文中关键术语的准确译法,并简要说明其与 WebGL/GLSL 对应概念的差异。

原文术语推荐译法与 WebGL/GLSL 的对应说明
WebGPU contextWebGPU 上下文对应 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、纹理、采样器)打包成组,在管线中统一绑定
WGSLWebGPU 着色语言不同于 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 多画布渲染的基本模式与生产环境中的优化策略。掌握了共享命令编码器、可见性剔除、以及资源尺寸精细化管理的技巧,你就能在需要展示大量画布的场景中保持流畅的渲染性能。