第一章:减少API调用次数的策略——批处理技术详解
各位同学,今天我们来聊聊GPU驱动优化里最核心的一个话题——批处理(Batching)。我做了这么多年驱动优化,可以负责任地说:减少API调用次数,是性能提升最直接、最有效的手段。没有之一。
你想想看,每次调用一个API,CPU都要做一堆工作:参数校验、状态切换、命令打包、提交到GPU……这些开销累积起来,非常可观。我见过不少项目,明明GPU算力很强,但帧率就是上不去。一查,发现CPU端成了瓶颈——Draw Call太多,驱动忙不过来了。
1.1 为什么API调用次数这么重要?
先看一组数据。我随便列个表,你感受一下:
| 操作类型 | 相对开销(CPU时间) | 说明 |
|---|---|---|
| 简单的数学计算 | 1x | 几乎可以忽略 |
| 一次Draw Call | 100x ~ 1000x | 取决于驱动和硬件 |
| 一次状态切换(如绑定Shader) | 500x ~ 2000x | 非常昂贵 |
| 一次资源绑定(如更新Buffer) | 200x ~ 500x | 涉及内存同步 |
看到了吧?一次Draw Call的开销,是普通计算的几百倍。如果你的场景里有几千个物体,每个物体都单独调用一次Draw,那CPU基本就废了。
我个人习惯是:先把Draw Call数量压到1000以下,再考虑其他优化。这是底线。
1.2 批处理的核心思想
批处理,说白了就是把多个小任务合并成一个大任务,一次性提交给GPU。这样,原本需要N次API调用,现在只需要1次。
举个例子。你要画1000个三角形,每个三角形颜色不同。最笨的办法:
for (int i = 0; i < 1000; i++) {
SetVertexBuffer(triangle[i].vb);
SetPixelShader(colorShader[i]);
Draw(3, 0); // 画一个三角形
}
这样就是1000次Draw Call。CPU累死,GPU闲死。
批处理的做法:
// 把所有三角形的顶点数据合并到一个Buffer里
// 把所有颜色数据合并到另一个Buffer里
SetVertexBuffer(bigVertexBuffer);
SetPixelShader(instanceColorShader);
DrawInstanced(3, 1000, 0, 0); // 一次调用,画1000个
一次Draw Call搞定。性能差距,天壤之别。
核心原则:能用一次API调用解决的问题,绝不用两次。
1.3 间接绘制(Indirect Draw)——真正的杀手锏
上面那个例子,还是CPU在控制绘制逻辑。但有些场景,比如粒子系统、大量动态物体,CPU根本来不及计算每个物体的绘制参数。这时候,间接绘制(Indirect Draw)就派上用场了。
间接绘制的思路是:把绘制命令的参数(顶点数、实例数、起始偏移等)放到GPU能访问的Buffer里,然后CPU只发一次API,告诉GPU「去那个Buffer里读命令,自己执行」。
代码长这样:
// 在GPU Buffer中构建绘制命令
struct DrawCommand {
uint vertexCount;
uint instanceCount;
uint firstVertex;
uint firstInstance;
};
// CPU端只需要调用一次
glDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT, indirectBuffer, 0);
我在项目中遇到过这样一个场景:一个大型开放世界,有上万个草叶、石块、小物件。如果用传统方式,每帧要发几万次Draw Call,CPU直接爆炸。后来我改用间接绘制,把绘制命令的计算全部交给Compute Shader,CPU每帧只发一次Indirect Draw。帧率从20帧直接飙到60帧。
嗯,这里要注意:间接绘制不是银弹。它适合那些绘制参数可以动态计算、且数量巨大的场景。如果你的物体数量很少,用间接绘制反而可能增加复杂度。
我的建议:当Draw Call数量超过5000时,优先考虑间接绘制。低于这个数,先试试实例化(Instancing)。
1.4 合并资源绑定与状态设置
除了减少Draw Call,资源绑定和状态设置的合并同样重要。你想想看,每次切换Shader、绑定纹理、设置渲染状态,都是一次API调用。这些调用虽然单个开销不如Draw Call大,但积少成多,也很可观。
常见的优化手段:
- 使用Descriptor Table(描述符表):把多个资源(纹理、Buffer、Sampler)打包成一个表,一次绑定。D3D12和Vulkan都支持这个特性。
- 状态分组:把相同渲染状态的物体放在一起绘制。比如所有不透明物体一起画,所有透明物体一起画。避免频繁切换Blend、Depth Test等状态。
- Pipeline State Object(PSO)缓存:把常用的渲染管线状态组合缓存起来,避免重复创建和切换。
我曾经在一个项目中,发现每帧有200多次Shader切换。每次切换都要重新编译或查找缓存,CPU时间全耗在这上面了。后来我把所有Shader按功能分组,用PSO缓存管理,切换次数降到了20次以下。效果立竿见影。
避坑指南:我曾经犯过一个错误——为了减少API调用,把所有资源绑定到一个巨大的Descriptor Table里。结果GPU访问时,因为表太大,缓存命中率下降,性能反而变差了。所以,合并要适度,不是越合并越好。
1.5 实战中的批处理策略
说了这么多,总结一下我常用的批处理策略:
- 先分析,再优化:用Profiler工具(如RenderDoc、NVIDIA Nsight)看看Draw Call数量和API调用分布。找到真正的瓶颈。
- 实例化优先:对于相同Mesh、不同变换的物体,用Instancing。这是最简单的批处理方式。
- 间接绘制兜底:当实例化不够用,或者绘制参数需要GPU动态计算时,上Indirect Draw。
- 状态合并:把渲染状态相同的物体分组,减少状态切换。用PSO缓存管理管线状态。
- 资源绑定合并:用Descriptor Table或类似机制,一次绑定多个资源。但注意控制表的大小。
最后说一句:批处理不是万能的。有些场景,比如UI渲染,每个控件状态都不一样,强行批处理反而会让代码变得复杂难维护。这时候,适当增加一些API调用,换取代码的可读性和可维护性,也是值得的。
好了,这一章就到这里。下一章我们聊聊实例化(Instancing)的具体实现和注意事项。到时候我会带一个实际项目的代码,手把手教你怎么做。