需要预先学习:


1. DrawCall 是什么

Draw Call(绘制调用)是 CPU 向 GPU 提交的一次绘制命令——在 OpenGL 里就是 glDrawElements、glDrawArrays 这类调用。一次 draw call 绘制一个网格(或一批相同状态的物体),RenderPass与RenderGraph 里一个 Pass 内的每次 draw_batch 提交,对应一次 draw call。

一帧画面通常由成百上千次 draw call 组成:场景里每个物体一次、阴影贴图渲染一次、后处理若干次。现代游戏场景中物体数量动辄上万,draw call 数量是渲染性能的关键指标之一。


1.1 一次 draw call 的旅程

调用 glDrawElements 之后发生了什么:

  1. CPU 把渲染状态(绑定的 shader、纹理、缓冲、绘制参数)打包进一条命令;
  2. 命令写入命令缓冲区(command buffer),GPU 异步读取执行;
  3. GPU 按命令切换状态、发起顶点处理,执行绘制。

关键在于第 2 步的异步:CPU 提交命令后不等待 GPU 执行完,继续准备下一条。CPU 与 GPU 像流水线上的两道工序,各自向前推进。


2. 开销从哪来


2.1 CPU 侧的开销(主要)

一次 draw call 的 GPU 执行时间很短(可能只有几微秒),真正贵的是 CPU 侧的固定开销:

开销 说明
状态验证 驱动检查参数合法性、当前状态是否一致
命令编码 把状态与参数打包成 GPU 可读的命令
Uniform 上传 每次绘制前设置 uniform 需要额外的通信
状态切换 切换 shader、纹理、缓冲时,驱动可能重新配置内部管线,甚至插入同步

这些开销与绘制的物体大小无关:画 100 个三角形和画 100 万个三角形的固定开销几乎一样。所以"大量小物体各画一次"是性能杀手——固定开销被摊到了极少的三角形上。


2.2 CPU bound 与 GPU bound

由于 CPU 与 GPU 异步并行,一帧的总耗时由较慢的一方决定:

  • CPU bound:CPU 提交命令的速度跟不上 GPU 执行速度,GPU 大量时间空闲等待。draw call 过多是典型原因;
  • GPU bound:GPU 执行太慢(像素填充率、顶点处理、着色器复杂),CPU 提前提交完,等待 GPU。

注:优化前先判断瓶颈在哪一侧,方向错了事倍功半。降低分辨率看帧率是否提升——提升说明 GPU bound;减少场景物体数看帧率是否提升——提升说明 CPU bound(draw call 是主因)。


2.3 同步是最贵的操作

CPU 与 GPU 异步工作,但如果 CPU 需要读取 GPU 的结果(glReadPixels、occlusion query、glFinish),就必须等待 GPU 追上来——管线被强制排空(pipeline flush),之前积累的所有并行优势瞬间清零。渲染循环里应避免任何形式的回读。


3. 优化手段


3.1 合批(Batching)

合批把多个物体合并成一次 draw call。前提是这些物体状态相同:同一个 shader、同一张纹理、同一套渲染状态,只是模型矩阵不同。

  • 静态合批(static batching):把静态物体离线合并进一个大网格,运行时一次绘制。代价是内存(所有物体顶点合并存储)与无法单独剔除;
  • 动态合批(dynamic batching):运行时把符合条件的物体临时合并。灵活但 CPU 开销高(每帧重新组装顶点),只适合少量小物体。

合批的本质是把 CPU 的固定开销摊到更多几何体上。


3.2 实例化(Instancing)

实例化(instancing)专门解决"同一个网格画很多份":glDrawElementsInstanced 一次调用绘制 N 份,每份通过 per-instance 属性(模型矩阵、颜色等)区分:

// 一次调用绘制 1000 个实例,instanceMatrices 是 per-instance 属性
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(Mat4) * 1000, instanceMatrices, GL_STATIC_DRAW);

// 模型矩阵作为 per-instance 顶点属性(4 个 vec4 槽位)
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, sizeof(Mat4), (void*)0);
// ... 槽位 4、5、6 同理 ...

// 关键:divisor 设为 1,属性每实例推进一次(否则仍是逐顶点读取)
glVertexAttribDivisor(3, 1);
glVertexAttribDivisor(4, 1);
glVertexAttribDivisor(5, 1);
glVertexAttribDivisor(6, 1);

glDrawElementsInstanced(GL_TRIANGLES, mesh.indexCount, GL_UNSIGNED_INT, 0, 1000);

顶点着色器里用 gl_InstanceID 或 per-instance 属性取自己的模型矩阵。实例化省掉了 N-1 次 draw call 的固定开销,适合草、树木、粒子、弹壳这类大量重复网格——比动态合批省 CPU,GPU 还能利用批次内的顶点缓存局部性。


3.3 减少状态切换

即使不能合批,提交顺序也影响性能:频繁切换 shader、纹理、缓冲会触发驱动内部的重新配置。两个常用手段:

  • 排序提交:场景物体按 shader → 纹理 → 网格 排序,相同状态的物体连续绘制,把切换次数降到最低;
  • 纹理图集(texture atlas):把多张小纹理打包进一张大图,不同物体共用同一张纹理,只需切换 UV 区域,省去纹理绑定切换。

3.4 减少无效绘制

比优化 draw call 更快的是根本别提交:

手段 做法 收益
视锥剔除 剔除不在摄像机视锥内的物体 大量场景物体直接跳过
遮挡剔除 被其他物体完全挡住的物体不绘制(GPU occlusion query / 软件遮挡) 室内场景收益巨大
背面剔除 不绘制背对摄像机的三角形(硬件状态) 减半三角形数
LOD 远处用低精度模型 减少顶点与像素压力

剔除在 CPU 上做(每帧遍历场景计算包围盒),把"提交了但看不见"的 draw call 直接消灭。


3.5 提交层面的优化

  • 命令复用:把固定内容的命令(如后处理绘制)缓存,避免每帧重新编码;
  • 渲染线程:主线程准备场景数据,渲染线程独立提交命令,避免物理、逻辑、动画与渲染互相阻塞;
  • GPU driven rendering:场景数据放 GPU 端,CPU 只发一条命令,GPU 自己用 compute shader 做剔除与间接绘制(glMultiDrawElementsIndirect)——draw call 开销被压缩到极限,这是现代引擎(如 UE5 Nanite)的方向。

4. 优化手段选择

场景 推荐手段
大量重复网格(草、树、弹壳) 实例化
静态场景大量小物体 静态合批 + 视锥剔除
动态小物体 动态合批(数量少时)
纹理频繁切换 纹理图集 + 排序提交
室内 / 密集场景 遮挡剔除
极端规模 GPU driven rendering

注:各手段不是互斥的,真实引擎是组合拳:剔除负责"少提交",合批与实例化负责"一次多画",排序负责"切换少"。