青雲的博客

Article

WebGPU 实例化渲染:如何用一次 draw call 画十万个矩形

· 9 分钟阅读

无限画布(Figma、Miro、tldraw 这类)最硬的性能瓶颈,是当画布上堆了十万个对象时还要保持 60fps。主流开源无限画布(tldraw、excalidraw)底层都是 Canvas2D,PixiJS 是 WebGL 时代的架构。本文记录我在给 sky-canvas 做 WebGPU 渲染底座时,如何用「实例化渲染」把海量矩形的绘制从 O(N) 个 draw call 压到 O(1)。

一、问题:即时模式为什么会崩

大多数 2D 渲染器最初都长这样——每画一个图形,就走一遍完整流程:

// 即时模式(immediate mode):每个矩形一次 draw call
drawGeometry(vertices, indices) {
  const vertexBuffer = this.bufferManager.createVertexBuffer(vertices)  // 建 buffer
  const indexBuffer = this.bufferManager.createIndexBuffer(indices)     // 建 buffer
  renderPass.setPipeline(pipeline)
  renderPass.setVertexBuffer(0, vertexBuffer)
  renderPass.setIndexBuffer(indexBuffer, 'uint16')
  renderPass.drawIndexed(indexCount)                                    // 1 次 draw call
  vertexBuffer.destroy()                                                // 立刻销毁
  indexBuffer.destroy()
}

单个图形这么写没问题。但无限画布的场景是十万个矩形。这段代码在这种规模下会做:

  • 10 万次 GPU buffer 创建 + 销毁
  • 10 万次 drawIndexed(即 10 万个 draw call)

每个 draw call 都有固定的 CPU→GPU 提交开销(状态切换、命令编码)。十万次叠加,CPU 会被”喂命令”这件事本身拖死——GPU 其实很闲,瓶颈全在提交端。这就是为什么很多”看起来简单”的 2D 渲染器一旦对象上量就卡成幻灯片。

二、思路:让 GPU 自己重复,而不是 CPU 重复下命令

实例化渲染(instancing)的核心洞察是:这十万个矩形,几何形状是完全一样的——都是一个单位正方形,只是位置、大小、颜色不同。

那我们完全不必给每个矩形都下一遍绘制命令。做法是:

  1. 只上传一个单位 quad(4 个顶点、2 个三角形)作为共享几何;
  2. 把每个矩形独有的数据(位置 offset、尺寸 size、颜色 color)打包进一个单独的 buffer;
  3. 告诉 GPU:“用这个 quad,画 10 万遍,每遍从 instance buffer 里取一份独有数据。”

一次 drawIndexed(6, 100000) —— 6 个索引(两个三角形),100000 个实例。Draw call 从十万降到一。

三、实现:WebGPU 的三块拼图

3.1 两个 vertex buffer,不同的 step mode

WebGPU 的顶点缓冲支持 stepMode,这是实例化的关键:

  • stepMode: 'vertex' —— 每个顶点推进一次(用于共享 quad)
  • stepMode: 'instance' —— 每个实例推进一次(用于 per-instance 数据)

管线里声明两个 buffer:

// buffer 0: 单位 quad 顶点,每顶点 2 float,逐顶点推进
const quadLayout: GPUVertexBufferLayout = {
  arrayStride: 2 * 4,
  stepMode: 'vertex',
  attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }],
}

// buffer 1: per-instance 数据,offset(2) + size(2) + color(4) = 8 float,逐实例推进
const instanceLayout: GPUVertexBufferLayout = {
  arrayStride: 8 * 4,
  stepMode: 'instance',
  attributes: [
    { shaderLocation: 1, offset: 0,     format: 'float32x2' }, // i_offset
    { shaderLocation: 2, offset: 2 * 4, format: 'float32x2' }, // i_size
    { shaderLocation: 3, offset: 4 * 4, format: 'float32x4' }, // i_color
  ],
}

device.createRenderPipeline({
  vertex: { module, entryPoint: 'main', buffers: [quadLayout, instanceLayout] },
  // ...
})

3.2 WGSL:在 GPU 里把单位 quad 展开成每个矩形

顶点着色器接收共享的 quad 顶点 + 当前实例的数据,算出这个实例在世界里的真实顶点:

struct VertexInput {
  @location(0) position: vec2<f32>,   // 单位 quad 顶点 (0,0)~(1,1)
  @location(1) i_offset: vec2<f32>,   // per-instance: 世界位置
  @location(2) i_size:   vec2<f32>,   // per-instance: 宽高
  @location(3) i_color:  vec4<f32>,   // per-instance: 颜色
}

