前言

本文仅作为性能优化的导读,依靠本文可以了解到一些Unity性能优化的方法,以及学习性能优化需要了解的知识,以此来入门如何性能优化。本文同时提供了很多参考阅读供读者阅读。

关于AOI,可参考我的另外一篇文章

关于Resources System,可参考这篇文章

由于文章太长,性能优化下篇可参考这篇文章

GPU CPU的分工

GPU比较适合大量可并行的简单任务。比如场景渲染,光照处理等等。

你看到的图像都是显卡渲染出来的。同时现在的GPU也支持一些其他的运算,比如通过GLSL,HLSL和Cg支持并行运算等等。

GPU同时也对游戏中的一些物理效果提供支持,比如PhysX。

一般CPU用于一些数值运算,比如伤害,随机数等等。同时你的敌人AI也是CPU运算出来的。

https://www.zhihu.com/question/21475727/answer/18348647

Statistics统计面板

官方文档:https://docs.unity3d.com/Manual/RenderingStatistics.html

【U3d】渲染统计窗口详细介绍:https://blog.csdn.net/wdmzjzlym/article/details/51335915

  • FPS:
    • Frames Per Second,即一秒内渲染多少帧
    • 1s=1000ms
    • ms=1000/目标帧率,1s30帧,即1ms=33.3帧
    • 手游一般要求30帧以上
  • DrawCall:CPU每次调用图形API命令GPU进行渲染的操作,在Unity中它可以多个Draw Call合并成一个Batch去渲染
  • Batch:把需要渲染的数据加载到显存并设置好渲染状态,然后CPU调用GPU渲染的过程被称为一次Batch(批次)
  • Setpass Call:每次GPU切换一个Pass前,都会产生一次SetPassCall,主要出现在材质不一致的时候,进行渲染状态切换
  • 如何批处理以降低批次(Batching)
    • 动态合批(Dynamic Batching)
      • 将物体动态组装成一个个稍大的vbo+ibo提交。
      • 这个过程不要求使用同样的mesh,但是也一样要求同样的材质。
      • 但是,由于每一帧CPU都要将每个物体的顶点从模型坐标空间变换到组装后的模型的坐标空间,这样做会带来一定的计算压力。所以对于Unity引擎,一个批次的动态物体顶点数是有限制的。
    • 静态合批(Static Batching)
      • 将静态物体集合成一个大号vbo提交,但是只对要渲染的物体提交其IBO。
      • 这么做不是没有代价。比如说,四个物体要静态批次合并前三个物体每个顶点只需要位置,第一套uv坐标信息,法线信息,而第四个物体除了以上信息,还多出来切线信息,则这个VBO会在每个顶点都包括所有的四套信息,毫无疑问组合这个VBO是要对CPU和显存有额外开销的。
      • 要求每一次Static Batching使用同样的material,但是对mesh不要求相同。
    • GPU实例化(GPU Instancing)
      • 只提交一个物体的mesh,但是将多个使用同种mesh和material的物体的差异化信息(包括位置,缩放,旋转,shader上面的参数等。shader参数不包括纹理)组合成一个PIA提交。
      • 在GPU侧,通过读取每个物体的PIA数据,对同一个mesh进行各种变换后绘制。
      • 这种方式相比static和dynamic节约显存,又相比dynamic节约CPU开销。但是相比这两种批次合并方案,会略微给GPU带来一定的计算压力。
      • 但这种压力通常可以忽略不计。限制是必须相同材质相同物体,但是不同物体的材质上的参数可以不同。
    • 所以Unity默认策略是优先static,其次gpu instancing,最后dynamic。当然如果顶点数过于巨大(比如渲染它几千颗使用同种mesh的树),那么gpu instancing或许比static batching是一个更加合适的方案。
  • Batch, Draw Call, Setpass Call:https://zhuanlan.zhihu.com/p/76562300

DrawCall

渲染对象关系:一个可以有多个MeshRender共用一个Material,多个Material公用一个Shader,只要是一个Material,理论上就可以放在一个DrawCall中

走近DrawCall:https://zhuanlan.zhihu.com/p/26386905

Unity中DrawCall和openGL、光栅化等有何内在联系,为什么说DC降低有助于渲染性能优化?:https://www.zhihu.com/question/36357893

Profiler性能分析器

Window->Profiler或者ctrl+7进入

可以定位每一帧的性能情况,包括CPU、GPU、内存等的消耗情况

官方文档:https://docs.unity3d.com/Manual/profiler-module-editor.html

开启Deep Profiler模式,可以深度采样所有函数的执行消耗,如果不开启就只会采样最上层的部分,但是开启Deep模式自身也会消耗一定性能,可以对应着关闭Deep模式做对比,不能完全相信Deep模式的结果。

