Article
WebGPU 实例化渲染:如何用一次 draw call 画十万个矩形
无限画布(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)的核心洞察是:这十万个矩形,几何形状是完全一样的——都是一个单位正方形,只是位置、大小、颜色不同。
那我们完全不必给每个矩形都下一遍绘制命令。做法是:
- 只上传一个单位 quad(4 个顶点、2 个三角形)作为共享几何;
- 把每个矩形独有的数据(位置 offset、尺寸 size、颜色 color)打包进一个单独的 buffer;
- 告诉 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 万(远超标题说的十万):

| 指标 | 数值 |
|---|---|
| 对象总数 | 500,000 |
| 视口剔除后可见 | 422,875 |
| Draw calls | 1 |
| FPS | 60(满帧) |
一次 draw call,五十万个矩形,满帧。作为对比,同规模下走「即时模式」(每对象一次 draw call)的渲染器,CPU 会被五十万次命令提交彻底压垮,根本到不了可交互的帧率。
这个数字里其实藏着两层优化的叠加:
- 实例化把 draw call 从 50 万压到 1——这是本文的主角;
- 视口剔除在 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