@vertex
fn main(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;
  // 关键一行:单位 quad 顶点 × 尺寸 + 偏移 = 这个实例的世界坐标
  let worldPos = input.i_offset + input.position * input.i_size;
  let modelPos = uniforms.modelMatrix * vec3<f32>(worldPos, 1.0);
  let projPos  = uniforms.projectionMatrix * modelPos;
  output.position = vec4<f32>(projPos.xy, 0.0, 1.0);
  output.color = input.i_color;
  return output;
}

input.position * input.i_size + input.i_offset 这一步,把 (0,0)-(1,1) 的单位方块,变换成任意位置、任意大小的矩形——这个乘加发生在 GPU 上,对每个实例并行执行,CPU 完全不参与。

3.3 CPU 侧:把实例打包成一段紧凑内存

CPU 要做的只剩”把数据摆成 GPU 想要的布局”:

// 每实例 8 float = [x, y, width, height, r, g, b, a]
export function packRectInstances(rects: RectInstance[]): Float32Array {
  const data = new Float32Array(rects.length * 8)
  for (let i = 0; i < rects.length; i++) {
    const r = rects[i], o = i * 8
    data[o] = r.x;      data[o+1] = r.y
    data[o+2] = r.width; data[o+3] = r.height
    data[o+4] = r.color.r; data[o+5] = r.color.g
    data[o+6] = r.color.b; data[o+7] = r.color.a
  }
  return data
}

然后一次性写进 instance buffer、一次 draw:

device.queue.writeBuffer(instanceBuffer, 0, data)
renderPass.setPipeline(instancedPipeline)
renderPass.setVertexBuffer(0, quadBuffer)      // 共享 quad
renderPass.setVertexBuffer(1, instanceBuffer)  // per-instance 数据
renderPass.setIndexBuffer(quadIndexBuffer, 'uint16')
renderPass.drawIndexed(6, rects.length)        // ← 一次 draw call 画全部

四、两个容易踩的工程细节

① instance buffer 不要每帧重建。 每帧 createBuffer + destroy 会把省下来的开销又还回去。正确做法是复用一个 buffer,容量不足时按 2 倍扩容:

if (!this.instanceBuffer || this.instanceBufferCapacity < byteLength) {
  this.instanceBuffer?.destroy()
  const newCapacity = Math.max(byteLength, this.instanceBufferCapacity * 2)
  this.instanceBuffer = device.createBuffer({
    size: newCapacity,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  })
  this.instanceBufferCapacity = newCapacity
}
device.queue.writeBuffer(this.instanceBuffer, 0, data)  // 只更新数据,不重建

② 无限画布还要叠一层视口剔除。 实例化解决的是”提交开销”,但如果画布有一百万个对象、屏幕只看得到几千个,把一百万个全打进 instance buffer 依然浪费。所以上层要先做视口剔除(理想是四叉树空间索引),只把可见的实例喂给 drawInstancedRects。实例化 + 剔除,才是无限画布海量对象的完整解法。

五、跑分:50 万对象,单次 draw call,稳定 60fps

在带独立 GPU 的 Chrome 上实测,直接把对象数拉到 50 万(远超标题说的十万):

WebGPU 实例化渲染跑分:50 万对象,单次 draw call,满帧 60FPS

指标数值
对象总数500,000
视口剔除后可见422,875
Draw calls1
FPS60(满帧)

一次 draw call,五十万个矩形,满帧。作为对比,同规模下走「即时模式」(每对象一次 draw call)的渲染器,CPU 会被五十万次命令提交彻底压垮,根本到不了可交互的帧率。

这个数字里其实藏着两层优化的叠加:

  1. 实例化把 draw call 从 50 万压到 1——这是本文的主角;
  2. 视口剔除在 CPU 侧先筛掉视口外的对象,只把可见实例喂进 instance buffer(截图缩放到 6% 时几乎整个世界都在视口内,所以可见数仍有 42 万;放大后可见数会骤降)。

两者缺一不可:光有实例化,百万级对象全量上传 instance buffer 依然浪费带宽;光有剔除,视口内几十万对象若逐个 draw call 仍然崩。实例化 + 剔除,才是无限画布海量对象的完整解法。

补充:当前剔除用的是线性扫描(O(N) 遍历全部对象做视口判断),50 万级尚可;要上百万级、或对象分布极不均匀时,应换成四叉树空间索引,把剔除本身也降到近似 O(可见数)。这是下一步优化。

结语

“WebGPU + 无限画布 + 海量对象”这个组合,现在的开源生态里还是空的:主流无限画布是 Canvas2D,主流 WebGPU 引擎是给 3D/游戏的。实例化渲染是把这块地基做实的第一步。代码开源在 sky-canvas,欢迎围观、拍砖、一起把它做成。


本文代码基于 sky-canvas 的 refactor/webgpu-infinite-canvas 分支,均为可运行的真实实现,非伪代码。

Keep Reading

相关文章

评论