注意,避免为了性能分析增加了过多的临时代码,造成额外性能的消耗。

【Unity】性能调优: 如何用 Unity Profiler 做项目的性能分析#性能优化

技巧:通过Profiler.BeginSample(name);和Profiler.EndSample();来获取无法定位(不是一个函数)的代码块消耗时间和GC

Profiler使用技巧实例

性能分析方法

干扰:

  • 内部干扰:
    • Profiler(性能分析器本身)
    • Vertical Sync(垂直同步)
    • Log output(日志输出,是一种IO操作,性能要求高)
  • 外部干扰:
    • CPU
    • 内存
    • IO

工具:

  • Unity Profiler(定位每一帧的性能情况)
  • Custom Profiler(自己写脚本,来测试出性能数据,在Profiler中看,这样的性能消耗比Profiler会低很多)
  • Timer & Log(打包以后因为无法使用Profiler了,只能使用这种方法来做了)
  • Frame Debugger(可以定位每一帧GPU都做了哪些事情)
  • GPU Profiler(或者各个厂商的GPU检测工具)
  • Memory Profiler(PackageManager中下载)

CPU优化

优化方向

Unity性能优化 - CPU:https://blog.csdn.net/HelloCSDN666/article/details/124694266

  • 批处理(Batching)
  • 合并图集
  • 动静分离(防止面片的重复渲染,其实是GPU优化,当然也减少了dc)
  • 一些Unity脚本的最佳做法
    • 组件的缓冲和获取(一些可以卸载Awake/Start的对象赋值,不必卸载Update里面每帧调用)
    • 移除空的mono方法申明
    • 尽量避免Find和SendMessage(大概比直接调用慢2000倍)使用以下方法替代:
      • 直接引用有序对象
      • 静态类
      • 单例组件
      • 自定义消息系统
    • 禁用未使用的游戏对象和脚本
      • 生存周期结束的脚本/游戏物品
      • 场景中不可见的物体/脚本
      • 距离太远的物体
    • 对象池(后面详细说明了)
  • 选择正确的数据结构:
    • Array、List用于遍历
    • Dictionary用于查询
    • 合适的使用Stack、Queue
  • 算法优化,使用合适的算法需要考虑:
    • 时间复杂度
    • 密集计算分布式
    • 缓存(将一些不会频繁变动的对象缓存起来,不用每一帧都计算一遍)
  • 使用帧动画代替骨骼动画(骨骼动画是用CPU处理的,用内存换CPU时间)https://www.bilibili.com/video/BV1E54y177Tc
  • GPU Skinning(将计算量转到GPU中,用于CPU瓶颈时)https://www.bilibili.com/video/BV1E54y177Tc

动态合批

介绍:

  • 动态合批是静态合批在运行时的体现,动态合批是专门为优化场景中共享同一材质的动态GameObject的渲染设计的,目标是以最小的代价合并小型网格模型,减少Drawcall。
  • 动态合批在进行场景绘制之前将所有的共享同一材质的模型的顶点信息变换到世界空间中,然后通过一次Draw call绘制多个模型,达到合批的目的。
  • 模型顶点变换的操作是由CPU完成的,所以这会带来一些CPU的性能消耗。
  • 动态合批是Unity自己默认去执行的,你没有办法去控制动态合批,只能去满足合批的要求。
  • 动态批处理默认是关闭的,需要手动开启:Project Setting—Player—勾上Dynamic Batching

合批规则:

  • 每个Unity版本的规则可能不一样,所以最好看看使用版本的官方文档。
  • 材质相同是合批的前提,但是如果是材质实例则一样无法合批。
  • 支持不同网格的合批。
  • 单个网格最多300个顶点,900个顶点属性(顶点的位置、法线、uv、切线等),顶点属性的上限可能未来unity版本会调整。
    • 即如果Shader中用到了网格的Position、normal和UV的话,则最多是300个顶点。
    • 即如果Shader中用到了网格的Position、normal、Uv0、Uv1和tangent的话,则最多是180个顶点。
  • 镜像的transform无法合批(scale中有负值,老版本只有缩放完全相同才能合批)
  • 所有实例必须采用UniformScale或者NonUniformScale,不能混用
  • Lightmap对象无法合批
  • 必须引用相同的Lightmap
  • 材质Shader不能使用Multiple passes
  • Mesh实例不能接受实时阴影
  • 隐藏规则:(后续可能就改掉了)
    • 每个Batch最大300个Mesh
    • 最多32000个Mesh可以Batch

静态合批

