Unity3D优化技巧系列(一):Draw Call优化

发表于2017-04-01
评论0 4.1k浏览

最近给读者分享一下关于Unity3D的优化,这个问题对于开发者来说都是比较头疼的问题,这里先介绍一下关于项目开发通常的做法。开发项目前期由于赶进度,不停的堆积功能和资源,这样项目完成后,包体非常庞大,代码写的也很乱,后期项目进入优化阶段,包括代码的重构,各种BUG的修复,从而导致版本开始变的不可控制,项目研发周期也是不停的延期延期,这种情况在大部分公司都是常见的问题。其实作为老板来说,他肯定要着急看到项目成果,所以就不停的督促开发人员加班加点的搞,站在老板的角度考虑问题,情有可原。

但是,作为开发者来说,我们不能按照老板的节奏走,这样后面不仅坑的是自己,也把公司坑掉了,后期项目由于各种问题很容易夭折,这种局面是不可控制的,因为项目开发完成了,后期还会加入很多功能,如果前期没有规划好,就会出现前一发而动全身的情况,这样也预示着项目即将死掉。如何避免这种情况发生,在这些系列文章中给读者分享一下笔者关于这些问题的解决方案。

用Unity引擎开发,我们就要了解它们的工作机制,这样在项目进行优化时也会有针对性,关于优化方面,笔者也做过一些视频讲座,当然这些系列文章是视频中没有的,Unity首先遇到的问题就是效率问题,很多项目由于效率问题夭折了,所以这个问题的解决非常重要,如何优化效率也就是运行帧率,针对于Unity就是减少Draw Call的数量。

 网上关于Draw Call的介绍非常多,这里再给读者向细的方向讲一下,其实Draw Call的执行就是CPU与GPU之间的通信,这就涉及到渲染流水线的概念,渲染流水线的起点是CPU,主要分三个阶段:

1、CPU把数据加载到显存中

2、设置渲染状态

3、调用Draw Call

根据上面提到的三个步骤,再详细介绍一下,所有游戏中渲染所需要的数据都需要从硬盘中加载到内存中,然后网格和纹理等数据被加载到显卡上的存储空间也就是我们所说的显存。这是因为显卡对于显存的访问速度更快,效果如下:

实际项目开发中,真实渲染中需要加载到显存中的数据比图中显示的更复杂,举例说明一下:顶点的位置信息、法线方向、顶点颜色、纹理坐标等等。

当把数据加载到显存后,在内存中的数据部分可以移除,因为对于一些数据来说,CPU仍然要访问它们,从硬盘加载到内存的过程是十分耗时的,这个也要考虑的。在这之后,开发者还需要通过CPU来设置渲染状态,从而“指导”GPU如何进行渲染工作。

接下来介绍设置渲染状态了,渲染状态定义了场景中的网格是如何被渲染的,如果我们编程没有更改渲染状态,所有的网格都将使用同一种渲染状态。如下图所示效果演示:


以上图片只是表达这个意思,同样的渲染状态,材质是一样的。准备好上述工作后,CPU就需要调用一个渲染命令来告诉GPU:数据准备好了,可以按照我的设置开始渲染,这个渲染命令就是Draw Call,讲了这么多终于回来了。

 实际上,Draw Call就是一个命令,它的发起方是CPU,接收方是GPU。当给定了一个Draw Call时,GPU就会根据渲染状态(比如材质,纹理,着色器等)和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的那些像素,这个过程也会涉及到GPU流水线。如果有读者对此不清楚可以看看笔者以前的博客有详细的介绍。接下来我们继续解释Draw Call ,给开发者造成的误区认为 造成Draw Call问题的主要原因是GPU, 认为GPU上的状态切换是耗时的,其实不是的,真正的罪魁祸手是CPU。

下面我们就介绍CPU和GPU工作原理,大家先想一下,如果没有流水线,那么CPU需要等到GPU完成上一个渲染任务才能再次发送渲染命令。但这种方法显然会造成效率低下。我们需要让CPU和GPU可以并行工作,解决方法是使用一个命令缓冲区(Command Buffer)。命令缓冲区包含了一个命令队列,它是由CPU向其中添加命令,而由GPU从中读取命令,添加和读取的过程是相互独立的。命令缓冲区使得CPU和GPU可以相互独立工作。当CPU需要渲染一些对象时,他可以向命令缓冲区中添加命令,而当GPU完成了上一次的渲染任务后,它就可以从命令队列中再取出一个命令并执行它。

