DrawCall:

DrawCall的产生:

Drawcall是CPU向GPU发起的在屏幕上渲染一个图元(渲染所需的几何信息)列表的请求。属于渲染流水线中的应用阶段。

渲染流水线:

1.应用阶段(输出渲染图元“渲染所需的集合信息”)
2.几何阶段(对顶点进行操作,如坐标转换)“顶点着色器”
3.光栅阶段(光栅化,图形裁切,输出图像)“片段着色器”
应用阶段可以分为三段:
    加载数据:将所有渲染所需要的数据从硬盘中加载到内存,然后将网格和纹理等数据加载到显存中.
    设置渲染状态(会占用CPU的时间和精力):比如使用那个顶点/片段着色器,光源属性,是否开启Alpha Blending(透明混合),设置渲染层级,纹理,深度写和深度测试;(引入材质的概念,材质是一种包含如何绘制对象的信息的数据结构。它包含一个着色器及其所有参数,以及有关如何设置GPU渲染状态的信息。每种材质至少使用一个SetPass(用于设置渲染参数)
    通知GPU渲染图像:产生DrawCall,发起方是CPU(中央处理器),接收方GPU(图形处理器)。这个命令仅仅会指向一个需要被渲染的图元列表,而不会再包含任何材质信息。因为这些功能在“设置渲染状态”中已经完成了。当给定一个DrawCall时,GPU就会根据渲染状态(材质、纹理、着色器等)和所有输入的顶点数据来进行计算,最终输出成屏幕上显示出图像。

DrawCall占用:

只要添加Canvas将会打断和之前元素DrawCall的合并,每个Canvas都会开始一个全新的DrawCall
只有一个Canvas:

两个相同的Canvas , DrawCall数量翻倍


一个材质一个drawcall;空场景只有摄像机和灯光也会有两个drawcall

文本drawcall:

一个文本“字体”占一个drawcall(前提是使用相同的材质);与文本组件的多少无关,如果不同字体的字体组件有重叠也不会影响drawcall数量。对于使用不同材质的文本组件所占用drawcall数与对应材质有关。
文本占用的drawcall跟文本组件使用的材质有关。


图集,图片的drawcall:

当图集中有多个相同的图片时,图集中只显示一个。当渲染多个图片时,如果是同一图集占一个drawcall,如果不是一个图集几个图片占几个deawcall(不考虑多个图片分布在多个图集)
总结:一个图集一个drawcall,一个图片一个drawcall

性能优化:

使用批处理

批处理将使用相同材质的对象的渲染分组到同一个DrawCall中(还需要注意渲染顺序)。

Batches 与 SetPass calls

在Stats中看到的Batches 与 SetPass calls指标之间存在着差异
Batches通常被称为DrawCall(绘图调用)这些是简单的绘制命令(CPU通知GPU进行图像绘制),主要是关于使用当前全局渲染状态来绘制相同着色器,相似参数的对象。
SetPass calls描述材质更改的操作,需要设置新的渲染状态是一种昂贵的操作,其中包含着色器参数和渲染管线设置,如Alpha Blending,Z-Test,Z-Writing。不同的材质会使Set Pass calls的数量猛增,并且不能够进行批处理(需要满足有相同材质)。

批处理

批处理流程图:


批处理前提是需要有相同的材质,不同的材质有不同的渲染设置,这些设置会更改全局GPU的渲染状态。

合并Unity材质(Materials)

第一步尽可能的减少项目中的着色器,如果可以将两个相似的着色器合并到同一着色器中,那将获得巨大的性能优势。
接下来就是合并其材质,这可能很复杂,因为他们可能具有不同的材质参数。如:
  • 纹理:每种材质通常具有一个或者多个与其他材质不共享的纹理。在不同材质上使用相同纹理的一种方法是创建包含所有单独纹理的较大纹理,称为图集。
  • 材质参数:如金属,镜面反射和其他参数。要合并这些值,可以找到合适所有条件的共同平均值,也可以在特定通道中创建包含该值的纹理图集。如可以将3个或4个纹理通道(r,g,b,a)用于不同的参数,例如将金属值存储在红色通道中。
当有了多个相同材质的对象,但他们必须有不同的参数,则可以把他们收集成为MaterialPropertyBlock(材质属性块)。可以为每个需要自定义参数的渲染器创建材质属性块,而不是创建单个材质实例,然后可以在单个bock中设置自己的参数。虽然不会减少drawcall但是会降低渲染成本,因为已经明确告诉Unity每个对象的不同之处。
为共享着色器的材质创建纹理图集通常遵循以下步骤:
  1. 创建一个大纹理,称为纹理图集
  2. 获取所有材质的纹理通道,并将其纹理复制到新创建的纹理图集中
  3. 遍历使用这些材料的网格以重新计算其UV。新的UV将指向包含原始纹理的纹理图集的新的子区域。
  4. 禁用旧的网格,然后使用有更新的UV的新网格
  5. 使用合并过的材料替换原来的材质
  6. 对着色器使用的每个纹理属性重复所有这些步骤。
可以使用Unity包在Unity中合并材质。Mesh Baker;  Super Combiner;Advanced Batcher;One Batch;Mesh Combine Studio

Unity静态批处理

静态批处理在默认状态下是处于启动状态。应用于场景中共享材质的所有静态对象。
如果场景中有两个被标记为静态的椅子和一个被标记为静态的桌子,并且使用相同的材质,Unity就会把他们合并为一个DrawCall。

Unity静态批处理通过创建包含各个网格的巨大网格来工作。但是Unity也会保持原始网格的完整,因此我们依然能够单独渲染他们,这样我们可以绘制可见视野内的对象,而丢弃不可见的对象,使得视锥裁切正常工作。通过将所有网格都放在一个网格中,我们就可以在不更改渲染状态的情况下全部绘制他们。
静态批处理仅在在编辑器运行游戏之前发生。并且在构建项目之前也会发生,Unity会尝试遍历每个场景,并尝试批处理尽可能多的静态对象。
主要限制:每批可以具有的顶点和索引的数量,通常为每个64000个,可以看官方文档检查闲置是否有更新。Draw call batching - Unity 手册 (unity3d.com)
缺点:增加了内存使用量。如果有100个石头,每个石头模型占用1mb,则可以预期内存使用量将超过100mb。是因为巨大的批处理网格将所有的石头一起包含在一个网格中。

Unity GPU Instancing

GPU Instancing可以用于非静态对象。
当动态绘制100个石头是,使用GPU Instancing可以将他们合并为一个DrawCall。启用GPU Instancing ,只需要在材质检视面板中启用它即可(Endable GPU Instancing)。如果有多个具有相同网格和材质的对象,他们会自动批处理。
通过GPUInstancing 可以让你高效的多次绘制同一网格。Unity通过向GPU传递一个Transorm列表来实现这个功能。毕竟没有对象都有它自己的位置,旋转,缩放值。
与静态批处理相比,他不会增加内存的使用量,也不要求对象是静态的。
然而创建Transform列表会降低性能,如果在游戏中没有物体移动,旋转,缩放,就只需要提交一次,产生一次开销,如果每帧都发生变化则每帧都产生一次开销。
相关插件: GPUInstance

Unity动态批处理

当不满足静态批处理和GPUInstancing时,可以对使用不同网格物体的动态对象使用动态批处理。
unity动态批处理有更严格的限制,只能将它行用到具有少于300个顶点和900个顶点属性(UV,颜色等)的网格。材质也应使用single-pass(单一通道)着色器。官方文档:Draw call batching - Unity 手册 (unity3d.com)
出现限制的原因是在运行时创建这些批处理的CPU性能成本与单独发出绘制调用相比,超过300个顶点时批处理的CPU成本变得难以接受。而且动态批处理是高度不可测的。你无法确定你的对象如何被批处理,而且批处理的结果因帧而异。可以通过使用Unity Frame Debugger 去观察不同帧之间批处理结果的不同。

Unity运行时批处理API

如果希望批处理可以更好的被控制,可以手动进行。并不用自己处理顶点。unity提供两个API用于在运行时合并网格。
  1. StaticBatchingUtility.Combine。该函数传入一个根对象,并将遍历其所有的子对象并将其几何形状合并为一大块。要批处理的所有子网格的导入设置必须开启CPU read/write
  2. Mesh.CombineMeshes。间接获取网格列表并创建组合的网格。然后将网格分配给 mesh filer渲染。

SRP Batcher

内置渲染管线(BRP)不支持。URP项目中,这一项是默认开启的,在URP Asset里,【SRP Batcher】是默认勾选的。
SRP Batcher 通过批处理一系列 Bind 和 Draw GPU 命令来减少 DrawCall 之间的 GPU 设置。来降低渲染成本。而传统方法是减少 DrawCall 的数量以优化 CPU 渲染成本,因为 Unity 在发出 DrawCall 之前必须进行很多设置。实际的 CPU 成本便来自该设置,而不是来自 GPU DrawCall 本身(DrawCall 只是 Unity 需要推送到 GPU 命令缓冲区的少量字节)。
在内渲染循环中,当 Unity 检测到新材质时,CPU 会收集所有属性并在 GPU 内存中设置不同的常量缓冲区。GPU 缓冲区的数量取决于着色器如何声明其 CBUFFER。为了在场景使用很多不同材质但很少使用着色器变体的一般情况下加快速度,SRP 在原生集成了范例(例如 GPU 数据持久性)。SRP Batcher 是一个低级渲染循环,使材质数据持久保留在 GPU 内存中。如果材质内容不变,SRP Batcher 不需要设置缓冲区并将缓冲区上传到 GPU。
为了最大的渲染性能,这些批处理次数越多越好。

游戏对象间的渲染顺序

有三个游戏对象,两个材质,对象A,B使用材质1;对象C使用材质2,
当渲染顺序是:A,B,C此时DrawCall为2;当渲染顺序为A,C,B时Draw Call为3。
在Unity中会根据相机的距离,有远到近进行渲染,在UI相机中会根据UI的深度进行渲染。
一些规则:
  • 2D场景中的对象,使用Z轴来进行空间的划分,如背景层,特效层,使用NGUI可以使用Depth来进行空间划分。

图集打包

可以通过打包图集将多个纹理Draw Call合并为一个。
图集打包规则:
  • 从功能角度划分,如UI的通用部分。在某个系统和功能上密切相关可能同时出现的图片打成一个图集。
  • 不要将全部图片打进一个图集。特别是不会同时出现的。这样会占用内存。并且要控制图集大小,如大小超出1024一点就会变成是2048。

特效清理

确保用完一个特效后将其自身显示设为不可见,或者销毁掉(如果是一次性的)否则会一直产生DrawCall。

UI的性能优化

Unity将UI的渲染分为两个步骤,对mesh的操作称为Rebatch,对material和layout(布局)的操作称为Rebuild,所有性能消耗也在这两个部分。

Rebatch的内部实现

将多个对象合并渲染。Rebatch发生在C++层面,是指Canvas分析UI节点生成最优批次的过程,节点数量过多会导致算法(贪心策略)耗时较长。对应SetVerticesDirty(设置脏标记),当一个Canvas中包含的mesh发生改变时就触发,例如SetActive、transform的改变、 颜色改变、文本内容改变等等,Canvas独立处理,互相不影响。消耗在对meshes按照深度和重叠情况排序、共享材质的检测等。
Batch以Canvas为单位,同一个Canvas下的UI元素最终都会被Batch到一个Mesh中。Batch前,UGUI根据UI材质以及渲染顺序重排,在不改变渲染结果的前提下,尽可能将相同材质的UI元素合并在同一个SubMesh中,以减少DC。Batch只在UI元素发生变化时进行,合成的Mesh越大,耗时越大。重建对Canvas下所有ui元素生效,不论是否修改过。

优化

Canvas动静分离,合理划分,按游戏类型和UI数量划分,太多也有额外消耗。
减少节点层次和数量,合批计算量小,速度快。
使用相同材质贴图的UI尽量保持深度相同,这样对合批算法友好,速度快。
修改Image的Color属性,原理是修改顶点色,会引起网格Rebatch,同时触发Canvas.SendWillRenderCanvases。好处在于修改顶点色材质不变,没有额外DC。修改shader颜色不会重绘,材质不变,没有Rebatch。

Rebuild的内部实现


Rebuild发生在C#层面,是指UGUI库中layout组件调整RectTransform尺寸、Graphic组件更新Material,以及Mask执行Cull的过程,耗时和发生变化的节点数量基本呈线性相关。
只有LayoutGroup的直接子节点,并且是 Graphic类型的(比如 Image 和 Text)会触发SetLayoutDirty。
Graphic改变的原因包括,基本的大小、旋转以及文字的变化、图片的修改等等,对应SetMaterialDirty。

优化

少用layout布局组件,简单的布局RectTransform代替
Canvas动静分离,按项目类型去规划。

组件的UI优化

不要用空的Image,继承Graphic,填充数据函数写成空实现一个只接收事件不显示内容的对象。
不显示的对象,不要SetActive,设置Canvas Group的alpha为0,scale为0,这样vbo(在显存中保存的顶点信息)不会被清除。或是canvasRenderer.cull为true。
不需要响应事件的组件,取消RaycastTarget。添加工具,代码设置Image和text默认取消。
Canvas渲染模式为World Space或Screen Space Camera,始终分别设置事件摄像机和渲染摄像机非常重要,没有设置会通过FindWithTag查找主摄像机。
少用Mask(应用GPU的模板缓冲区来实现遮罩占用GPU资源),用RectMask2D代替。
TextMeshPro代替原生text。
字体OutLine会多绘制4次。PixelPerfect(完美像素)有消耗,滑动消耗更大。
Font.CacheFontForText:生成动态字体Font Texture,一次性打开UI界面中的文字越多,开销越大。如果当前Font Texture不能容下接下来的文字,扩大texture,性能影响大。

脚本篇

Unity API

GameObject.GetComponent / GameObject.Find

每次调用GetComponent时,Unity都要去遍历所有的组件来找到目标组件。每次都去查找是不必要的耗费,我们可以通过缓存的方式来避免这些不必要的开销。

GameObject.Find会遍历当前所有的GameObject来返回名字相符的对象。所以当游戏内对象很多时,这个函数将很耗时。可以通过缓存的方法,在Start或Awake时缓存一次找到的对象,在后续使用中使用缓存的对象而非继续调用GameObject.Find。或者采用GameObject.FindWithTag来寻找特定标签的对象。

Camera.Main

Camera.Main:返回主相机,Unity是通过GameObject.FindWhitTag来查找tag为MainCamera的相机,当使用频繁时可以将主相机缓存起来。

GameObject.tag

常用来比较对象的tag 直接使用 == 来进行比较的话每一帧都会产生GC Alloc,通过GameObject.CompareTag来进行比较则可以避免掉这些GC,但是前提是比较的tag需在Tag Manager中定义。同理字符串的比较尽量也使用方法来做比较(String.Compare(s1,s2); / s1.CompareTo(s2); / Equals(s1,s2); / s1.Equals(s2))

Transform.SetPositionAndRotation


每次调用Transform.SetPosition或Transform.SetRotation时,Unity都会通知一遍所有的子节点。

当位置和角度信息都可以预先知道时,我们可以通过Transform.SetPositionAndRotation一次调用来同时设置位置和角度,从而避免两次调用导致的性能开销。


Animator.Set…  / Material.Set…

当使用Animator 或 Material 的一些Set...方法时,不要传入字符串,而是将字符串转换为hash后再传入。因为在这些函数的内部 会将字符串转换为整数,如果频繁的使用Set...函数话,先进行一次转换,可以避免频繁的进行hash运算。

Coroutine

是Unity实现异步调用的一种机制,本质是迭代器。

当需要实现一些定时操作时,有些同学可能会在Update中每帧进行一次判断,假设帧率是60帧,需要定时1秒调用一次,则会导致59次无效的Update调用。

用Coroutine则可以避免掉这些无效的调用,只需要yield return new WaitForSeconds(1f);即可。当然这里的最佳实践还是用一个变量缓存一下new WaitForSeconds(1f),这样省去了每次都new的开销。

Debug.Log

输出Log是一件异常耗时,而且玩家感知不到的事情。所以应该在正式发布版本时,将其关闭。

Unity的Log输出并不会在Release模式下被自动禁用掉,所以需要我们手动来禁用。我们可以在运行时用一行代码来禁用Log的输出:Debug.logger.logEnabled = false;。

不过最好采用条件编译标签Conditional封装一层自己的Log输出,来直接避免掉Log输出的编译,还可以省去Log函数参数传递和调用的开销。具体可以参见:********************************************************************

C#

内存分配(栈和堆)

在C#中,内存分配有两种策略,一种是分配在栈Stack上,另一种是分配在堆Heap上。

在栈上分配的对象都是拥有固定大小的类型,在栈上分配内存十分高效。

在堆上分配的对象都是不能确定其大小的类型,由于其内存大小不固定,所以经常容易产生内存碎片,导致其内存分配相对于栈来说更为低效。

值类型和引用类型

避免拆装箱

在C#中,数据可以分为两种类型:值类型ValueType和引用类型ReferenceType

值类型包括所有数字类型、Bool、Char、Date、所有Struct类型和枚举类型。其类型的大小都是固定,它们都在栈上进行内存分配。

引用类型包括字符串、所有类型的数组、所有Class以及Delegate,它们都在堆上进行内存分配。

装箱(Boxing)指的是将值类型转换为引用类型,而拆箱(UnBoxing的是将引用类型转换为值类型。

垃圾回收

我们在上分配的内存,其实是由垃圾回收器(Garbage Collector)来负责回收的。垃圾回收算法异常耗时,因为它需要遍历所有的对象,然后找到没有引用的孤岛,将它们标记为「垃圾」,然后将其内存回收掉。

频繁的垃圾回收不仅很耗时,还会导致内存碎片的产生,使得下一次的内存分配变得更加困难或者干脆无法分配有效内存,此时堆内存上限会往上翻一倍,而且无法回落,造成内存吃紧。

所以我们应该极力避免GC Alloc,即需要控制堆内存的分配。

字符串

字符串连接会导致GC Alloc,所以如果字符串连接是高频操作,应该尽量避免使用+来进行字符串连接。C#提供了StringBuilder类来专门进行字符串的连接。

IL2CPP

I2LCPP是Unity提供的将C#的IL码转换为C++代码的服务,由于转成了C++,所以其最后会转换成汇编语言,直接以机器语言的方式执行,而不需要跑在.NET虚拟机上,所以提高了性能。同时由于IL的反编译较为简单,转换成C++后,也会增加一定的反汇编难度。

IL2CPP的C++代码虽然是自动生成的,但是其中间的某些过程也可以被人为操纵,从而达到提升性能的目的。

避免自动判空

在自动转换的C++代码中,IL2CPP默认会对所有Nullable的变量做判空。其实在某些你非常确定参数不为空的场合,这种检测实际上是不必要的。具体步骤是复制Il2CppSetOptionAttribute.cs文件到你的Assets目录下,然后在类或者函数定义上加一个修饰语句[Il2CppSetOption(Option.NullChecks, false)]即可以禁用整个类或者函数的判空检测。

避免数组越界检测

同理,IL2CPP也会默认对所有数组的读写做越界检测,我们可以通过修饰语句[Il2CppSetOption(Option.ArrayBoundsChecks, false)]来将其禁用。