3、CPU优化基础:Draw Call是什么、为什么Draw Call是瓶颈、如何减少Draw Call
好,咱们今天聊一个老生常谈,但又绕不开的话题——Draw Call。我敢说,每个做游戏优化的,不管是新手还是老手,都跟它打过交道。说白了,它就是CPU和GPU之间沟通的“指令”。
3.1 Draw Call到底是什么?
想象一下,你是个包工头(CPU),手下有一帮工人(GPU)。你想让工人砌一堵墙。你不能直接说“去砌墙”,你得先下指令:“去,把砖搬过来”、“把水泥和好”、“开始砌”。每一条指令,就是一个Draw Call。
在图形API(比如DirectX、OpenGL、Vulkan)里,Draw Call就是CPU告诉GPU“嘿,把这块数据渲染出来”的命令。每次你调用类似 glDrawElements 或 DrawIndexedPrimitive 这样的函数,就是在发一个Draw Call。
核心定义: Draw Call是CPU向GPU发送的一次渲染命令,它告诉GPU使用哪些顶点数据、纹理、着色器来绘制一个物体或一组物体。
举个例子,一个简单的场景里有100个不同的茶壶。如果你每个茶壶单独发一次Draw Call,那就是100次。如果把它们合并成一个批次,可能只需要1次。嗯,这里要注意,合并是有条件的,我们后面会讲。
3.2 为什么Draw Call会成为瓶颈?
这个问题,我当年刚入行时也困惑过。CPU发指令不是很快吗?为什么会卡?
原因其实很简单:CPU和GPU是异步工作的,但CPU的准备工作是串行的。
每次发Draw Call之前,CPU要做一堆准备工作:
- 检查渲染状态(是否开启深度测试、混合模式等)
- 绑定顶点缓冲区、索引缓冲区
- 设置纹理和着色器参数
- 验证数据合法性
这些操作,每一个都需要CPU花时间。而且,它们必须按顺序来,不能并行。你想想看,如果一帧里有几千个Draw Call,CPU光忙活这些准备工作,就没时间处理游戏逻辑了。
我记得在优化一个开放世界项目时,场景里密密麻麻全是小物件。一帧的Draw Call数飙到了8000多。结果呢?CPU帧时间直接爆了,GPU反而在“摸鱼”,因为CPU根本来不及给它派活。这就是典型的CPU瓶颈。
避坑指南: 我曾经见过一个团队,把Draw Call数压到了200以下,但帧率还是上不去。后来发现,他们虽然合并了Draw Call,但每个合并后的网格有几十万个顶点。GPU直接累趴了。记住,Draw Call优化解决的是CPU瓶颈,不是GPU瓶颈。别搞混了。
为什么会这样?因为CPU处理一个Draw Call的开销,大约是几十到几百微秒。听起来不多,但一帧只有16.6毫秒(60帧)。算一下,300个Draw Call,每个50微秒,就是15毫秒。游戏逻辑、物理、动画还做不做了?
3.3 如何减少Draw Call?
好,问题找到了,怎么解决?我个人的习惯是,从三个层面入手:合批、剔除、优化资源。
3.3.1 静态合批(Static Batching)
这是最直接的方法。把场景中不动的物体,在编辑阶段就合并成一个大的网格。Unity和Unreal都支持。比如,一栋楼的窗户、墙壁、门,如果它们都不动,就合并成一个Draw Call。
但要注意,合批后的网格会变大,内存占用会增加。因为每个物体原本有自己的变换矩阵,合批后需要额外存储。我建议只对大量重复的小物体做静态合批,比如路灯、路障、小石头。
3.3.2 动态合批(Dynamic Batching)
对于会动的物体,比如一群小兵,可以用动态合批。引擎会在运行时,把满足条件的物体合并成一个批次。
条件比较苛刻:
- 顶点数不能太多(Unity限制是900个顶点以内)
- 必须使用相同的材质和纹理
- 不能有镜像变换(缩放为负数)
个人经验: 动态合批其实挺“娇气”的。我在项目中一般只对粒子系统或者小规模弹幕用。如果物体稍微复杂一点,动态合批的开销反而比不合并还大。建议先测试,再决定用不用。
3.3.3 GPU Instancing
这是我最喜欢的方式。它利用GPU的实例化能力,一次Draw Call渲染多个相同网格的物体。适合大量重复的物体,比如森林里的树、草地、子弹。
代码示例(Unity Shader中开启Instancing):
// 在Shader中声明
#pragma multi_compile_instancing
// 在顶点着色器中
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_SETUP_INSTANCE_ID(v);
使用GPU Instancing时,CPU只需要准备一份网格数据和一份实例数据(位置、旋转、颜色等)。GPU会自己循环处理。效率非常高。
3.3.4 视锥剔除(Frustum Culling)
这个不用多说,不在摄像机视野内的物体,直接不渲染。引擎默认都开了,但要注意,如果你的物体是动态合批的,剔除粒度会变粗。我建议对大型场景,手动做一些分块剔除。
3.3.5 遮挡剔除(Occlusion Culling)
比视锥剔除更进一步。即使物体在视野内,但如果被其他物体挡住了,也不渲染。比如,你站在一堵墙后面,墙后面的所有物体都可以不画。
Unity的遮挡剔除系统需要烘焙数据。我踩过一个坑:烘焙时忘了把一些动态物体标记为“遮挡物”,结果它们明明挡住了后面的东西,却还是被渲染了。帧率直接掉了一半。
3.3.6 合并材质和纹理
Draw Call的合并,前提是材质相同。如果你有100个物体,但用了50种材质,那无论如何也合不到一起。所以,尽量使用材质实例,或者把多个纹理合并到一张图集(Texture Atlas)里。
举个例子,一个关卡里所有木箱,都用同一张纹理图集的不同区域。这样它们就可以共享一个材质,从而合批。
3.4 总结与建议
好了,咱们把Draw Call的来龙去脉理了一遍。最后,我给出一个实用的优化流程:
- 先测量: 用Profiler看Draw Call数。目标是多少?移动端建议200-300,PC端可以到1000-1500。
- 再分析: 哪些物体Draw Call最多?是重复的小物件?还是复杂的角色?
- 然后动手:
- 静态物体 → 静态合批
- 动态重复物体 → GPU Instancing
- 简单动态物体 → 动态合批(慎用)
- 所有物体 → 确保剔除有效
- 最后验证: 优化后,CPU帧时间降了吗?GPU帧时间升了吗?如果GPU帧时间也降了,说明你优化过头了,GPU在等CPU。
一句话总结: Draw Call优化的本质,是让CPU少干活,把更多时间留给游戏逻辑。别为了合批而合批,时刻关注CPU和GPU的平衡。
嗯,这一章就到这里。下一章咱们聊聊更细的CPU优化技巧——缓存友好性。你会发现,有时候改一下数据结构,比改渲染设置还管用。