2D 游戏开发如何合理地减少 DrawCall

01/19 18:24
阅读数 21

麒麟子直播分享了关于 《2D 游戏开发如何减少 DrawCall》的议题,发现很多开发者对这个话题感兴趣,所以整理出了这个文字稿,方便错过直播的同学学习。

性能优化,作为游戏开发最后的关键环节,至关重要。

2D 游戏开发性能优化工作中,DrawCall 优化带来的性能提升效果最显著,今天我们就聊聊如何减少 2D 游戏中的 DrawCall。

DrawCall 基础知识

在讲如何进行 DrawCall 优化之前,我们先来看看一些 DrawCall 相关的基础知识,这样有助于大家理解方案背后的逻辑,也方便大家在资源有限的时候做出取舍。

什么是 DrawCall?

顾名思义, 一个 DrawCall,就是一次绘制(Draw) 调用(Call)。

以 OpenGL/WebGL 等图形 API 为例,在绘制一个网络时,通常会进行以下操作:

  1. 设置渲染状态(深度、模板、光删器、混合)
  2. 设置 Shader(Shader 代码、U逆)
  3. 设置 Uniforms
  4. 设置纹理参数(纹理图片、采样、寻址)
  5. 调用 glDraw*** 函数发起绘制

以上就是一个 DrawCall 发起时,需要进行的操作。

上面的 1.2.3.4 步涉及的内容,被游戏引擎放到了材质(Material)里,而 glDraw*** 使用的则是 MeshRenderer 中的 Mesh 数据。

CPU 与 GPU 通信

1、CPU 组装

上面这些图形 API 在被调用时,并不会直接调用显卡的功能,CPU 端会先将这些图形 API 的调用组装为 GPU 可以识别的指令再发送给 GPU。

2、分批提交

DrawCall 并不是一个个提交给 GPU 的,而是分批提交。

CPU 组装好 DrawCall 后,会将其放入 CPU 与 GPU 之间的一个命令缓冲区。当这个缓冲区满了,或者调用强制清空命令(如 glFush)时,缓冲区的内容才会被提交给 GPU。

这样可以减少 CPU 与 GPU 的通信频率,提高通信效率。

3、GPU 解析

GPU 接到绘制命令后,会逐个解析 DrawCall 并执行后面的运算。

为什么 DrawCall 多了影响性能?

1、CPU 组装耗时

CPU 进行 DrawCall 组装时,会有性能开销,大量的 DrawCall 会占用过多的时间。

2、通信成本变高

CPU 与 GPU 是跨硬件通信,需要经过图形驱动层和操作系统内核调度,相比普通函数的调用,最多可达到数十倍的开销。

而 CPU 与 GPU 之间的缓冲区大小是有限的,过多的 DrawCall 会导致一帧内发起太多次通信,影响性能。

3、上下文切换消耗

DrawCall 过多,也意味着渲染数据没有良好组织。

我们从 DrawCall 的图形 API 调用可以看出,如果没有得到良好的组织,每一次绘制都需要切换渲染状态、Shader、图片等渲染上下文。

这不仅会让 CPU 组装时有更多开销,也会让 GPU 产生频繁的设备切换和显存读取,整体影响性能。

DrawCall 影响 CPU 还是 GPU?

1、CPU 影响最大

在渲染数据量不变的情况下,DrawCall 越多,对 CPU 的压力越大。因为 GPU 的吞吐量远远大于 CPU。CPU 会首先成为瓶颈。

2、其次才是 GPU

DrawCall 过多会在一定程度上影响 GPU,这是由于渲染数据没有组织好,导致 GPU 频繁切换硬件状态,频繁请求显存数据,频繁出现 Cache Miss 等情况造成的。

DrawCall 越少越好吗?

在不消耗过多额外运算的情况下,DrawCall 越少越好。这意味着,渲染消耗的 CPU 和 GPU 会更少,将会有更多的性能空间用于提升游戏品质。

事实上,DrawCall 只要不超出机型能够承受的阈值,DrawCall 是不会影响性能的。

比如,某个手机能够承受的 DrawCall 上限是 100。那么 50,80,100 个 DrawCall,DrawCall 本身的开销,是没有太大区别的。

DrawCall 优化原则

1、渲染剔除

一个物体最快的渲染方式,就是让它不渲染。这样可以同时减少 CPU 和 GPU 的负担。