命令缓冲区中的命令有很多种,而Draw Call是其中一种,其它命令还有改变渲染状态等。效果如下图所示:

图中显示的是CPU与GPU通过缓冲区进行交互,Draw Call执行的是图中灰色的显示的,而白色的是改变渲染状态的,这个相对来说比较耗时间。

为什么Draw Call 多了会影响帧率?读者可以做一个实验,比如你复制1000个文本文件到另一个文件夹中,每个文件大小是100K,总计大小是10MB,这个要花费很长时间。我们再来创建一个单独的文件,它的大小是10M,然后也把它从一个文件夹复制到另一个文件夹。这次复制的时间少很多。主要原因在于,每一个复制动作需要很多额外的操作,比如分配内存等,如果复制1000个文件,他要开辟内存1000次,这个开销将会很大。

渲染过程跟这个类似,在每次调用Draw Call之前,CPU需要向GPU发送很多内容,包括数据、状态和命令等。在这一阶段,CPU要完成很多工作。一旦CPU完成了这些准备工作,GPU就可以开始本次的渲染。GPU渲染能力是很强的,渲染200个还是2000个三角网格没啥区别,因此渲染速度往往快于CPU提交命令的速度。如果需要执行的数据很多,也就是说Draw Call 数量很多,CPU就会把大量的时间花费在提交Draw Call上,造成CPU的过载,这个是需要我们避免的。

如何减少Draw Call的数量,这个在Unity开发中常见,减少Draw Call的方法很多,介绍一下批处理的方法。在前面提到过,复制文件的问题,CPU的时间都花费在准备Draw Call的工作上了。优化的想法就是把很多小的DrawCall合并成一个大的Draw Call,这就是批处理思想。

需要注意的是,合并网格是需要CPU在内存中执行的,合并的过程是需要消耗时间的。因此,批处理技术更加适合于哪些静态的物体,当然动态的如果合并的话,也需要在CPU中执行,我在以前的博客中也有介绍。要注意的是不要每一帧都去重新进行合并再发送给GPU,这对空间和时间都会造成一定的影响。

要做到减少Draw Call需要注意以下两点:

1、避免使用大量很小的网格,如果不可避免需要使用很小的网格结构,可以考虑合并,当然,模型的材质也可以考虑通用。

2、避免使用过多的材质,尽量在不同的网格之间共用同一个材质。

3、遇到有相同动作的物体,可以使用合并动态网格的方式进行优化,效果还不错。

在本文最后把动态网格合并的代码给读者展示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class CombineOpMesh : MonoBehaviour { 
   
   
    void Start() 
    
        CombineToMesh(this.gameObject); 
    
   
   
    public void CombineToMesh(GameObject _go) 
    
        SkinnedMeshRenderer[] smr = _go.GetComponentsInChildren(); 
        List lcom = new List(); 
   
   
        List lmat = new List(); 
        List ltra = new List(); 
   
   
        for (int i = 0; i < smr.Length; i++ ) 
        
            lmat.AddRange(smr[i].materials); 
            ltra.AddRange(smr[i].bones); 
   
   
            for (int sub = 0; sub < smr[i].sharedMesh.subMeshCount; sub++ ) 
            
                CombineInstance ci = new CombineInstance(); 
                ci.mesh = smr[i].sharedMesh; 
                ci.subMeshIndex = sub; 
                lcom.Add(ci); 
            
            Destroy(smr[i].gameObject); 
        
   
   
        SkinnedMeshRenderer _r = _go.GetComponent(); 
        if (_r == null
            _r = _go.AddComponent(); 
   
   
        _r.sharedMesh = new Mesh(); 
        _r.bones = ltra.ToArray(); 
        _r.materials = new Material[] { lmat[0] }; 
        _r.rootBone = _go.transform; 
        _r.sharedMesh.CombineMeshes(lcom.ToArray(), true, false); 
    
实现原理是:首先去遍历每个对象的SkinnderMeshRenderer,然后将其所有的动态对象组合成一个大的对象并且将骨骼动画赋值给他,这样,我们就实现了动态对象的优化。

实现效果图对比如下,首先展示的是没有合并的动态动画的Draw Call数量:


然后再挂上文提到的合并脚本后的效果如下所示:


具体操作方式如下所示:

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引