本节摘要:动态数据的每帧更新是第二个大开销源。本节解剖缓冲更新的三代方案——整体重传、缓冲流式更新(孤儿化与同步区轮转)、持久映射(CPU 直接写显存 + 显式围栏同步),讲清「CPU 写 GPU 读」的竞态本质与围栏的使用纪律,延伸到无绑定纹理与纹理缓冲对绑定开销的进一步削减。带宽优化的判断标准始终是「字节搬运次数」,不是 API 调用次数。
粒子位置每帧变、骨骼矩阵每帧换、相机矩阵每帧更新——动态数据必须每帧从内存进显存。最朴素的方案是每帧 glBufferData 整体重传:驱动每次都可能分配新存储、废弃旧的(缓冲的「孤儿化」效应),一次拷贝变成「分配加拷贝加回收」三笔账。几千字节无所谓,几兆字节的粒子缓冲每帧走一遍,带宽与分配抖动立刻上榜。优化的方向因此明确:让动态数据的更新路径稳定下来——固定一块存储、CPU 持续写入、GPU 按帧消费,中间不经过反复分配。
持久映射的核心难点不是 API 而是同步。回忆 1.1 节的异步模型:你提交的命令在 GPU 那里可能还没执行完。如果 CPU 此刻直接改写一块 GPU 正在读的缓冲——数据撕裂,甚至更糟:某些平台直接崩溃。传统方案把同步责任交给驱动(glBufferData 重新分配等于声明「旧的我不要了」),代价是每帧的分配开销;持久映射把责任接过来——你拿到一块 CPU 指针直接读写显存,代价是必须自己保证读写不撞车。
保证的机制叫围栏(fence):CPU 写完一块数据后插一道围栏,继续写下一块;写到快追上「GPU 可能还在读」的区域时,查询最旧的围栏——没到就等,到了就说明 GPU 已经消费完那块数据,可以安全复写。配上环形多区设计(缓冲切成 2 到 4 块轮流当写靶),CPU 与 GPU 像两条不同速的传送带错开运行。
// 持久映射的骨架:一次分配 · 永久映射 · 围栏轮转 glBindBuffer(GL_UNIFORM_BUFFER, ubo); // PERSISTENT 允许映射期间被GPU使用 · COHERENT 让写入对GPU即时可见 GLbitfield flags = GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT; glBufferStorage(GL_UNIFORM_BUFFER, FRAME_COUNT * regionSize, nullptr, flags); void* ptr = glMapBufferRange(GL_UNIFORM_BUFFER, 0, FRAME_COUNT * regionSize, flags); // 每帧写入第 frameIndex 块 char* region = (char*)ptr + frameIndex * regionSize; memcpy(region, &cameraData, sizeof(cameraData)); // CPU 直写显存 glDeleteSync(fences[frameIndex]); // 旧围栏先回收 fences[frameIndex] = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); // 下一帧前:确保将被复写的最旧区块的围栏已 signaled(GPU 用完了)
三条纪律。分区数按 CPU 领先程度定:CPU 每帧领先 GPU 不超过一帧(垂直同步的常态)时双区就够,CPU 预先攒好几帧命令的架构要三四区。围栏查询放在复写前而不是每帧固定位置,等到再写才是它的语义。coherent 位有微小成本但省心,不开的话每次写完要手动加内存屏障——初学阶段开着,profile 后再考虑省。
⚠️ 常见坑:把持久映射当成万能通道,静态几何也走映射写入。持久映射优化的是「高频小更新」,静态数据(一亿年不变的楼模型)用它纯属给同步机制白交管理费——静态数据仍然走一次性 glBufferStorage 上传(3.1 节的 STATIC_DRAW 语义在 4.4 时代的直系传承)。
持久映射之前有个成本低一档的方案值得先试:孤儿化加子范围更新。每帧 glBufferData 传 null「遗弃」旧存储(驱动多半会把同一块翻新复用),再 glBufferSubData 写入新数据。它把「分配」从每帧变成潜在复用,仍然每帧一次拷贝调用,但实现只有两行——数据量中等(几十到几百 KB)时,它与持久映射的差距常常小于工程复杂度的代价。决策线:数据量小用孤儿化,数据量大或更新频率高于帧率(多 pass 各更新一次)再上持久映射。
调用数与搬运之外,第三种开销藏在「绑定」本身。传统用法里着色器想读纹理,CPU 必须先把纹理绑到某个纹理单元——同一着色器要按材质切换几十张纹理时,绑定调用就是新的管理费。无绑定纹理(bindless,或 4.4 的采样器数组加纹理视图路线)把「纹理的句柄」当成数据存进缓冲,着色器按索引取句柄直接采样——绑定环节整体蒸发,材质数组、虚拟纹理这些「海量纹理」技术都建在它上面。
同类思路的还有把小数据塞进纹理(或纹理缓冲)替代 uniform 的老技巧:一个 256 帧的动画曲线、一张查找表,做成一维纹理采样比一堆 uniform 数组更省插槽。共同的思想:绑定是状态机时代的税,把资源引用变成数据流,税就免了——与 6.1 节「把决策变数据」是同一个哲学的两次显形。
字节搬运次数之外,每个字节的宽度也是账。顶点属性用紧凑格式(半精度、紧缩归一化整数——3.1 节埋过的线)能把顶点带宽砍半;纹理用块压缩(3.2 节)四分之一起步;索引用 16 位够就别用 32 位。局部性上,「同一帧内重复采样的纹理」与「同一批次访问的顶点」尽量在时间上聚拢,GPU 缓存的命中率直接反映在带宽计数器上(第 7 章的剖析工具能看到它)。这些是「不用改架构、只改格式」的低垂果实,优化中期回头收割。
提交与搬运的开销都压到地板后,还剩一类问题图形管线天生不适配——粒子物理、遮挡剔除、图像分析这类「不是画三角形」的计算。下一节解剖计算着色器:跳出管线、直接指挥 GPU 并行机的通用模式。