在做 DrawCall 优化时,我们首先考虑的不应该是合并,而是应该思考如何减少 DrawCall。根据实现的复杂度,我们可以按照下面的步骤进行。

  1. 看不见的内容,不渲染
  2. 降低渲染复杂度,隐藏不必要的渲染对象(特别对于低端机来说,是一个非常有效的优化手法)
  3. 将一些有差异的对象,更换为相同的对象(比如:大型国战统一服装)

2、合并 DrawCall

借助引擎渲染机制,减少 DrawCall。

2D 对象的渲染合批机制非常简单,即:若前后两个渲染对象的材质与贴图相同,则可以合并。

这种优化方式,能够极大地减少 CPU 的负担。

这里强调“借助引擎渲染机制”,是因为,合并 DrawCall 往往需要 CPU 做额外的工作。我们是用合批算法,将本来用于 DrawCall 组装消耗的 CPU 置换了出来。

对于一些不适合合并 DrawCall 的场合强行去合并 DrawCall 的话,反而有可能导致性能下降。

2D 渲染流程与合批规则

渲染流程

1、遍历结点树

引擎在渲染场景时,会采用先序遍历方式,遍历整个节点树,找出需要渲染的对象,形成渲染列表。

如果不考虑合批的话,拿到的渲染列表,有可能是下面这样的情况:

[sprite1, sprite2, label1, sprite2, label2, sprite3]

这种情况下,需要  6 个 DrawCall 才能完成绘制。

2、自动合批

在构建渲染列表时,引擎会判断相邻的两个渲染元素是否可以合批,判断条件如下:

  1. 是否使用相同的材质实例
  2. 是否处于同一图集

由于可以合批的元素使用的是同一个材质和图集,那么合批就只需要处理顶点数据就好了,步骤如下:

  1. 创建一个顶点缓冲区,比如叫 BatchedMesh
  2. 将能够合批的元素的顶点信息放到这个 BatchedMesh 里
  3. 将顶点的坐标变为世界坐标
  4. 将顶点的 UV 变为实际 UV

最终可能的渲染列表如下:

[batchedMesh1, label1, batchedMesh2]

可以看到,合批后,只需要 3 个 DrawCall 就能完成绘制。

3、提交渲染

得到渲染列表后,引擎渲染管线会调用图形 API,完成真正的渲染。

图集与合批

上面说到,想要让两个 2D 渲染元素合批,除了材质需要一致外,还需要它们的图片处于同一图集。

在 Cocos Creator 中,可以使用静态图集和动态图集来实现。

静态图集

使用 TexturePacker 工具,或者 Cocos Creator 内置的自动图集(Auto Atlas)工具,可以将一些小图预先打包到大图图集上。

当引擎在渲染 2D 元素时,如果连续的元素使用的是同一材质、同一图集,则会实现合批。

静态图集的好处是内容可控,方便我们规划图片的图集分布情况。

而静态图集的缺点,是当处于多个图集的图片交叉渲染时,就会打断合批。

动态图集

Cocos Creator 提供了动态图集功能,实现机制如下:

  1. 从动态图集中获得一张 2048 x 2048 的空白纹理
  2. 当渲染一张小图时(任何一边不超过 512),如果这张小图没有被合并过,则将这张小图合并到这张空白纹理上(如果图集空间不够,则会新开空白纹理)
  3. 修改当前 2D 元素的 uv和图集信息

这样一来,小图就可以根据顺序实现自动图集分布,相邻的两个元素使用的图集会尽可能的一致。

动态图集功能在 Web 端默认开启,如果想要禁用,则需要调用:DynamicAtlasManager.instance.enabled = fase 进行关闭。

这个机制会有几个缺点。

  1. 需要开启图片内存缓存,会增一倍的内存开销
  2. 由于需要内存数据支持,目前PVR/ETC等GPU压缩格式的纹理,不支持动态图集
  3. 如果需要渲染的2D元素过多,会很容易导致图集交叉使用的情况
  4. 图集只会在场景切换时清空,对单场景不友好

使用建议

1、开启动态图集功能

macro.CLEANUP_IMAGE_CACHE = false;
DynamicAtlasManager.instance.enabled = true;

注意,一定要:macro.CLEANUP_IMAGE_CACHE = false 才行,否则动态图集不会生效。

2、区分2D 游戏对象和 UI

2D 游戏中,虽然 2D 游戏对象和UI都使用 2D 渲染管线,但我们应该区别对待,做出不同的渲染管理策略。

3、对 2D 游戏对象,禁用动态图集