介绍:

  • 静态合批是勾选Static,Unity在Build的时候,会自动下生成合并的网格,并将它以文件形式存储合并后的数据,这样在当场景被加载时,一次性提交整个合并模型的顶点数据,根据引擎的场景管理系统判断各个子模型的可见性,然后设置一次渲染状态,调用多次Draw call分别绘制每一个子模型。
  • 静态合批的要求比动态合批低,只需要在一个材质球,且在运行时不能移动,旋转或缩放即可。
  • 当顶点数超过32k后会生成另一个合批。
  • 缺点:包体大小和内存的增加
  • 注意:静态合批并不减少Draw call的数量(但是在编辑器时由于计算方法区别Draw call数量是会显示减少了的),但是由于我们预先把所有的子模型的顶点变换到了世界空间下,并且这些子模型共享材质,所以在多次Draw call调用之间并没有渲染状态的切换,渲染API会缓存绘制命令,起到了渲染优化的目的。另外,在运行时所有的顶点位置处理不再需要进行计算,节约了计算资源。

Unity自动实现:

在Unity中选中相同材质的物体,设置为static,将layer设置为static batching即可

Mesh合并的代码实现测试:

  • 将需要合并的物体统一放到一个空物体下
  • 为空物体添加MeshRender与MeshFitter
  • 编写脚本合并子物体的Mesh:
void Start () 
{
    MeshCombine();
}
void MeshCombine()
{
    //获取子物体的MeshFilter
    MeshFilter[] filters = GetComponentsInChildren<MeshFilter>();
    //创建用于组合Mesh的unityapi,是一个结构体
    CombineInstance[] combiners = new CombineInstance[filters.Length];
    //对每一个子物体进行操作
    for(int i = 0; i < filters.Length; i++)
    {
        //将mesh给到combiners
        //(mesh和sharedMesh的区别)https://blog.csdn.net/wodownload2/article/details/53693268
        combiners[i].mesh = filters[i].sharedMesh;
        //获取mesh坐在的Matrix4x4矩阵
        //(Unity中的Matrix4x4)https://zhuanlan.zhihu.com/p/146918827s
        combiners[i].transform = filters[i].transform.localToWorldMatrix;
    }
    //创建一个表示合并后的mesh对象
    Mesh finalMesh = new Mesh();
    //合并网格
    finalMesh.CombineMeshes(combiners);
    //将合并后的网格给到父物体的MeshFilter中
    GetComponent<MeshFilter>().sharedMesh = finalMesh;
}
  • 合并成功后的网格赋予空物体,之后该空物体即是合并后的统一物体(可以直接隐藏合并网格的子物体)

Unity Mesh网格合并:https://blog.csdn.net/qq_42139931/article/details/121232276

官方文档(CombineInstance):https://docs.unity3d.com/cn/current/ScriptReference/CombineInstance.html

官方文档(Mesh.CombineMeshes)https://docs.unity3d.com/cn/current/ScriptReference/Mesh.CombineMeshes.html

GPU实例化

介绍:

  • GPU实例化可以对网格一致的对象但具有不同的材质属性(比如颜色)的对象进行合批并产生可观的优化。
  • GPU Instancing 没有动态合批那样对网格数量的限制,也没有静态网格那样需要这么大的内存,它很好的弥补了这两者的缺陷,但也有存在着一些限制
  • 与动态和静态合批不同的是,GPU Instancing 并不通过对网格的合并操作来减少Drawcall,GPU Instancing 的处理过程是只提交一个模型网格让GPU绘制很多个地方,这些不同地方绘制的网格可以对缩放大小,旋转角度和坐标有不一样的操作,材质球虽然相同但材质球属性可以各自有各自的区别。

规则:

  • 要保证GPU实例化的网格一致
  • opengles3.0以上才支持,但不是所有opengles3.0都是支持的
  • 用于对多个对象(网格一样,材质一样,但是材质属性不一样,材质属性通过shader修改)进行合批,单个合批最大上限为511个对象
  • 在对应材质的AdvancedOptions的Enable GPU Instancing勾选

具体的shader编写可以参考下面的链接:

GPUInstance的使用:https://blog.csdn.net/gzg_restart/article/details/120687835

GPU Instancing教学,Unity降低DrawCall不再是梦想:https://www.bilibili.com/video/BV1UV411B79X

合并图集

介绍:

  • 通过将碎图打包成图集,可以降低内存;
  • 打成图集相同材质,可以通过静态合批降低drawcall

Unity 将Sprite打包进图集:https://blog.csdn.net/xinzhilinger/article/details/116043662

Unity3D打包图集与动态加载图集的两种方法:https://blog.csdn.net/qq_34406755/article/details/103699215

UGUI性能优化——图集:https://zhuanlan.zhihu.com/p/391885850

Unity3D图集讲解:https://www.bilibili.com/video/BV1wo4y1D7tW

大型游戏中UI优化的几个原则:https://zhuanlan.zhihu.com/p/198999167

