同一个 bug,我在 WebGPU 渲染器里犯了两轮:一帧内多次写同一块 buffer

同一个 bug,我在 WebGPU 渲染器里犯了两轮:一帧内多次写同一块 buffer

上一篇讲了怎么用实例化渲染让 WebGPU 一次 draw call 画十万个矩形(传送门)。这篇讲后来发生的事:把同样手法套到圆、线、文字时,一个 GPU buffer 复用 bug 连续犯了两轮------第一轮是 rect/circle/line 三种图元一帧内三次写同一块 buffer,第二轮是 drawText 同帧被多次调用时复发。

这不是一篇"教你怎么修 bug"的文章,而是一篇"为什么一个修过的 bug 会换个地方重新长出来"的复盘。代码全部真实,来自 sky-canvas

大家好,我是青雲。

先说个扎心的结论:修复能跑,不代表你理解对了。 我第一次修完这个 bug 时信心满满,结果几天后同一个 bug 在几十行外重新冒了出来。


一、起点:一个很自然的"优化"

矩形实例化跑通后,加圆、线、文字是顺理成章的事。它们共享同一个模式:单位 quad + per-instance 数据 + 一次 drawIndexed。我很自然地抽了个公共方法:

typescript 复制代码
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 里一帧内依次画:

typescript 复制代码
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。真实的执行顺序是:

arduino 复制代码
帧内(记录阶段):  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 分桶:

typescript 复制代码
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"模式写:

typescript 复制代码
drawText(text, x, y, size, color) {
  const data = /* 把每个字形打包成实例 */
  const instanceBuffer = this.uploadInstanceData('glyph', data)  // ← key 用 'glyph'
  // ... draw
}

看起来很规矩------用了带 key 的新 API,key 是 'glyph'。测试、typecheck 全过。demo 里画三行文字:

typescript 复制代码
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 就够了------这掩盖了更一般的根因。

五、第二次修复:按调用序号分,不是按图元分

正确的粒度不是"每种图元",是"每次绘制调用":

typescript 复制代码
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:

指标
对象数 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):

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

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

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


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

我是青雲,正在用 WebGPU 从零打磨一个无限画布引擎 sky-canvas,欢迎围观。后续会继续写 WebGPU 渲染底座的实战记录(SDF 文本、四叉树剔除、批处理),关注不迷路

相关推荐
用户6919026813391 小时前
React 组件间通信全解析:从父子到跨级
前端·javascript
胡萝卜术1 小时前
数据主权与渲染防线:从受控组件到性能优化的完整图景
前端·javascript·面试
DFT计算杂谈1 小时前
FeSe超薄膜在CaF2衬底上的电子结构DFT研究
java·服务器·前端
BreezeJiang1 小时前
父组件一更新,子组件就必须跟着更新吗?从 memo 走到 useCallback
前端·javascript
胡萝卜术1 小时前
复用与并行:从自定义 Hooks 封装状态逻辑,到 Web Worker 的多线程计算
前端·javascript·面试
circuitsosk1 小时前
长文本与高并发下的Token“瘦身”策略:Prompt压缩与上下文窗口优化
java·前端·python·prompt·上下文窗口·token优化
触底反弹1 小时前
🏗️ 写完 Todos 之后,大型 React 项目的 7 个架构真相
前端·react.js·前端框架
huabuyu1 小时前
CLS 总是修不好?因为你只盯着分数,从没拆开看过它
前端·javascript
倾颜1 小时前
会 Vue / React,上手 Electron 真没那么难:前端开发者需要补齐的核心知识
前端