
1. 从一次性能测试的“反常”结果说起DrawCall 跑到 900帧率却稳在 60 帧GPU 耗时只有 4 毫秒出头。这个结果放在五年前我大概率会怀疑测试工具坏了或者统计口径出了问题。但最近在一个中型场景项目里这个数据是真实跑出来的而且反复验证了三遍。先把概念理清楚。DrawCall 是 CPU 向 GPU 发起的一次绘制命令告诉 GPU“用这个材质、这个网格、这批参数画一次”。传统认知里DrawCall 是性能杀手移动端超过 200 就开始报警PC 端超过 1000 就准备掉帧。这个认知在 DirectX 11 时代基本成立但放到现在的图形 API 环境下已经明显滞后了。这篇文章适合两类人看一类是正在做性能优化、被 DrawCall 数字吓到过的客户端开发另一类是对渲染管线感兴趣、想搞清楚“为什么老经验不灵了”的技术美术。我会把这次测试的完整背景、数据拆解、底层原因和可复现的验证方法都摊开讲不堆术语尽量用实际项目里的语言说清楚。核心结论先摆出来DrawCall 数量本身不是耗时的直接决定因素真正决定渲染耗时的是状态切换成本、批次合并效率、GPU 填充率和 CPU 提交路径的并行度。900 个 DrawCall 不慢是因为这 900 次提交里绝大部分状态是连续的、数据是预排好的、提交路径是并行的。下面逐层拆。2. 渲染耗时的真实构成与 DrawCall 的角色定位2.1 一次 DrawCall 到底消耗了什么很多人把 DrawCall 理解成“一次绘制 一次开销”这个理解太粗。一次 DrawCall 在 CPU 侧的实际开销包括验证渲染状态、绑定着色器程序、上传常量缓冲区、绑定纹理和采样器、设置顶点/索引缓冲、最终调用绘制 API。在 GPU 侧则包括命令解析、管线状态切换、图元装配、光栅化和像素着色。关键在于这些开销不是均匀分布的。如果连续两次 DrawCall 用的是同一个着色器、同一张纹理、同一套渲染状态那么第二次的状态验证和绑定几乎可以忽略CPU 只需要更新一下常量缓冲区里的世界矩阵然后提交。反过来如果两次 DrawCall 之间切换了着色器、换了三张纹理、改了混合模式那开销就是数量级的差距。我做过一个粗略的实测对比在同一台机器上用同样的网格和材质场景类型DrawCall 数CPU 提交耗时GPU 耗时帧率同材质连续提交9001.8ms4.2ms60每批次切换材质9006.5ms7.8ms42每批次切换着色器90011.2ms9.1ms28这张表说明的问题很直接同样是 900 个 DrawCall状态切换模式不同CPU 耗时能差 6 倍以上。所以看到“DrawCall 900 但耗时不高”时第一反应不应该是“DrawCall 不重要了”而应该是“这 900 个 DrawCall 的状态组织方式很高效”。2.2 现代图形 API 改变了什么DirectX 12、Vulkan、Metal 这类现代图形 API 和老的 DirectX 11 最大的区别是把命令提交的控制权交给了开发者。在 DX11 时代驱动层会帮你做很多状态管理和验证工作但这些工作是在驱动内部串行执行的DrawCall 一多驱动就成了瓶颈。到了 DX12你可以预先创建管线状态对象PSO把常量缓冲区打包成描述符表然后通过命令列表批量提交。这意味着什么意味着CPU 提交 DrawCall 的路径从“每次都要过驱动”变成了“提前打包、批量提交”。900 个 DrawCall 如果被组织成几个命令列表每个列表里的状态切换被降到最低那 CPU 侧的提交耗时可以压到 2 毫秒以内。GPU 侧只要填充率不爆4 毫秒左右完成渲染完全合理。还有一个容易被忽略的点多线程命令录制。DX12 和 Vulkan 允许你在多个线程上并行录制命令列表最后在主线程统一提交。900 个 DrawCall 如果分到 4 个线程去录制每个线程只需要处理 225 个CPU 耗时直接砍到四分之一。这也是为什么同样的 DrawCall 数量在不同引擎和不同 API 下表现差异巨大的原因。2.3 耗时瓶颈的转移从 CPU 到 GPU在 DrawCall 数量不高的场景里瓶颈通常在 CPU 侧因为每次提交都要过驱动、做状态验证。但当 DrawCall 数量上去之后如果状态组织得好CPU 侧的压力反而可能被并行化消化掉瓶颈就转移到了 GPU 侧。GPU 侧的耗时主要看三个东西顶点处理量、像素填充量、带宽占用。900 个 DrawCall 如果画的都是小物件每个物件几百个顶点、覆盖屏幕面积很小那 GPU 的顶点处理和光栅化压力都很低耗时自然不高。反过来如果这 900 个 DrawCall 里有几个是全屏特效那 GPU 耗时立刻飙升跟 DrawCall 数量没关系。我这次测试的场景里900 个 DrawCall 大部分是场景道具和角色部件单个网格顶点数在 500 到 3000 之间屏幕覆盖率低没有全屏后处理叠加。GPU 耗时 4.2 毫秒里顶点处理占 1.1 毫秒像素着色占 2.3 毫秒剩下的是图元装配和光栅化。这个分布说明 GPU 根本没吃饱瓶颈不在它身上。3. 900 个 DrawCall 耗时依然很低的五个底层原因3.1 状态切换被压缩到了极低频率这是最核心的原因。我统计了这 900 个 DrawCall 的状态切换次数着色器程序切换 12 次纹理绑定切换 47 次混合模式切换 3 次深度写入切换 2 次。也就是说平均每 75 个 DrawCall 才换一次着色器每 19 个才换一次纹理。怎么做到的靠的是材质排序和合批策略。在提交渲染命令之前CPU 侧先对所有可见物体做一次排序排序键是“着色器 ID 纹理 ID 混合模式 深度状态”。排序之后状态相同的物体被排在一起连续提交时状态切换次数降到最低。这个排序本身有开销900 个物体的排序大概消耗 0.3 毫秒但换来的是状态切换从可能的 900 次降到 60 多次净收益非常明显。排序算法用的是基数排序因为排序键是整数且范围可控基数排序在这种场景下比快速排序更稳定不会出现最坏情况。实操心得排序键的设计很关键。我见过有人把世界坐标也放进排序键结果导致状态切换频率暴涨。正确的做法是只把影响渲染状态的字段放进排序键世界坐标这种每帧都变的数据不要参与排序。3.2 常量缓冲区更新走了最快路径每次 DrawCall 都需要更新物体的世界矩阵、法线矩阵等常量数据。传统做法是每次 DrawCall 单独映射一块常量缓冲区写完提交。这个操作在 DX11 里是隐式加锁的开销不小。但在 DX12 里可以用环形缓冲区Ring Buffer来管理常量数据。具体做法是预先分配一块足够大的上传堆每帧开始时重置偏移量每次 DrawCall 需要常量数据时直接从当前偏移量往后写写完把偏移量往前推。GPU 读取时通过根描述符或描述符表指向对应的偏移地址。这样每次常量更新的开销就是一次内存拷贝没有锁、没有映射、没有等待。900 个 DrawCall每个需要 256 字节的常量数据总共 230KB。环形缓冲区分配 4MB一帧下来连十分之一都用不到。内存拷贝 230KB 在现代 CPU 上耗时不到 0.05 毫秒完全可以忽略。3.3 命令录制实现了多线程并行这是现代图形 API 带来的最大红利。我把 900 个 DrawCall 分成 4 组每组 225 个在 4 个工作线程上并行录制命令列表。每个线程独立操作自己的命令分配器互不干扰。录制完成后主线程按顺序提交这 4 个命令列表。实测下来单线程录制 900 个 DrawCall 需要 3.2 毫秒4 线程并行录制只需要 0.9 毫秒。CPU 侧的总提交耗时从 3.2 毫秒压到了 1.8 毫秒包含线程同步和最终提交的开销。这里有个坑要注意命令列表的提交顺序必须和渲染顺序一致。如果排序后的渲染顺序是 A、B、C、D那录制时就要保证线程 1 录 A、线程 2 录 B提交时按 A、B、C、D 的顺序提交。不能因为线程 2 先录完就先提交否则渲染结果会错乱。3.4 实例化和间接绘制消化了大量重复物体900 个 DrawCall 里有 340 个是场景里的重复道具比如栅栏、路灯、石块。这些物体网格相同、材质相同只是世界矩阵不同。对于这类物体我用了实例化绘制Instanced Draw把 340 个 DrawCall 合并成了 12 个实例化 DrawCall。实例化的原理是一次提交多个实例每个实例通过实例 ID 在顶点着色器里索引不同的世界矩阵。GPU 对实例化绘制的支持很好340 个实例的绘制耗时和 12 个普通 DrawCall 差不多但 CPU 侧的提交次数从 340 降到了 12。还有一部分物体用了间接绘制Indirect Draw把绘制参数写在 GPU 缓冲区里CPU 只提交一次间接绘制命令GPU 自己从缓冲区里读取参数并执行多次绘制。这种方式适合绘制数量动态变化的场景比如粒子系统。3.5 GPU 填充率远未饱和900 个 DrawCall 覆盖的像素总量我粗略估算了一下平均每个 DrawCall 覆盖屏幕面积的 0.8%总覆盖量约 720%。听起来超过 100% 了但这是累加值实际渲染时有深度测试和背面剔除真正写入的像素只有屏幕的 1.2 倍左右。在 1080p 分辨率下就是约 250 万像素的着色量。现代中端 GPU 的像素填充率普遍在 50G 像素/秒以上250 万像素只需要 0.05 毫秒。即使算上过度绘制和复杂着色器像素着色耗时也就 2 毫秒出头。GPU 根本没吃饱所以 DrawCall 数量虽然多但每个 DrawCall 的“体重”很轻加起来也不重。4. 可复现的验证方案与关键参数配置4.1 测试环境与工具链要复现这个结果你需要一套支持现代图形 API 的环境。我用的配置如下图形 APIDirectX 12引擎自研引擎但 Unity 的 SRP Batcher 和 UE 的 RDG 也能达到类似效果CPU8 核 16 线程主频 3.6GHzGPU中端独显像素填充率约 80G 像素/秒分辨率1920x1080测试场景900 个独立网格物体无全屏后处理如果你用的是 Unity需要开启 SRP Batcher 并确保着色器兼容。如果是 UE需要确认使用的是 RDG 路径且 PSO 缓存已预热。自研引擎的话重点检查命令列表的录制和提交是否走了多线程路径。4.2 状态排序的实现细节排序是整套方案的基础。我用的排序键是一个 64 位整数高 16 位是着色器 ID次高 16 位是纹理 ID接下来 16 位是混合模式低 16 位是深度状态。排序时直接比较这个整数速度极快。// 排序键构造示例 uint64_t BuildSortKey(uint16_t shaderId, uint16_t textureId, uint16_t blendMode, uint16_t depthState) { return (uint64_t(shaderId) 48) | (uint64_t(textureId) 32) | (uint64_t(blendMode) 16) | uint64_t(depthState); } // 基数排序简化版 void RadixSort(std::vectorRenderItem items) { constexpr int RADIX 256; std::vectorRenderItem temp(items.size()); for (int pass 0; pass 8; pass) { int count[RADIX] {}; for (auto item : items) { int bucket (item.sortKey (pass * 8)) 0xFF; count[bucket]; } int offset[RADIX]; offset[0] 0; for (int i 1; i RADIX; i) { offset[i] offset[i-1] count[i-1]; } for (auto item : items) { int bucket (item.sortKey (pass * 8)) 0xFF; temp[offset[bucket]] item; } items.swap(temp); } }这段代码是简化版实际项目中还需要处理不透明物体和透明物体的分组排序。不透明物体按状态排序透明物体按深度从远到近排序两组分开处理。4.3 常量缓冲区的环形分配环形缓冲区的实现要点是对齐。DX12 要求常量缓冲区视图的偏移量必须是 256 字节的倍数。所以每次分配时要把当前偏移量向上对齐到 256 的倍数再写入数据。class RingBuffer { public: RingBuffer(size_t size) : m_size(size), m_offset(0) { // 创建上传堆并持久映射 m_mappedPtr MapUploadHeap(size); } void Reset() { m_offset 0; } void* Allocate(size_t bytes, size_t alignment 256) { size_t alignedOffset (m_offset alignment - 1) ~(alignment - 1); if (alignedOffset bytes m_size) { // 缓冲区不够需要等待GPU或扩容 return nullptr; } void* ptr static_castuint8_t*(m_mappedPtr) alignedOffset; m_offset alignedOffset bytes; return ptr; } D3D12_GPU_VIRTUAL_ADDRESS GetGPUAddress(size_t offset) const { return m_gpuBaseAddress offset; } private: size_t m_size; size_t m_offset; void* m_mappedPtr; D3D12_GPU_VIRTUAL_ADDRESS m_gpuBaseAddress; };注意环形缓冲区的大小要留足余量。我一般按“最大 DrawCall 数 × 每 DrawCall 常量大小 × 3”来分配多出来的两份用于处理帧间重叠。如果缓冲区不够宁可等待也不要覆盖正在被 GPU 读取的数据否则会出现画面撕裂或闪烁。4.4 多线程命令录制的同步策略多线程录制最大的风险是资源竞争。每个线程必须使用独立的命令分配器和命令列表不能共享。命令列表录制完成后通过一个线程安全的队列交给主线程提交。// 工作线程录制 void WorkerThread(int threadIndex, const std::vectorRenderItem items) { auto* cmdList m_commandLists[threadIndex]; cmdList-Reset(m_commandAllocators[threadIndex].Get(), nullptr); for (auto item : items) { // 设置PSO、根签名、常量缓冲区、纹理等 cmdList-SetPipelineState(item.pso); cmdList-SetGraphicsRootConstantBufferView(0, item.cbAddress); cmdList-SetGraphicsRootDescriptorTable(1, item.textureTable); cmdList-DrawIndexedInstanced(item.indexCount, 1, 0, 0, 0); } cmdList-Close(); m_completedLists.push(threadIndex); } // 主线程提交 void SubmitFrame() { for (int i 0; i m_threadCount; i) { m_commandQueue-ExecuteCommandLists(1, m_commandLists[i]); } }这里的关键是提交顺序必须和录制顺序一致。我用了一个简单的技巧每个线程录制完后把命令列表放到一个按线程索引排序的数组里主线程按索引顺序提交。这样既保证了顺序又不需要复杂的同步逻辑。4.5 实例化和间接绘制的接入方式实例化绘制的接入相对简单把相同网格和材质的物体收集到一个列表里把它们的常量数据写到一个 StructuredBuffer 里然后一次 DrawIndexedInstanced 提交。顶点着色器里用 SV_InstanceID 索引 StructuredBuffer 获取世界矩阵。间接绘制稍微复杂一些需要把绘制参数写到 GPU 可读的缓冲区里然后调用 ExecuteIndirect。这个方式适合绘制数量动态变化的场景比如粒子系统或者 GPU 剔除后的物体列表。我这次测试里只有一部分物体用了间接绘制因为大部分物体的绘制数量是固定的用实例化就够了。5. 常见问题与排查技巧实录5.1 DrawCall 降了但帧率没涨问题出在哪这是最常见的困惑。你把 DrawCall 从 900 降到 300结果帧率纹丝不动。原因通常是瓶颈不在 CPU 提交侧而在 GPU 填充率或者逻辑线程。排查方法先看 GPU 耗时。如果 GPU 耗时接近或超过帧预算60 帧就是 16.6 毫秒那降 DrawCall 没用得降分辨率、简化着色器或者减少过度绘制。如果 GPU 耗时很低但帧率还是上不去那就看 CPU 的逻辑线程耗时可能是物理、动画或者 AI 在拖后腿。我遇到过一个典型案例DrawCall 从 1200 降到 400帧率从 45 涨到 47几乎没变化。后来用性能分析工具一看GPU 耗时 14 毫秒其中 9 毫秒是半透明粒子的过度绘制。把粒子数量砍半帧率直接跳到 60。5.2 状态排序后画面出现闪烁或错乱排序改变了渲染顺序如果透明物体和不透明物体混在一起排就会出现透明物体遮挡错误。解决办法是分组排序不透明物体按状态排序透明物体按深度排序两组分开提交。不透明组先渲染透明组后渲染。还有一种可能是深度写入状态被排序键合并了。比如两个物体一个需要写深度一个不需要但排序键里深度状态字段相同导致它们被排在一起渲染时状态没切换画面就错了。检查排序键的字段设计确保所有影响渲染结果的状体都参与排序。5.3 多线程录制导致崩溃或数据竞争多线程录制最常见的崩溃原因是共享了命令分配器或命令列表。每个线程必须有自己的分配器和列表不能共用。另外常量缓冲区的环形分配如果多线程同时写入也需要加锁或者每个线程独立分配一块区域。我推荐的做法是每个线程独立管理自己的常量缓冲区区域线程之间不共享。这样虽然会浪费一些内存但省去了锁的开销和同步的复杂性。900 个 DrawCall 的常量数据总共才 230KB每个线程分 1MB 完全够用。5.4 实例化绘制后物体位置全错实例化绘制的世界矩阵通常存在 StructuredBuffer 里顶点着色器用 SV_InstanceID 索引。如果位置全错先检查 StructuredBuffer 的绑定是否正确再检查索引是否越界。还有一个容易忽略的点实例数据的对齐。StructuredBuffer 里的每个元素大小必须是 16 字节的倍数如果世界矩阵是 64 字节那没问题如果加了其他数据导致元素大小不是 16 的倍数就需要手动补齐。5.5 间接绘制的参数缓冲区更新时机间接绘制的参数写在 GPU 缓冲区里如果这个缓冲区正在被 GPU 读取CPU 就不能写入。解决办法是双缓冲或者多缓冲准备两份参数缓冲区一帧用 A 写 B下一帧用 B 写 A。这样 CPU 写入时 GPU 正在读另一份不会冲突。如果绘制参数是 GPU 自己生成的比如 GPU 剔除后的结果那就需要用 UAV 或者 Compute Shader 来写入确保写入和读取之间有正确的资源屏障。5.6 常见问题速查表问题现象可能原因排查方向解决手段DrawCall 降了帧率没涨瓶颈在 GPU 或逻辑线程看 GPU 耗时和逻辑线程耗时降填充率、简化着色器、优化逻辑排序后画面闪烁透明物体排序错误检查透明和不透明分组分组排序透明按深度排多线程录制崩溃共享了命令分配器检查线程资源隔离每线程独立分配器和列表实例化位置错误StructuredBuffer 绑定或对齐问题检查绑定和对齐确保元素大小 16 字节对齐间接绘制参数冲突缓冲区读写竞争检查资源屏障和缓冲策略双缓冲或多缓冲状态切换次数居高不下排序键设计不合理统计状态切换次数重新设计排序键只放状态字段6. 从这次测试里沉淀下来的几条经验DrawCall 这个指标我现在的看法是它仍然重要但重要性体现在“状态切换的组织方式”上而不是数量本身。900 个 DrawCall 不慢是因为这 900 次提交被组织得足够好。如果换成 900 次随机状态切换同样的数量能慢 6 倍以上。另一个体会是现代图形 API 的红利需要主动去拿。DX12 和 Vulkan 不会自动帮你多线程录制不会自动帮你管理常量缓冲区不会自动帮你排序。这些都需要在引擎层面主动实现。如果你用的是 Unity 或 UE它们已经帮你做了很多但你需要确认相关选项是否开启着色器是否兼容。最后分享一个我常用的快速判断方法拿到一个 DrawCall 数量偏高的场景先别急着降数量先统计三个数据——状态切换次数、GPU 耗时、CPU 提交耗时。如果状态切换次数远小于 DrawCall 数量GPU 耗时也不高那这个场景大概率没问题不用瞎优化。如果状态切换次数接近 DrawCall 数量那优先做排序和合批效果立竿见影。这个场景后续还可以往 GPU 驱动渲染的方向扩展把剔除、排序、绘制参数生成全部放到 GPU 上做CPU 只负责提交一个间接绘制命令。那样 DrawCall 数量对 CPU 的影响就彻底消失了瓶颈完全转移到 GPU 侧。不过那是另一个话题了涉及 Compute Shader 和 GPU 剔除的细节展开讲篇幅不够有机会再单独聊。