2.3 缓冲区:CPU 写入 vs GPU 常驻 本节摘要:WebGL 缓冲区是 GPU 可消费的一块类型化内存。CPU 用 或 写入,之后顶点着色器通过 Attribute 指针读取,索引绘制通过元素缓冲读取。写入频率决定你该给驱动的使用提示:几乎只写一次,还是每帧流式更新。把动态数据塞进“当静态用”的习惯路径,或反过来每帧重传整网静态网格,是两端账单同时恶化的典型写法。 本节地图 阅读完本节,你应当能够: 说明 与 分别服务谁 对照 整块替换与 局部更新 解释 STATIC、DYNAMIC、STREAM 提示对驱动的含义,以及它们不是硬协议 在 WebGL1 与 WebGL2 之间对照 VAO 如何打包缓冲绑定 判断粒子位置、骨骼矩阵、静态建筑网格各自该用哪种写入策略 缓冲是仓库,不是
本节摘要:WebGL 缓冲区是 GPU 可消费的一块类型化内存。CPU 用
bufferData或bufferSubData写入,之后顶点着色器通过 Attribute 指针读取,索引绘制通过元素缓冲读取。写入频率决定你该给驱动的使用提示:几乎只写一次,还是每帧流式更新。把动态数据塞进“当静态用”的习惯路径,或反过来每帧重传整网静态网格,是两端账单同时恶化的典型写法。
阅读完本节,你应当能够:
ARRAY_BUFFER 与 ELEMENT_ARRAY_BUFFER 分别服务谁bufferData 整块替换与 bufferSubData 局部更新Float32Array 活在 CPU 堆上。createBuffer 加 bufferData 才在 GPU 侧(或驱动管理的共享区)建仓库。拷贝发生在写入那一刻。之后你改 JS 数组,画面不会变,除非再写一次。这是初学者最常见的错觉:以为 GPU 还指着那份数组。没有。合同是拷贝语义,不是引用语义。
仓库有绑定点。ARRAY_BUFFER 是当前用于顶点属性的仓库入口;ELEMENT_ARRAY_BUFFER 是索引入口。很多调用没有把缓冲当参数传来传去,而是“先 bind,再操作当前绑定”。状态机风格让漏 bind 的后果是:数据写进了上一份对象。排查“网格串味”,先打印你以为的绑定,再怀疑着色器。
CPU: Float32Array [x y z x y z ...] │ bufferData 拷贝 GPU: Buffer 对象 常驻,直到 delete 或上下文丢失 │ vertexAttribPointer 解释 VS: aPosition 每个顶点取一截
上下文丢失(切后台、GPU 重置)会让仓库蒸发。引擎通常有恢复流程;裸写必须监听丢失与恢复,重新 bufferData。这再次证明:CPU 数组才是可重建的源,GPU 缓冲是缓存。源被你丢掉、只留 GPU 对象,丢失后便无法恢复。
| 操作 | 行为 | 适合 | 不适合 |
|---|---|---|---|
bufferData 整块 |
可分配或替换整仓 | 首次上传、尺寸变化 | 每帧只改几个顶点却重传整网 |
bufferSubData |
改仓内一段 | 局部变形、部分粒子 | 每次改的范围其实是整仓 |
| 映射缓冲 | WebGL2 更完整 | 流式顶点 | WebGL1 几乎别指望同一套代码 |
每帧 bufferData 新数组 |
简单粗暴 | 原型 | 静态场景、大网格 |
使用提示 STATIC_DRAW、DYNAMIC_DRAW、STREAM_DRAW 是给驱动的频率暗示:数据多久改一次、是否作为绘制源。它们不是权限位,乱填通常仍能画,但驱动可能把仓库放在不合适的堆。我的经验规则:网格一辈子不变 → STATIC;每几帧改一块 → DYNAMIC;每帧由 CPU gener 全新内容 → STREAM。粒子若改为 GPU 反馈更新,提示应跟着策略改,而不是永远 STREAM。
gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW); // 若干帧后只改前 12 个 float gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(head));
WebGL1 里属性布局散落全局:绑 VBO、vertexAttribPointer、enableVertexAttribArray,换网格就要重做一遍。VAO 把“哪些缓冲、如何切分”打包。WebGL2 核心拥有 VAO;WebGL1 靠扩展。对照很直接:没有 VAO 时,CPU 每 draw 前重复解释仓库;有 VAO 时,解释发生在建立时,绘制只切对象。第 1.1 节提过别把 2.0 当 1.0 用,这里是具体落点。
交错存储与分缓冲是另一组对照。把位置、法线、UV 塞进一个 VBO,用 stride 切开,缓存更友好,一次绑定。分成三个 VBO,更新位置时不必动 UV,动态网格更省。静态角色网格我倾向交错;CPU 每帧改位置的粒子,位置单独一个 STREAM 仓,颜色 STATIC。没有万能布局,只有“谁在写、写多频”的布局。