2D 游戏对象由于渲染顺序不定,内容不定,不适合使用动态图集,建议使用静态图集进行管理。

如果打包了静态图集,尺寸一般都会超过 512x512,自然不会走动态图集流程。

如果是单独的小图不想参与动态图集,可以通过 SpriteFrame 资源的 Packable 标记来关闭。

对于采用 GPU 压缩纹理的图片,引擎会把 packable 标记置为 false

4、UI 使用动态图集

UI 的时效和可控性较强,使用动态图集能够很好地降低 DrawCall,特别是有大量的小图标和静态 Label 的情况。

需要在适合的时机,调用 DynamicAtlasManager.instance.reset() 重置动态图集,否则动态图集会不够用。

动态图集单张 2048 x 2048,最多 5 张

如果对动态图集有更精细的控制要求,可以考乐府分享的突破动态合图。

5、避免内存开销

开启动态图集需要启用 macro.CLEANUP_IMAGE_CACHE 宏,但对于不需要参与动态图集的纹理怎么办呢?

if (macro.CLEANUP_IMAGE_CACHE) {
    const deps = dependUtil.getDeps(this._uuid);
    const index = deps.indexOf(image._uuid);
    if (index !== -1) {
        js.array.fastRemoveAt(deps, index);
        image.decRef();
    }
}

参考上面引擎源码中 Texture2D 基类 SimpleTexture 的代码我们可以知道,只要在纹理提交成功后,调用 image.defRef 释放纹理的 image 数据即可。

按理,我们可以将所有 packable 为 false 的纹理的 image 数据释放,以节省内存。

Sprite 与 Label

Sprite 和 Label 作为两个使用频率最高的 2D 渲染组件,我们来看看常见的可能打断合批的情况。

渲染分析工具示例

工欲善其事,必先利其器。好的工具,可以让你的工作效率事半功倍。

下面我们以 SpectorJS 为例,看看常见的渲染分析工具的用法。

1、SpectorJS 安装

  1. 搜索引擎中搜索 SpectorJS
  2. 进入 Chrome 插件商店
  3. 安装并激活 SpectorJS 插件

2、抓帧

  1. 点击右上角的 SpectorJS 插件图标,打开面板

  2. 点击红色的抓帧按钮,即可抓取当前画面帧

  3. 如果想清晰地查看每一个 DrawCall 的画面,需要勾选 Full Capture

3、DrawCall 分析

完成抓帧后,会打开一个新的帧信息页面。

  • 左边的每一幅图,表示一个 DrawCall
  • 中间显示的是这个 DrawCall 进行了哪些渲染状态的切换、绘制了多少三角面、使用了什么 Shader
  • 右边展示的是具体的渲染状态、Uniforms、使用的贴图

通过查看这些信息,我们可以很容易知道一个 DrawCall 绘制了哪些东西,为什么 DrawCall 会被打断,让问题定位更加容易。

4、其他工具

XCode

XCode 是一个十分强大的调式工具,C++ 代码、内存、性能分析、GPU 负载等都可以用它初步分析一次。

只需要在发布的时候,把项目发布为 iOS 就行。一些Web/小游戏平台的游戏,也可以用这个方法来做调试优化。

RenderDoc

这是 Windows 平台上的一款渲染调试工具,可以通过挂载 Chrome 进程来调试 WebGL,也可以将项目发布为 Windows,进行原生调式。

Snapdragon Profiler

Snapdragon Profiler 是高通提供的调试工具,如果手机芯片是高通的,则需要用这个工具才行。

渲染问题处理经验分享

  1. 使用 SpectorJS/RenderDoc 处理常见渲染问题,百分之七八十的问题,通过这个都能解决。

  2. 使用 XCode 进行性能分析和优化。由于 Shader 和渲染机制在 iOS 和 Android 等系统上差异并不大,大部分性能问题都可以借助 XCode 定位和解决。

  3. 如果对应设备上有问题,则使用 Snapdragon Profiler/ SmartPerf 等对应芯片工具处理特殊情况

结束语

渲染相关的问题定位和性能优化总的来说,就 3 个步骤:

  1. 熟悉基本原理和渲染流程
  2. 采用适合的工具进行分析和定位
  3. 结合基本原理、项目实际情况、定位到的问题定制适合的解决方案

好啦,今天的分享就到这里,希望能够对大家有所帮助。

本文分享自微信公众号 - COCOS(CocosEngine)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部