Article
同一个 bug,我在 WebGPU 渲染器里犯了三次
上一篇讲了怎么用实例化渲染让 WebGPU 一次 draw call 画十万个矩形。这篇讲后来发生的事:当我把同样的手法套到圆、线、文字上,一个 GPU buffer 复用的 bug 连续犯了三次——rect/circle/line 一次,drawText 又一次。
代码全部真实,来自 sky-canvas。
一、起点:一个很自然的”优化”
矩形实例化跑通后,加圆、线、文字是顺理成章的事。它们共享同一个模式:单位 quad + per-instance 数据 + 一次 drawIndexed。我很自然地抽了个公共方法:
private uploadInstanceData(data: Float32Array): void {
// 复用一块 instance buffer,容量不足才扩容
if (!this.instanceBuffer || this.instanceBufferCapacity < data.byteLength) {
this.instanceBuffer?.destroy()
this.instanceBuffer = this.device.createBuffer({ /* ... */ })
}
this.device.queue.writeBuffer(this.instanceBuffer, 0, data) // ← 永远写 offset 0
}
“一块 buffer 反复用,省内存”——看起来很合理。rect、circle、line 三个绘制方法都调它。demo 里一帧内依次画:
renderer.drawInstancedRects(visible) // writeBuffer(buffer, 0, rectData)
renderer.drawInstancedLines(lines) // writeBuffer(buffer, 0, lineData)
renderer.drawInstancedCircles(circles) // writeBuffer(buffer, 0, circleData)
renderer.endFrame() // 到这里才 submit
跑起来——矩形没了,线也没了,满屏都是圆。
二、Bug:WebGPU 的时间线不是你写代码的时间线
问题出在一个容易被忽略的语义:queue.writeBuffer 和 renderPass 的 draw 命令,不在同一条时间线上。
代码是顺序执行的:写 rect 数据 → 记录 rect 的 draw → 写 line 数据 → 记录 line 的 draw……但这些操作被分成了两拨:
queue.writeBuffer(...)立即排进 queue 时间线;renderPass.draw(...)只是在 command encoder 里记录命令,要等queue.submit()才执行。
而 submit 发生在 endFrame。真实的执行顺序是:
帧内(记录阶段): writeBuffer(rect) → writeBuffer(line) → writeBuffer(circle)
↑ 三次都写同一块 buffer 的 offset 0,circle 最后写,覆盖前两次
submit 时: 执行全部 writeBuffer(buffer 里现在只剩 circle 数据)
→ 再执行 rect.draw / line.draw / circle.draw(全都读到 circle 数据)
三个 draw call 读的是同一块 buffer 的最终状态——最后写进去的 circle 数据。rect 的 draw 用 circle 的实例数据去画,顶点布局对不上(rect 每实例 8 float、circle 7 float),结果就是错乱加满屏乱圆。
根因一句话:一块共享 buffer + 帧内多次覆盖写 + 延迟到 submit 才 draw = 所有 draw 都读到最后一次写。
三、第一次修复:每种图元一块 buffer
修法不难——别让它们共享。把单一 buffer 换成按图元 key 分桶:
private instanceBuffers = new Map<string, { buffer: GPUBuffer; capacity: number }>()
private uploadInstanceData(key: string, data: Float32Array): GPUBuffer {
let slot = this.instanceBuffers.get(key)
if (!slot || slot.capacity < data.byteLength) {
slot?.buffer.destroy()
slot = { buffer: this.device.createBuffer({ /* ... */ }), capacity: /* ... */ }
this.instanceBuffers.set(key, slot)
}
this.device.queue.writeBuffer(slot.buffer, 0, data)
return slot.buffer
}
drawInstancedRects 传 'rect'、circle 传 'circle'、line 传 'line'。各写各的 buffer,submit 时互不干扰。矩形、线、圆都回来了。
我给这个修复写了 commit,心想:经典 GPU 新手坑,踩过了,记住了。
四、第二次:同一个 bug,换了个马甲
几天后加文字渲染(SDF glyph)。drawText 也要实例化——每个字符一个 instance。我照着刚建立的”每图元一块 buffer”模式写:
drawText(text, x, y, size, color) {
const data = /* 把每个字形打包成实例 */
const instanceBuffer = this.uploadInstanceData('glyph', data) // ← key 用 'glyph'
// ... draw
}
看起来很规矩——用了带 key 的新 API,key 是 'glyph'。测试、typecheck 全过。demo 里画三行文字:
renderer.drawText('Sky Canvas', ...) // uploadInstanceData('glyph', 第一段)
renderer.drawText('WebGPU SDF Text', ...) // uploadInstanceData('glyph', 第二段) ← 覆盖!
renderer.drawText('infinite canvas', ...) // uploadInstanceData('glyph', 第三段) ← 又覆盖!
只有最后一行 “infinite canvas” 渲染正确,前两行要么消失、要么显示成第三行的字形。
一模一样的 bug。我明明刚修过——但我修的时候,脑子里的模型是”每种图元一块 buffer”,于是 rect/circle/line 用不同 key 就解决了。可我漏了一种情况:同一种图元,一帧内画多次。文字就是这样——drawText 一帧会被调用很多次(每段文字一次),而它们全用固定的 'glyph' key,于是又回到了”共享一块 buffer 帧内覆盖”的原始 bug。
第一次修复给了我一个错误的安全感:我以为问题是”图元之间共享”,其实问题是”任何帧内多次写同一块 buffer”。rect/circle/line 恰好各画一次,按图元分 key 就够了——这掩盖了更一般的根因。
五、第二次修复:按调用序号分,不是按图元分
正确的粒度不是”每种图元”,是”每次绘制调用”:
private glyphDrawSeq = 0
beginFrame() {
// ...
this.glyphDrawSeq = 0 // 每帧重置
}
drawText(text, x, y, size, color) {
const data = /* ... */
// 每次调用用唯一 key,同帧多段文字互不覆盖
const instanceBuffer = this.uploadInstanceData(`glyph_${this.glyphDrawSeq++}`, data)
// ...
}
glyph_0、glyph_1、glyph_2……每段文字一块独立 buffer,submit 时都还在。三行字都正确了。
六、验证:修复后的真实渲染表现
bug 修完,跑 benchmark。下面是在 macOS + Chrome(GPU 硬件加速)上的真实测试数据。
矩形实例化(perf-demo)
10 万+矩形,一次 draw call,稳定 60 FPS:

