青雲的博客

Article

同一个 bug,我在 WebGPU 渲染器里犯了三次

· 11 分钟阅读

上一篇讲了怎么用实例化渲染让 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.writeBufferrenderPass 的 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_0glyph_1glyph_2……每段文字一块独立 buffer,submit 时都还在。三行字都正确了。

六、验证:修复后的真实渲染表现

bug 修完,跑 benchmark。下面是在 macOS + Chrome(GPU 硬件加速)上的真实测试数据。

矩形实例化(perf-demo)

10 万+矩形,一次 draw call,稳定 60 FPS:

WebGPU 矩形实例化渲染 - 124K 对象 60FPS

指标
对象数124,000
Draw Calls1
可见(视口剔除后)114,344
FPS60
缩放5%

单次 draw call 画 12 万矩形,四叉树剔除后 11 万+可见对象,满帧无压力。

圆 + 线 + 文本 (shapes-verify)

修复 buffer 复用 bug 后,三种图元一起跑:

WebGPU 圆/线/文本渲染验证 - 3200 对象 60FPS

指标
圆形2,000 个 (instanced)
线条1,000 条 (instanced)
文本200 段 (SDF)
总对象数3,200
Draw Calls202(圆 1 + 线 1 + 文本每段 1)
FPS60

圆形和线段各只用了 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):

  1. writeBuffer 立即排队,draw 延迟到 submit——一帧内往同一块 buffer 的同一位置写多次,所有读它的 draw 都只会看到最后一次写。 这是 retained/deferred 提交模型的通用陷阱,不止 WebGPU。

  2. 一个 bug 修完,先问自己”根因的最一般形式是什么”,再问”我的修复覆盖了这个一般形式,还是只覆盖了眼前这个特例”。 特例修复会给你假的安全感。

  3. 最该写测试的地方,往往是最难写测试的地方(时序、并发、GPU 状态)。 纯函数测试让覆盖率好看,但 bug 常常就活在那些”不好测所以没测”的缝里。


代码开源在 sky-canvas。两个 bug 和两次修复均为真实提交,可在仓库 commit 历史里找到(关键词:instance buffer)。

Keep Reading

相关文章

评论