合理划分合集:

  • 一个Common图集
  • 一个道具图集
  • 一个头像图集
  • 若干功能图集(登录、背包、技能、角色、商店等)
  • 散图(loading图之类的)

穿插问题:

  • 图集尽量减少穿插,不然会打断合批
  • 字体和UI尽量不要穿插,因为材质不一样会打断合批

动静分离

  • 动静分离就是说同一个界面下的UI,可活动的元素(血条,蓝条,伤害数字,CD)放在一个Canvas下,不可活动的元素(人物头像,商城,和平,抽奖等按钮)放在另一个Canvas下。
  • 虽然两个Canvas打断了合批,但是却减少了网格的重建时间,总体上是有优化的。
  • 为什么要避免网格重建那? 如果重建的网格过大,就会很卡。比多一个drawcall还卡。
  • 假如同一个Canvas里面有100个Image,Canvas将会把100个单独的Mesh合并成一个大的ShareMesh,用于渲染。
  • 如果刚好这100个Image都是使用了相同的图片或者是同一个图集里面的图片,那么由于使用的Mesh只有一个(ShareMesh),假如UI元素非常多,ShareMesh可能有2M大小(这是一个比较极端的例子,实际上一般的ShareMesh都是几百K,出现这么大的ShareMesh本身就需要注意了),材质球都是同一个(内置的默认材质球),贴图也是同一张,所以得到的结果就是,DrawCall只有一个。
  • 如果这个100个Image是不会动的,合并ShareMesh没有太大的问题,但是如果是上面的血条,蓝条,名字板,CD,文字聊天信息这些需要动的UI元素和其他UI元素合并同一个ShareMesh的,那么问题就来了,血条由长变短,顶点发生了改变,导致2M的整体ShareMesh也需要重新的生成顶点信息,并且整个ShareMesh重新渲染,这明显是得不偿失的。
  • 当然动态的UI还包括文字部分,其中Text是UGUI产生顶点数量的重灾区,一个字符产生4个顶点,如果再加上Shadow则相当于又把Text复制了一遍产生8个,Outline则会将Text复制4遍产生20个顶点。
  • 所以Shadow、Outline不但会产生额外的OverDraw外还会产生过多的顶点数,一定慎用,确实需要请选择用图片背景替代,其次选择相对较省的Shadow。

对象池ObjectPool

介绍:

对于一些需要不停实例化和销毁的物体,例如发射的子弹对于CPU的消耗十分的大,因此可以使用对象池技术来储存子弹资源,提前实例化足够的物体,要使用的时候直接从对象池中调用,不需要用时直接隐藏即可,减少了频繁的销毁和初始化。

实现步骤:

  • 生成一定数量的子弹在对象池中,使用list存储,并初始化为隐藏状态。
  • 开枪时从对象池中获取一个子弹对象,并将其设置为激活,位置初始化在枪口位置,使用协程3秒后自动隐藏对象。

代码演示:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Shoot : MonoBehaviour 
{
    public GameObject bulletPrefab;
    private BulletPool bulletPool;
    void Start () 
    {
        bulletPool = GetComponent<BulletPool>();
    }
  
    void Update () {
        //模拟开枪
        if (Input.GetMouseButtonDown(0))
        {
            GameObject go = bulletPool.GetBullet();
            go.transform.position = transform.position;
            go.GetComponent<Rigidbody>().velocity = transform.forward * 50;
            StartCoroutine(DestroyBullet(go));
        }
    }
    //删除子弹的协程
    IEnumerator DestroyBullet(GameObject go)
    {
        yield return new WaitForSeconds(3);
        go.SetActive(false);
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BulletPool : MonoBehaviour 
{
    public int poolCount = 30;
    public GameObject bulletPrefab;
    private List<GameObject> bulletList = new List<GameObject>();
    private void Start()
    {
        InitPool();
    }
    //初始化对象池
    void InitPool()
    {
        for(int i = 0; i < poolCount; i++)
        {
            GameObject go = GameObject.Instantiate(bulletPrefab);
            bulletList.Add(go);
            go.SetActive(false);
            go.transform.parent = this.transform;
        }
    }
    
    //开枪时调用,激活子弹
    public GameObject GetBullet()
    {
        foreach(GameObject go in bulletList)
        {
            if (go.activeInHierarchy == false)
            {
                go.SetActive(true);
                return go;
            }
        }
        return null;
    }
}

https://blog.csdn.net/FiveRicer/article/details/120770445

https://www.taikr.com/article/3930

移动设备优化要点

  • 尽量少的Drawcall
  • 尽量少的材质数量
  • 尽量小的纹理尺寸
  • 方形与POT纹理(压缩为PVR(IOS)、ETC(安卓))
  • Shader中使用尽量低的数据类型
  • 避免Alpha测试,性能消耗高