| 指标 | 值 |
|---|---|
| 对象数 | 124,000 |
| Draw Calls | 1 |
| 可见(视口剔除后) | 114,344 |
| FPS | 60 |
| 缩放 | 5% |
单次 draw call 画 12 万矩形,四叉树剔除后 11 万+可见对象,满帧无压力。
圆 + 线 + 文本 (shapes-verify)
修复 buffer 复用 bug 后,三种图元一起跑:

| 指标 | 值 |
|---|---|
| 圆形 | 2,000 个 (instanced) |
| 线条 | 1,000 条 (instanced) |
| 文本 | 200 段 (SDF) |
| 总对象数 | 3,200 |
| Draw Calls | 202(圆 1 + 线 1 + 文本每段 1) |
| FPS | 60 |
圆形和线段各只用了 1 次 draw call(实例化),SDF 文本每段文字 1 次 draw call(每段的 glyph 实例合批)。全部稳定 60 FPS。
七、复盘:为什么会犯第二次
这才是想写这篇的原因。同一个人、同一周、刚修过的 bug,为什么会在几十行外重新犯一遍?
1. 第一次修复修的是”症状的一个切面”,不是根因。
“每图元一块 buffer” 能让 rect/circle/line 工作,是因为它们的调用次数恰好是 1。这个巧合让错误的心智模型(“问题 = 图元间共享”)通过了测试,而正确模型(“问题 = 帧内重复写”)没被逼出来。修复能跑,不代表你理解对了。
2. 抽象的边界骗了我。
drawText 用了带 key 的”新 API”,给人一种”我在遵循已修复的正确模式”的错觉。但 API 换了,调用它的模式(一帧多次)没变——bug 藏在调用模式里,不在 API 签名里。
3. 缺一个能表达根因的测试。
我为 buffer 打包逻辑写了单测,但那些是纯函数测试(数据布局对不对),测不到”帧内多次绘制”这种时序行为——而时序恰恰是 bug 的所在。纯函数好测的部分测了,难测的 GPU 时序部分正是漏网的部分。
能不能一劳永逸? 更彻底的做法是让 uploadInstanceData 在帧内累积写入(每次追加到 buffer 的新 offset,用 setVertexBuffer(buffer, offset) 指定区段),调用方根本无需关心 key。我暂时没做,因为它引入”帧内扩容会使已记录的 buffer 引用失效”的新复杂度——那是另一个坑。当前的 per-call key 方案够用且简单,把更彻底的方向记在了 issue 里。
八、三条总结
如果你也在写 WebGPU(或任何”记录命令、延迟提交”的图形 API):
-
writeBuffer立即排队,draw延迟到 submit——一帧内往同一块 buffer 的同一位置写多次,所有读它的 draw 都只会看到最后一次写。 这是 retained/deferred 提交模型的通用陷阱,不止 WebGPU。 -
一个 bug 修完,先问自己”根因的最一般形式是什么”,再问”我的修复覆盖了这个一般形式,还是只覆盖了眼前这个特例”。 特例修复会给你假的安全感。
-
最该写测试的地方,往往是最难写测试的地方(时序、并发、GPU 状态)。 纯函数测试让覆盖率好看,但 bug 常常就活在那些”不好测所以没测”的缝里。
代码开源在 sky-canvas。两个 bug 和两次修复均为真实提交,可在仓库 commit 历史里找到(关键词:instance buffer)。
Keep Reading