建筑场景的墙体:CPU 加载时写一次 STATIC,整局只读。角色世界矩阵:那不是顶点缓冲,是 Uniform,别误塞 VBO。CPU 蒙皮若每帧把变形后的位置写入 VBO,仓库变成 STREAM,CPU 与总线一起忙——第 4.4 节对照 GPU 蒙皮,就是为了把这笔写入从 CPU 拿掉。
多缓冲(double/triple buffering)用于流式:CPU 写 A 仓时 GPU 读 B 仓,避免读写冲突造成同步。WebGL 里这套要自己管,引擎对动态几何常有类似池。原型阶段可以不管;性能面板出现 bufferSubData 与绘制争用时再上。
WebGL2 的 copyBufferSubData、Uniform Buffer,让“CPU 少写、GPU 内部搬”成为选项。UBO 适合多着色器共享的同一块相机矩阵。对照 WebGL1:同一矩阵要对每个 program uniformMatrix4fv 一次,CPU 重复提交。迁 2.0 却仍对每个 program 逐个传 4x4,等于放弃这条减 CPU 的路。
⚠️ 常见坑:每帧
new Float32Array再bufferData静态网格。GC 与拷贝会在低端机上比着色器更明显。网格不变就让仓库活着。💡 关键直觉:缓冲优化问三句——还改吗、改哪一段、能不能改成 Uniform 或 VS 公式从而不再写仓。
删除与泄漏:页面里反复创建几何却不 deleteBuffer,显存会涨。引擎的 dispose 必须真正走到删除。单页应用切路由时最常见。CPU 侧数组可以丢掉以省堆;GPU 侧必须显式删。两边的“丢掉”不是同一个 API。
类型化数组避免了普通 JS 数组到驱动的二次转换,这只解决了 CPU 内部格式。上传仍可能是跨进程、跨芯片拷贝。一万顶点一次无所谓;每帧一百万顶点,带宽会先于 ALU 爆掉。所以程序化城市若拓扑不变,应在加载帧上传,不要在逻辑帧生成。生成可以在 Worker,上传仍要回到拥有上下文的线程——上下文不同步共享,这是 WebGL 的硬边界。
交错布局的 vertexAttribPointer 参数:size、type、normalized、stride、offset。CPU 必须与 GPU 解释一致。stride 为 0 表示紧密排列。写错 offset,法线会吃到位置的字节,光照会像发霉。这是组装合同,不是运行时随机。把布局写成一张表贴在创建 VAO 的代码旁,比事后猜更省命。
浏览器会在切后台、GPU 进程崩溃时丢上下文。监听丢失事件后,所有 Buffer、Texture、Program 句柄作废。恢复时按 CPU 源重建。若你加载模型后把 JS 数组丢掉,只剩 GPU 句柄,恢复只能重新网络请求。长应用要决定:源数据常驻内存以便秒恢复,还是接受恢复时重新加载。这是内存与体验的对照,不是 API 细节。引擎通常有自己的恢复,裸写必须登记一份“重建清单”:哪些仓、哪些纹理、哪些程序。清单就是 CPU 作为真相源的证明。
Worker 里不能直接碰属于页面的 WebGL 上下文。OffscreenCanvas 可以把上下文搬到 Worker,但那是另一份上下文,对象不能共享。粒子模拟在 Worker 算位置,仍要回传类型化数组到拥有上下文的线程再上传。回传本身是 CPU 拷贝。对照:模拟与上传是否值得分线程,要看拷贝是否比主线程计算更便宜。很多时候主线程算 1 万粒子比来回传更快。分线程不是免费的端。
环状多缓冲的实现要点是:知道 GPU 可能还在读上一帧的仓。WebGL 没有通用的 fence 那么好用,实践中用三倍缓冲加“本帧只写 index%3 的仓”来降低冲突概率。若仍出现闪烁,可能是 SubData 与绘制重叠。退回整块 bufferData 有时反而稳,虽然 theoretically 更贵。稳与快的对照要以时间线为准,不要以文档里的提示枚举为准。提示枚举是暗示,实现可以忽略。
某些实现对类型与偏移对齐敏感。半浮点、byte 法线 packed 时,偏移要按规范对齐。CPU 填写 packed 数据要用 DataView 或手工位移,填错则 GPU 解释成噪声法线。为了省带宽去 packed,要同时投资调试可视化。省了带宽却加了两天调试,不一定赚。先用 float 交错跑通,再在性能节按需 packed。这是 2.3 与 4.7 的交接:过早 packed 是优化错序。
实例化缓冲是另一份 ARRAY_BUFFER,divisor 为 1。它与网格 STATIC 仓分开,更新策略独立。网格永不写,实例仓每帧写。两仓绑在同一 VAO。VAO 记录两份解释。漏设 divisor,实例数据会被当成每顶点,一万实例变成网格顶点爆炸或越界。divisor 是通道粒度的硬件开关,设错没有好错误信息。画一个实例立方体验证 divisor,再扩到一万。
能。提示不是锁。改只是可能更慢或引发重新分配。偶尔改一次材质颜色顶点,直接 SubData 即可。若发现改的频率变成每帧,应换提示并考虑拆仓。不要因为曾经标了 STATIC 就每帧新建 Buffer 对象。新建对象比原地改更贵,也更容易漏删。
GPU 对象没删。几何与纹理句柄活在上下文里,JS 侧数组已经丢掉,GC 救不了显存。单页应用在路由离开时走 dispose:deleteBuffer、deleteTexture、deleteProgram。引擎要调用官方 dispose 并确认没有匿名闭包抓住几何。内存曲线向上而帧时间后段才坏,是泄漏典型像,不像着色器突然变重。
一万以下往往不值,拷贝与同步吃掉收益。十万以上或还要做空间哈希,可以值。传回的应是类型化数组,不要结构化克隆普通对象。主线程只负责上传与 draw。上下文仍不能在 Worker 与页面之间共享对象。OffscreenCanvas 是另一条路,等于把整条管线搬走,而不是只搬粒子。
长应用给 Buffer 做池:按尺寸分桶,避免频繁 create/delete。池统计打到调试面板:当前仓数、字节、本帧 SubData 次数。次数突然涨,说明有人把静态网当动态写。统计比感觉早发现。删除时从池取出再 delete,不要只把 JS 引用置空。上下文恢复时池全部作废,按清单重建,不要试图复用旧句柄。句柄数字可能被重用到别的对象,复用会静默画错网格。这比泄漏更阴险。开发版给每个仓起名字,draw 前打印当前 VAO 绑定名。名字是卫生,不是幼稚。
上下文丢失演练应写进开发手册:在浏览器里强制丢 GPU 进程,看场景是否能重建。重建失败的仓就是清单漏项。漏项在用户手机上表现为白屏,日志还在你这台没丢过的机器上。演练比文档有效。动态几何的环状三缓冲在粒子上很值,在角色网格上通常不值,因为角色该走蒙皮而不是改仓。选错策略会让 2.3 与 4.4 互相打架:动画系统写仓,缓冲系统按 STATIC 提示,驱动把仓库放在不适合每帧写的堆。两边都按自己的正确性工作,合起来却慢。联调时把写入次数打到屏幕,次数跟角色顶点数成正比,就是走错路了。改回骨矩阵 Uniform,次数应掉到角色数这一级。这才是对照,而不是把提示枚举从 STATIC 改成 STREAM 当修好。
下一节对照着色器:编译链接发生在 CPU 与驱动,真正的逐顶点逐片元奔跑发生在 GPU。