Unity游戏优化

Posted by LudoArt on December 13, 2021

Unity游戏优化

第一章 研究性能问题

1.1 Unity Profiler

Unity Profiler的细分视图主要包含了以下几个区域(每个区域对应了在做性能优化时需要注意的每个模块):

  • CPU使用情况
  • GPU使用情况
  • 渲染
  • 内存(垃圾回收)
  • 音频(音频需要大量潜在的磁盘访问和CPU处理)
  • physics3D和physics2D(物理系统)
  • 网络消息和网络操作
  • 视频(Unity的Videoplay)
  • UI(针对UGUI)
  • 全局光照

1.2 性能分析的最佳方法

  • 验证脚本是否出现
    • 方法:在Hierarchy窗口中输入:**t:**,可以查找以该脚本作为组件的GameObject,此外,还应再检查GameObject的激活状态。
  • 验证脚本次数
    • 若某个MonoBehaviour方法执行的次数比预期多或执行时间比预期长,则需要检查它出现的次数是否正确。
    • 方法:同上。
  • 验证事件的顺序
    • 注意在以下场景内,代码的执行顺序可能会与预期不符:
      • MonoBehaviour组件的回调,如Awake() Start() Update() 等;
      • 协程;
    • 方法:IDE中加断点逐步调试,或者使用Debug.Log()打印简单的日志语句。
  • 最小化正在进行的代码更改
    • 方法一:加标记或注释,以便后续查找与删除;
    • 方法二:加断点调试;
  • 最小化内部影响(来自于Unity内部的影响)
    • Unity编辑器的小怪癖和细微差别,会使调试某些类型的问题变得混乱,如:
      • 开始测试后,需回到Game窗口;
      • Game窗口不可见但处于活动状态,Unity不会向该视图渲染内容,因此不会触发依赖Game窗口渲染的事件;
      • 垂直同步(VSync)可能会使“CPU使用情况”区域中产生许多嘈杂的峰值,因为应用程序故意降低速度,以匹配显示器的帧率;
      • Unity的Debug.Log()和类似的方法,在CPU使用率和堆内存消耗方法非常昂贵,会导致发生垃圾回收,甚至丢失CPU循环;
  • 最小化外部影响(来自于Unity外部的影响)
    • 检查后台是否有进程消耗CPU周期、占用大量内存或硬盘活动。
  • 代码片段的针对性分析
    • 若上述方法还没有解决性能问题,就需要调查问题出现的特定帧,并快速确定是哪个位置的方法导致了问题,因此,我们有两种主要的方式来定位问题的具体位置:
      • 从脚本代码控制Profiler:
        • 主要是UnityEngine.Profiling.Profiler类的BeginSample()EndSample()方法,分别控制运行时激活和禁用分析功能;
      • 自定义定义和日志记录方法:
        • 可以使用System.Diagnostics命名空间下的StopWatch类,来记录时间信息;
        • 可以使用缓存日志的方式来记录日志信息,因为Unity的控制台窗口日志记录机制十分昂贵;

Tips:

  • 可以在Input.GetKeyDown()方法中进行测试,以防止其他干扰;
  • 使用string.Format()代替 “some string”+”some string” 的形式,以减少垃圾回收;
  • 如果一个很高的CPU使用峰值没有超过60FPS或30FPS的基准条,可以选择先忽略它,搜索别处的CPU性能问题;

1.3 关于分析的思考

性能分析的3种不同战略:

  1. 理解Profiler工具;
  2. 减少干扰;
  3. 关注问题;

第二章 脚本策略

2.1 使用最快的方法获取组件

GetComponent()方法有一些变体,它们的性能消耗不同(实际取决于当前使用的Unity版本,可以通过简单的测试验证消耗)

在Unity2017中,3种重载的性能消耗如下(均测试调用100万次):

  • GetComponent(string):6413.00ms
  • GetComponent<ComponentName>():89.00ms
  • GetComponent(typeof(ComponentName)):95.00ms

得出结论:推荐使用GetComponent<ComponentName>()方法。

2.2 移除空的回调定义

MonoBehaviour常用的4个回调:Awake() Start() Update() FixedUpdate()

第一次创建MonoBehaviour时调用Awake() ;之后不久调用Start() ;每次渲染管线呈现一个新图像时,调用Update() ;每次物理引擎更新之前调用FixedUpdate()

  • Update() 直接绑定在渲染帧率上;
  • FixedUpdate()随着时间的推移调用,频率更一致;

参考下面Unity文档,更准确了解Unity的不同回调函数的调用时机:

https://docs.unity3d.com/Manual/ExecutionOrder.html

MonoBehaviour在场景中第一次实例化时,Unity会将任何定义好的回调添加到一个函数指针列表中(即使函数体是空的,Unity也会添加)。在所有Update() 回调完成之前,渲染管线是不允许呈现新的一帧的。

性能问题的最常见来源是执行以下一个或多个操作,而误用Update() 回调:

  • 反复计算很少或从不改变的值;
  • 太多的组件计算一个可以共享的结果;
  • 执行工作的频率远超必要值;

2.3 缓存组件引用

在Unity中编写脚本时,反复计算一个值是常见的错误,特别是在使用GetComponent()方法时。好的方法是在初始化过程中获取引用,并保存它们。可以节省一些CPU开销,代价是少量的额外内存消耗。

2.4 共享计算输出

让多个对象共享某些计算的结果,可节省性能开销。如:在场景中找到对象、从文件中读取数据、解析数据(如XML或JSON)、在大列表或深层的信息字典中找到内容、为一组人工智能对象计算路径、复杂的数学轨迹、光线追踪等。

2.5 Update、Coroutines和InvokeRepeating

若在Update()方法中调用的一个函数的使用频率并没有那么高,可以通过加上计时器的方式,来降低函数的调用频率;

除此之外,也可以使用协程或InvokeRepeating的方式来实现相同的需求;

  • Update()中加计时器:
    • 仍要调用一个空的回调函数;
    • 需要一些额外的内存来存储浮点数据;
  • 使用协程:
    • 与标准函数调用相比,启动协程会带来额外的开销成本(大约三倍);
    • 需要一些内存存储当前状态;
    • 独立于MonoBehaviour,不管组件是否禁用,都将继续调用协程;
    • 协程会在它的GameObject变成不活动的时候自动停止,且不会自动重新启动;
    • WaitForSeconds不是精确的计时器,重复执行时可能会有少量的变化;
  • 使用InvokeRepeating()
    • InvokeRepeating()完全独立于MonoBehaviour和GameObject的状态;
    • 停止InvokeRepeating()调用的两种方法:
      • 调用CancelInvoke(),它停止由给定的MonoBehaviour发起的所有InvokeRepeating()回调;
      • 销毁关联的MonoBehaviour或它的父GameObject,禁用MonoBehaviour或GameObject都不会停止InvokeRepeating()

2.6 更快的GameObject空引用检查

与典型C#对象相比,GameObjectMonoBehaviour是特殊对象,因为它们在内存中有两个表示:一个表示存在于管理C#代码的相同系统管理的内存中,C#代码是用户编写的(托管代码),而另一个表示存在于另一个单独处理的内存空间中(本机代码)。数据可以在这两个内存空间之间移动,但是每次这种移动都会导致额外的CPU开销和可能的额外内存分配。这种效果通常称为跨越本机-托管的桥接。

对GameObject执行空引用检查会导致一些不必要的性能开销;

if (gameObject != null) // 对GameObject的简单空引用检查
{
    // 对gameObject做一些事情
}

// 另一种方法是System.Object.ReferenceEquals()
if (!System.Object.ReferenceEquals(gameObject, null))
{
    // 对gameObject做一些事情
}

System.Object.ReferenceEquals()生成功能想当的输出,其运行速度大约是原来的两倍(尽管它确实稍微混淆了代码的用途)。

2.7 避免从GameObject取出字符串属性

从GameObject中检索字符串属性是另一种意外跨越本机-托管桥接的微妙方式。

GameObject中受此行为影响的两个属性是tagname

下面的代码会在循环的每次迭代中导致额外的内存分配(自然会导致垃圾回收):

for (int i = 0; i < listOfObjects.Count; ++i) {
    if (listOfObjects[i].tag == "Player") {
        // do something
    }
}

GameObject提供了CompareTag()方法,可以完全避免本机-托管桥接:

for (int i = 0; i < listOfObjects.Count; ++i) {
    if (listOfObjects[i].CompareTag("Player")) {
        // do something
    }
}

但是name属性没有对应的方法,因此应该尽可能使用tag属性

2.8 使用合适的数据结构

如果希望遍历一组对象,最好使用列表;如果两个对象互相关联,且希望快速获取、插入或删除这些关联,最好使用字典。

2.9 避免运行时修改Transform的父节点

Unity尝试将所有共享相同父元素的Transform按顺序存储在预先分配的内存缓冲区中,并在Hierarchy窗口中根据父元素下面的深度进行排序。

  • 优点:允许在整个组中进行更快的迭代,对物理和动画等多个子系统特别有利;
  • 缺点:如果将一个GameObject的父对象重新指定为另一个对象,父对象必须将新的子对象放入预先分配的内存缓冲区中,并根据新的深度对所有Transform排序,如果父对象没有预先分配足够的空间来容纳新的子对象,就必须扩展缓冲区。

GameObject.Instantiate()实例化新的GameObject时,它的一个参数是父节点的Transform,默认值是null。可以将父节点的Transform参数提供给GameObject.Instantiate()调用,以跳过缓冲区分配这个步骤。

2.10 注意缓存Transform的变化

访问和修改Transform组件的positionrotationscale属性会导致大量的矩阵乘法计算,对象在Hierarchy窗口中的位置越深,确定最终结果需要进行的计算就越多。

使用localPositionlocalRotationlocalScale的相对成本较小,不需要任何额外的矩阵乘法。

不断更改Transform组件属性,也会向其他组件(如ColliderRigidbodyLightCamera)发送内部通知,已进行相应的更新。

在复杂的事件链中,在同一帧甚至同一函数中多次替换Transform组件的属性是很常见的,解决方法是将它们缓存在一个成员变量中,只在帧的末尾处进行修改。

2.11 避免在运行时使用Find()和SendMessage()方法

GameObject.Find()SendMessage()方法十分昂贵,可以采取以下几种方法来解决:

  • 将引用分配给预先存在的对象
  • 静态类
  • 单例组件
  • 全局信息传递系统

2.11.1 将引用分配给预先存在的对象

使用Unity内置的序列化系统:在MonoBehaviour中创建公共字段,当组件被选中时,Unity会自动序列化并在Inspertor窗口中显示该值。私有成员变量或受保护的成员变量也可以通过使用[SerializeField]属性将其显示给Inspertor窗口。

优点:

  • 简单

缺点:

  • 有风险:如留下空引用、预制体被更改(并不符合期望)等;

2.11.2 静态类

2.11.3 单例组件

PS:使用单例的方式类似于观察者(Observer)模式

2.11.4 全局信息传递系统

全局信息传递系统的特性:

  • 可以全局访问;
  • 任何对象都应该能够注册/注销为侦听器,来接收特定的消息类型(即Observer模式);
  • 当从其他地方广播给定的消息时,注册对象应该提供一个调用方法;
  • 系统应该在合理的时间范围内将消息发送给所有侦听器,但不要同时处理太多请求;

具体项目可参考:https://github.com/LudoArt/GlobalMessageSystem

2.12 禁用未使用的脚本和对象

  • 通过可见性禁用对象
    • 使用OnBecameVisible()OnBecameInvisible()回调,这些回调方式是在可渲染对象对于场景中的任何相机变得可见或不可见时调用的。
    • 由于可见性回调必须与渲染管线通信,因此GameObject必须附加一个可渲染的组件,例如MeshRendererSkinnedMeshRenderer
    • 必须确保希望接收可见性回调的组件也与可渲染的对象连接在同一个GameObject上;
  • 通过距离禁用对象
[SerializeField] GameObject _target; // 玩家的角色对象
[SerializeField] float _maxDistance; // 最大距离
[SerializeField] int _coroutineFrameDelay; // 调用协程的频率

void Start()
{
    StartCoroutine(DisableAtADistance());
}

IEnumerator DisableAtADistance()
{
    while(true)
    {
        float distSqrd = (transform.position - _target.transform.position).sqrMagnitude;
        if (distSqrd < _maxDistance * _maxDistance)
        {
            enabled = true;
        }
        else
        {
            enabled = false;
        }
        
        for(int i = 0; i < _coroutineFrameDelay; ++i)
        {
            yield return new WaitForEndOfFrame();
        }
    }
}

2.13 使用距离平方而不是距离

CPU擅长将浮点数相乘,但不擅长计算平方根。

使用距离平方代替距离:

  • 优点:节约CPU开销;
  • 缺点:损失部分精度;

2.14 最小化反序列化行为

Unity的序列化系统主要用于场景、预制件、ScriptableObjects和各种资产类型。当其中一种对象类型保存到磁盘时,就用YAML(Yet Another Markup Language,另一种标记语言)格式将其转换为文本文件,稍后可以将其反序列化为原始对象类型。

反序列化在调用Resources.Load()时发生,用于在名为Resources的文件夹中查找文件路径。

可以使用以下几种方法来最小化反序列化的成本

  • 减小序列化对象
    • 使序列化的对象尽可能小,或者将它们分割成更小的数据块,然后一块一块地组合在一起,这样它们就可以一次加载一块;(适合UI预制块分块)
  • 异步加载序列化对象
    • 通过Resources.LoadAsync()以异步方式加载预制块和其他序列化内容;
    • 可以通过上面方法调用返回的ResourceRequest对象的isDone属性,来判断是否完成序列化对象的加载;
  • 在内存中保存之前加载的序列化对象
    • 一旦序列化对象加载到内存中,它就会保留在内存中,如果以后需要,可以复制它(例如实例化更多的预制副本);
    • 可以通过显式地调用Resources.Unload()释放数据,这将释放内存空间;
    • 也可以选择不释放,保存在内存中,以减少以后从磁盘重新加载数据的需要(但不推荐);
  • 将公共数据移入ScriptableObject
    • 如果有许多不同的预制件,其中的组件包含许多倾向于共享数据的属性,如游戏设计值(命中率、力量、速度等),那么所有这些数据都将序列化到使用它们的每个预制件中;
    • 更好的办法是将这些公共数据序列化到ScriptableObject中,然后加载并使用它(类似于享元模式);

2.15 叠加、异步地加载场景

可以加载场景来替换当前场景,也可以添加内容到当前场景中,而不卸载前一个场景,这可以通过SceneManager.LoadScene()函数的LoadSceneMode参数进行切换。

同步加载是通过调用SceneManager.LoadScene()加载场景的典型方法,其中主线程将阻塞,直到给定的场景完成加载。

异步叠加式:使用SceneManager.LoadSceneAsync()并传递LoadSceneMode.Additive加载场景,可以让场景逐渐加载到背景中,而不会对用户体验造成明显的影响。

可以通过SceneManager.UnloadScene()SceneManager.UnloadSceneAsync()同步或异步卸载场景,使之从内存中清除出来。场景卸载会导致许多对象被销毁,可能会释放大量内存并触发垃圾回收。

2.16 创建自定义的Update()层

成本对比:调用1000次Update()函数的成本 > 调用1次Update(),但Update()函数中包含了1000个常规函数调用的成本(因为本机-托管桥接)。

解决方案:使用一个GodMonoBehaviour使用它自己的Update()回调来调用自定义组件使用的自定义更新样式系统。

public interface IUpdateable {
    void OnUpdate(float dt);
}

public class UpdateableComponent : MonoBehaviour, IUpdateable {
    public virtual void OnUpdate(float dt) {}
    
    void Start() {
        GameLogic.Instance.RegisterUpdateableObject(this); // 注册
        Initialize();
    }
    
    protected virtual void Initialize() {
        // 派生类应该覆盖此方法以实现初始化代码,而不是实现Start()方法
    }
    
    void OnDestroy() {
        if (GameLogic.Instance.IsAlive) {
            GameLogic.Instance.DeregisterUpdateableObject(this); // 注销
        }
    }
}

// 由GameLogic对象来记录和销毁UpdateableComponent类
public class GameLogic {
    List<IUpdateable> _updateableObjects = new List<IUpdateable>();
    
    // 注册
    public void RegisterUpdateableObject(IUpdateable obj) {
        if (!_updateableObjects.Contains(obj)) {
            _updateableObjects.Add(obj);
        }
    }
    
    // 注销
    public void DeregisterUpdateableObject(IUpdateable obj) {
        if (_updateableObjects.Contains(obj)) {
            _updateableObjects.Remove(obj);
        }
    }
    
    // 更新
    void Update() {
        float dt = Time.deltaTime;
        for (int i = 0; i < _updateableObjects.Count; ++i) {
            _updateableObjects[i].OnUpdate(dt);
        }
    }
}

优点:

  • 尽可能避免Native-Managed桥;
  • 甚至可以扩展为提供优先级系统(如果它检测到当前帧花费的时间太长,就可以跳过低优先级任务);
  • ……

第三章 批处理的优势

3.1 动态批处理

动态批处理的3个重要优势:

  • 批处理在运行时生成(批处理是动态产生的);
  • 批处理中包含的对象在不同的帧之间可能有所不同,这取决于哪些网格在当前主摄像机视图中是可见的(批处理的内容是动态的);
  • 甚至能在场景中运动的对象也可以批处理(对动态对象有效);

为给定网格执行动态批处理的要求:

  • 所有网格实例必须使用相同的材质引用;
  • 只有ParticleSystemMeshRenderer组件可以进行动态批处理。SkinnedMeshRenderer组件和所有其他可渲染的组件类型不能进行批处理;
  • 每个网格至多有300个顶点;
  • 着色器使用的顶点属性数不能大于900;
  • 所有网格实例要么使用等比缩放,要么使用非等比缩放,但不能两者混用;
  • 网格实例应该引用相同的光照纹理;
  • 材质的着色器不能依赖多个过程;
  • 网格实例不能接受实时投影;
  • 整个批处理中网格索引的总数有上限,这与所有的Graphics API和平台有关;

3.2 静态批处理

静态批处理只处理标记为Static的对象。

静态批处理的要求:

  • 网格必须标记为Static;
  • 每个被静态批处理的网格都需要额外的内存;
  • 合并到静态批处理中的顶点数量有上限(一般为32~64K个顶点);
  • 网格实例可以来自任何网格数据源,但它们必须使用相同的材质引用;

PS:

  • 静态批处理中,Draw Call减少了,但不能直接在Stats窗口中看到,要在运行时才能看到
  • 在运行时向场景中引入标记为Batching Static的对象,不能自动包含进静态批处理中

第四章 着手处理艺术资源

4.1 音频

4.1.1 导入音频文件

Project窗口中选中导入的音频文件时,Inspector窗口将显示多个导入设置。

4.1.2 加载音频文件

通过以下3种设置指定音频文件的加载方式:

  • Preload Audio Data:决定了音频数据是在场景初始化期间自动加载,还是在以后加载;
  • Load in Background:决定了加载音频时是阻塞主线程,还是在后台异步加载;
  • Load Type:决定了将什么类型的数据拉入内存,以及一次拉入多少数据;

Load Type的3种选择:

  • Decompress On Load:(常用方法)压缩磁盘上的文件以节省空间,并在首次加载时将其解压缩到内存中;
    • 优点:减少播放音频文件时所需的工作量;
    • 缺点:加载过程中的额外开销;
  • Compressed In Memory:加载音频时只是将其直接从磁盘复制到内存中,只有在播放音频文件时,才会在运行期间对其进行解压缩;
    • 优点:在播放音频文件时,牺牲运行时CPU;
    • 缺点:在音频文件保持休眠时,提高了场景加载速度,减少了运行时内存消耗;
  • Streaming:(缓冲)将在运行时加载、解码和播放文件。具体做法是逐步将文件推至一个小缓冲区,在缓冲区中一次只存在整个文件的一小部分数据;
    • 优点:保持休眠时,使用的内存量最小;
    • 缺点:运行时CPU使用的内存量最大;

4.1.3 编码格式与品质级别

Unity支持3种音频剪辑编码格式,在Inspector窗口中查看音频剪辑的属性时,由Compression Format选项决定哪种格式:

  • Compressed(真正的文本内容会随着平台的不同而不同):(常用方法)质量低于PCM,但明显优于ADPCM,代价是要使用额外的运行时CPU,可自定义压缩算法的结果质量级别;
  • PCM:无损的、未压缩的音频格式,以更大的文件大小换取更好的音频质量;
  • ADPCM:在大小和CPU消耗方面都比PCM高效得多,但压缩会产生相当大的噪声;

4.1.4 音频性能增强

  • 最小化活动音源数量
  • 为3D声音启用强制为单声道(Force to Mono)
  • 重新采样到低频
  • 考虑所有的压缩格式
  • 注意流媒体(Streaming)
  • 通过混音器组应用过滤效果以减少重复(Adudio Mix
  • 谨慎使用远程内容流
  • 考虑用于背景音乐的音频模块(Audio Module)文件

4.2 纹理文件

纹理性能增强:

  • 减小纹理文件的大小:如果每秒推送的总内存超过图形卡的总内存带宽,就会产生瓶颈;
  • 谨慎使用Mip Map:如果纹理一直都和主相机保持固定的距离,则永远不会使用Mip Map方案;
    • 通过开启Generate Mip Maps设置,Unity自动生成Mip Map
    • 将Scene窗口的Draw Mode设置切换为MipMaps,可以观察应用程序中某些时刻使用了哪个Mip Map级别
  • 从外部管理分辨率的降低:使用外部的专业软件,如PS等处理;
  • 调整 Anisotropic Filtering 级别Anisotropic Filtering 是一项在非常倾斜的角度观察纹理时提升纹理品质的特性。如果场景中的一些纹理肯定不会从倾斜的角度看到(如UI、粒子特效等),就可以安全地禁用Anisotropic Filtering
  • 考虑使用图集:合并纹理,减少Draw Call(类似动态批处理);
  • 调整非方形纹理的压缩率:尽量将纹理文件的大小设置为2的n次冥的正方形,减少内存带宽的消耗;
  • Sparse Texture:提供了一种运行时从磁盘传输纹理数据流的方式(https://docs.unity3d.com/Manual/SparseTextures.html)
  • 程序化材质:一种在运行时通过使用自定义数学公式混合小型高质量的纹理样本,通过程序化方式生成纹理的手段。目标是在初始化期间以额外的运行时内存和CUP处理为代价,极大地减少应用程序的磁盘占用(2018版Unity已搜不到相关内容);
  • 异步纹理上传:纹理导入选项中,禁用Read/Write Enalbe即可以使用Asynchronous Texture Uploading特性(https://docs.unity3d.com/2022.1/Documentation/Manual/LoadingTextureandMeshData.html);
    • 启用异步纹理上传的条件:
      • 禁用 Read/Write Enalbe
      • 纹理不在 Resources 文件夹内;
      • 不是通过 LoadImage(byte[] data) 加载的纹理;
      • 如果编译平台是安卓,需要启用 LZ4 compression
    • 优势:纹理会从磁盘异步上传到RAM中;且当GPU需要纹理数据时,传输发生在渲染线程,而不是主线程,最终减少了每帧准备渲染状态所花费的时间;
    • 异步纹理上传特性允许花费的时间上限和Unity为了推送要上传的纹理而使用的循环缓冲区总大小都是可以调整的:Edit Project Settings Quality Other -> Async Upload Time Slice 和 Async Upload Buffer Size;

4.3 网格和动画文件

网格和动画性能增强:

  • 减少多边形数量
  • 调整网格压缩:Unity为导入的网格文件提供了4种不同的网格压缩设置(通过将浮点数转换为固定值、降低顶点位置/法线方向的精度、简化顶点颜色信息等);开启 Optimize Mesh Data 将剔除该网格当前使用的材质所不需要的数据;
    • 优点:减少了磁盘占用;
    • 缺点:需要数据之前必须花费额外的时间解压缩数据;
  • 恰当使用Read-Write Enabled:若开启,则可以在运行时通过脚本修改网格,或由Unity自动对网格进行更改,为了实现更改的效果,Unity必须复制一份网格数据存在内存中;
  • 考虑烘焙动画:将每帧每个顶点的每个位置采样并硬编码到网格/动画文件中;
  • 合并网格:手动将多个网格合并成一个大网格,以减少Draw Call(特别是当网格对于动态批处理来说太大,并且不能与其他静态批处理组很好配合时,很适合手动合并网格)
    • 注意:如果网格的任何一个顶点在场景中可见,那么整个对象将作为一个整体进行渲染。
    • 手动合并网格,会生成一个全新的网格文件,意味着对原始网格的任务更改都不会反映到合并的网格中。

4.4 Asset Bundle 和 Resource

Resource 相比 Asset Bundle 有较多局限性:

  • Resource System 的可伸缩性不是很大;
  • Resource System 以 Nlog(N) 的方式从序列化文件中获取数据;
  • Resource System 使应用程序难以基于每个设备听不同的素材数据;
  • Resource System 提供自定义内容更新需要完全替换整个应用程序;

参考资料:

  1. https://learn.unity.com/tutorial/assets-resources-and-assetbundles?_ga=2.193998248.408881791.1641024275-485949218.1605194085#5c7f8528edbc2a002053b5a5
  2. https://blog.unity.com/technology/asset-bundles-vs-resources-a-memory-showdown
  3. https://blog.unity.com/technology/learn-to-save-memory-usage-by-improving-the-way-you-use-assetbundles

第五章 加速物理

5.1 物理引擎的内部工作情况

物理和时间

物理引擎通常是在时间按固定值前进的假设下运行的,每个迭代都成为时间步长,在Unity中成为Fixed Update Timestep,默认值为20毫秒。

允许的最大时间步长(Maximum Allowed Timestep):如果当前一批固定更新的处理时间太长,则它将停止并放弃进一步的处理,知道下一次渲染更新完成。

可以通过 Edit | Project Setting | Time | Maximum Allowed Timestep 访问。

静态碰撞器和动态碰撞器

  • 动态碰撞器:GameObject包含Collider组件和Rigidbody组件,会对外部的力(如重力)和与其他碰撞器的碰撞做出反应;
  • 静态碰撞器:GameObject只包含Collider组件,无法移动,对碰撞不做响应;

碰撞检测

共有三种类型:离散、连续和动态连续;

  • Discrete(离散检测):当物体这一帧还在前面,下一帧就到后面去了,就检测不到,不适用于高速运动的物体
  • Continuous(连续检测):防止对象穿过所有静态碰撞体
  • Continuous Dynamic(动态连续检测):防止对象穿过所有静态碰撞体以及设置为Continuous或Continuous Dynamic的刚体

碰撞矩阵

碰撞矩阵定义允许哪些对象与哪些其他对象发生碰撞。

当处理边界体积重叠和碰撞时,物理引擎将自动忽略不适合此矩阵的对象。

碰撞矩阵可以通过 Edit | Project Setting | (Physics / Physics2D) | Layer Collision Matrix 访问。

碰撞矩阵系统通过Unity的Layer系统工作。

Rigidbody激活和休眠状态

静止物体的内部状态将从活动状态变为休眠状态,当Rigidbody处于休眠状态时,在固定的更新过程中,几乎没有处理器时间来更新对象,直到它被外力或碰撞事件唤醒。

可以通过 Edit | Project Setting | Physics | Sleep Threshold 修改控制休眠状态的阈值。

还可以从Profiler窗口中的Physics Area中获取活动Rigidbody对象的综述

射线和对象投射

  • Physics.OverlapSphere():检查在空间中固定点的有限距离内获得目标列表。通常用于实现效果区域的游戏功能,如手榴弹;
  • Physics.SphereCast():在空间中向前投射整个对象。通常用于模拟宽激光束;
  • Physics.CapsuleCast():在空间中向前投射整个对象。

调试物理

物理错误通常分为两类:

  • 本来不应该碰撞的一对对象碰撞了:通常是用于碰撞矩阵的错误,射线投射中使用的Layer不正确,或者对象碰撞器的大小或形状错误;

  • 本来应该碰撞的一对对象没有碰撞:

    有3个大问题:

    • 确定哪个碰撞对象导致了问题;
    • 碰撞的条件;
    • 重现碰撞;

    可以通过Physics Debugger帮助调试。

5.2 物理性能优化

适当使用静态碰撞器

如果在运行时将新对象引入静态碰撞器,那么必须重新生成它,这可能会导致显著的CPU峰值。仅移动、旋转或缩放静态碰撞器也会触发此重新生成的过程。

如果碰撞器希望在不与其他物体发生物理碰撞的情况下移动,应该附加一个Rigidbody,使之成为动态碰撞器,并开启Kinematic标志。

最小化射线发射和边界体积检查

射线投射方法的消耗较大,特别是 CapsuleCast()SphereCast() 方法。应该避免在Update回调或协程中定期调用这些方法,只在脚本代码中的关键事件调用它们。

可以使用层遮罩来最小化每个射线投射的处理量,更好的方法是使用 RaycastAll() 的另一个重载版本,它接受LayerMask值作为参数。该参数为射线过滤碰撞,其方式与碰撞矩阵一样,仅对给顶层的对象进行测试。

如果在场景中使用持续的线、射线或区域效果,并且对象保持相对静止,那么使用简单的触发体积可能更好地模拟它们。

修改处理器迭代次数

当处理关节系统等复杂模拟的时候,需要使用多迭代的方法来计算精确的结果。

处理器允许尝试的最大迭代次数成为 Solver Iteration Count ,可在 Edit | Project Setting | Physics | Default Solver Iterations 下修改。在大多数情况下,六次迭代的默认值是完成可以接受的。

物理2D对处理器迭代次数的设置名为 Position Iterations。

优化布娃娃

  • 减少关节和碰撞器
  • 避免布娃娃间碰撞
  • 更换、禁用或移除不活跃的布娃娃

其他

  • 恰当使用触发体积
  • 优化碰撞矩阵
  • 首选离散碰撞检测
  • 修改固定更新频率
  • 调整允许的最大时间步长
  • 避免复杂的网格碰撞器
  • 避免复杂的物理组件
  • 确定何时使用物理,是否有必要使用物理

第六章 动态图形

6.1 管线渲染

image-20220228221923403

  • 前端是指渲染过程中GPU处理顶点数据的部分;
  • 后端是指管线渲染中处理片元的部分;

在后端,有两个指标往往是瓶颈的根源——填充率和内存带宽。

填充率

填充率指的是GPU绘制片元的速度,然而,这仅仅包含在给定的片元着色器中通过各种条件测试的片元。

填充率也会被其他高级渲染技术所消耗,例如阴影和后期效果处理需要提取同样的片元数据,在帧缓冲区中执行自己的处理。

由于渲染对象的顺序,我们总是会重绘一些相同的像素,这成为过度绘制,是衡量填充率是否有效使用的一个重要指标,过度绘制得越多,覆盖片元数据所浪费的填充率就越多。

可以通过Scene窗口的Overdraw Shading模式显示场景的过度绘制情况。

PS:所有的UnityUI对象通常都在透明队列中渲染,这也是过度绘制的主要来源。

内存带宽

只要从GPU VRAM的某个部位将纹理拉入更低级别的内存中,就会消耗内存带宽。这通常发生在对纹理采样时,其中片元着色器尝试选择匹配的纹理像素,以便在给定的位置绘制给定的片元。

光照和阴影

https://docs.unity3d.com/Manual/LightingOverview.html

  • 前向渲染:每个对象都通过同一个着色器进行多次渲染,渲染次数取决于光源的数量、距离和亮度;(https://docs.unity3d.com/Manual/RenderTech-ForwardRendering.html)
  • 延迟渲染:创建一个几何缓冲区(G-缓冲区),在该缓冲区中,场景在没有任何光照的情况下进行初始渲染。有了这些信息,延迟着色系统可以在一个过程中生成照明配置文件。缺点是无法独立管理抗锯齿、透明度和动画人物的阴影应用;(https://docs.unity3d.com/Manual/RenderTech-DeferredShading.html)
  • 顶点照明着色(传统):
  • 全局照明:

低级渲染API

Unity通过CommandBuffer类对外提供渲染API。这允许通过C#代码发出高级渲染命令,来控制管线渲染。(https://docs.unity3d.com/ScriptReference/Rendering.CommandBuffer.html)

如果需要更直接控制渲染,例如想直接给OpenGL等调用图形API,就需要创建一个本地插件,挂接到Unity的管线渲染,设置为特定渲染事件发生时的回调,类似于MonoBehaviours挂接到Unity主引擎的各种回调。(https://docs.unity3d.com/Manual/NativePluginInterface.html)

6.2 性能检测问题

性能分析器可将管线渲染中的瓶颈快速定位到所使用的两个设备:CPU和GPU。

为了执行准确的GPU受限的性能分析测试,应在Edit | Project Setting | Quality | Other | V Sync Count 中禁用Vertical Sync(垂直同步),否则测试数据将受到干扰。(CPU中执行的 Gfx.WaitForPresent 表示CPU正在等待垂直同步完成)

暴力测试

  • CPU:降低Draw Call来检查性能是否有突然的提升;
  • GPU:降低屏幕分辨率、降低纹理分辨率;
    • 如果降低屏幕分辨率,性能提高,说明主要的瓶颈在填充率上;
    • 如果降低纹理分辨率,性能提高,说明主要的瓶颈在内存带宽上;

6.3 渲染性能的增强

启用/禁用 GPU Skinning

Skinning是基于动画骨骼的当前位置变换网络顶点的过程。在CPU上工作的动画系统会转换对象的骨骼,用于确定其当前的姿势,但动画过程中的下一个重要步骤是围绕这些骨骼包裹网络顶点,以将网格放在最终的姿势中。为此,需要迭代每个顶点,并对连接到这些顶点的骨骼执行加权平均。

该顶点处理任务可以在CPU上执行,也可以在GPU的前端执行,具体取决于是否启用了GPU Skinning选项。( Edit | Project Settings | Player Settings | Other Settings | GPU Skinning

该功能启用后,会将Skinning活动推送到GPU中,但注意,CPU仍必须将数据传输到GPU,并在命令缓冲区上为任务生成指令。

降低几何复杂度

  • 让美术团队手动调整;
  • 简单地从场景中移除网格;
  • 实现网格的自动剔除特性,如LOD;

减少曲面部分

如果前端遇到了瓶颈,却在使用曲面细分技术,就应仔细检查曲面细分是否消耗了前端的大量资源。

应用 GPU实例化

GPU实例化利用对象具有相同渲染状态的特点,快速渲染同一网格的多个副本,因此只需要最少的Draw Call。

选中Enable Instancing复选框,可以在材质级别上应用GPU实例化,修改着色器代码,就可以引入变化。(对于渲染森林和岩石区域等场景很有用)

使用基于网格的LOD

LOD指的是根据对象与相机距离和/或对象在相机视图上占用的空间,动态地替换对象。

LOD最常见的实现是基于网格的LOD,当相机越来越远时,网格会采用细节更少的版本替代。

http://docs.unity3d.com/Manual/LevelOfDetail.html

基于网格的LOD还会消耗磁盘占用空间、RAM和CPU。

  • 剔除组(Culling group):允许创建自定义的LOD系统,作为动态替换某些游戏或渲染行为的方法。(http://docs.unity3d.com/Manual/CullingGroupAPI.html)

使用遮挡剔除

Unity的遮挡剔除系统的工作原理是将世界分割成一系列的小单元,并在场景中运行一个虚拟摄像机,根据对象的大小和位置,记录哪些单元对其他单元是不可见的(被遮挡)。

只有在StaticFlags下拉列表下正确标记为Occluder Static和/或Occludee Static的对象才能生成遮挡剔除数据。

其中Occluder Static是静态物体的一般设置,它们既能遮挡其他物体,也能被其他物体遮挡。

Occludee Static是一种特殊情况,例如透明对象总是需要利用它们后面的其他对象才能呈现出来,但如果有大的对象遮挡了它们,则需要隐藏它们本身。

启用遮挡剔除功能将消耗额外的磁盘空间、RAM和CPU时间。需要额外的磁盘空间来存储遮挡数据,需要额外的RAM来保存数据结结构,需要CPU处理资源来确定每帧中哪些对象需要被遮挡。

优化粒子系统

  • 使用粒子删除系统

    https://blog.unity.com/technology/unitytips-particlesystem-performance-culling(翻译:https://blog.csdn.net/culiao6493/article/details/108642302)

    文章的基本思想是根据不同的设置,所有粒子系统都是可预测的或不可预测的。

    • 当粒子系统是可预测的,且对主视图是不可见的时,可以自动删除整个粒子系统,以提升系统;

    • 当粒子系统是不可预测的时,即使它是隐藏的,也可能需要进行全帧渲染;

    当粒子系统的自动剔除功能被中断后,Unity会提供警告。

  • 避免粒子系统的递归调用

    ParticleSystem组件中的很多方法都是递归调用的。这些方法的调用需要遍历粒子系统的每个子节点,并调用子节点的GetComponent<ParticleSystem>()方法获得组件信息。

优化Unity UI

  • 使用更多画布

    画布组件的主要任务是管理在层次窗口中绘制UI元素的网格,并发出渲染这些元素所需的Draw Call。另一个重要作用是将网格合并进行批处理,以降低Draw Call数。

    然而当画布或其子对象发生变动时,这称为“画布污染”。当画布污染后,就需要为画布上的所有UI对象重新生成网格,才可发出Draw Call。

    PS:更改UI元素的颜色属性不会污染画布。

    解决画布污染方案:使用更多的画布。

    • 优点:可以将工作负载分离开,简化单个画布所需的任务;
    • 缺点:不同画布上的元素不会被批量组合在一起;因此应该尽量将具有相同材质的相似元素组合在同一画布中。

    PS2:确保将GraphicsRaycaster组件添加到与子画布相同的GameObject上,以便画布上的子元素可以相互交互。相反,如果画布上的子元素不可相互交互,就可以安全地从中删除任何GraphicsRaycaster组件,以减少性能消耗。

  • 在静态和动态画布中分离对象

    在尝试生成画布时,应采用基于元素更新的时间给元素分组的方式。元素可分为3组:静态偶尔动态连续动态

  • 为无交互的元素禁用 Raycaster Target

    UI元素具有Raycaster Target选项,允许该元素通过单击、触摸和其他用户行为进行交互。当以上任何一个动作发生时,GraphicsRaycaster组件将执行像素到边界框检查,以确定与之交互的是哪个元素,这是一个简单的迭代for循环。

    对非交互元素禁用此选项,就减少了GraphicsRaycaster需要迭代的元素数量,提高了性能。

  • 通过禁用父画布组件来隐藏UI元素

    UI使用单独的布局系统来处理某些元素类型的重新生成工作,其操作方式类似于污染画布。

    UIImage、UIText和LayoutGroup都是属于这个系统的组件示例。很多操作可能导致布局系统被污染,其中最明显的就是启用和禁用这些元素。

    解决方案:禁用其子节点的画布组件,就可以避免布局系统的这种昂贵的重新调用。

    缺点:禁用画布组件,只会停止UI的渲染和交互,各种更新调用会继续正常执行(如Update()、Coroutine()等方法)。

  • 避免Animator组件

    每一帧,Animator都会改变UI元素的属性,导致布局被污染,重新生成许多内部UI信息。

    应该完全避免使用Animator,而使用自己的动画内插方法或使用可实现此类操作的程序。

  • 为World Space画布显式定义Event Camera

    画布可用于2D和3D中的UI交互。每次进行UI交互时,画布组件都会检查其eventCamera属性以确定要使用的相机,默认情况下,2D画布会设置其为MainCamera,但3D画布会设置为null。

    每次需要Event Camera时,都会通过调用FindObjectWithTag()方法来找到MainCamera,应将该属性手动设置为MainCamera以节约性能。

  • 优化ScrollRect

    • 确保使用RectMask2D

      应使用RectMask2D组件来裁剪和剔除不可见的子对象。此组件创建了一个空间区域,如果其中的任何子UI元素超出了RectMask2D组件的边界,就会被剔除。

    • 在ScrollRect中禁用Pixel Perfect

      Pixel Perfect是画布组件上的一个设置,它强制其子UI元素与屏幕上的像素对齐。但对于动画和快速移动的物体,由于涉及运行,Pixel Perfect没多大意义,禁用Pixel Perfect属性是一种节省大量成本的好方法。

    • 手动停用ScrollRect活动

      即使移动速度是每帧只移动像素的一小部分,画布也需要重新生成整个ScrollRect元素。一旦使用ScrollRect.velocity和ScrollRect.StopMovement()方法检测到帧的移动速度低于某个阈值,就可以手动冻结它的运动。这有助于大大降低重新生成的频率。

  • 使用空的UIText元素进行全屏交互

    大多数UI的常用实现是激活一个很大、透明的可交互元素来覆盖整个屏幕,并强制玩家必须处理弹出窗口才能进入下一步,但仍允许玩家看到元素背后发生的事情。

    解决方案:使用一个没有定义字体或文本的UIText元素来代替UIImage元素,这将创建一个不需要生成任何可渲染信息的元素,只处理边界框的交互检查。

  • 查看UnityUI源代码

    https://blog.csdn.net/qq_28820675/article/details/105619250

    PS:源码仓库可能已经被屏蔽了

  • 查看文档

    https://learn.unity.com/tutorial/optimizing-unity-ui#

着色器优化

片元着色器是填充率和内存带宽的主要消耗者。消耗成本取决于它们的复杂度:纹理采样的数量、使用的数学函数量以及其他因素。

  • 考虑使用针对移动平台的着色器

  • 使用小的数据类型

    • GPU使用更小的数据类型来计算比使用更大的数据类型往往更快。
    • 颜色值是降低精度的很好选择,因为通常可以使用低精度的颜色值而不会有明显的着色损失。
    • 然而对于图形计算来说,降低精度的影响是非常不可预测的。
  • 在重排时避免修改精度

    • 重排是一种着色器编程技术,它将组建按照所需的顺序列出并复制到新的结构中,从现有向量中创建一个新的向量(一组数值)。
    • 可以使用xyzw和rgba表示法一次引用相同的组件。不管是代表颜色还是向量,它们只是为了让着色器代码容易阅读。
    • 但在着色器中将一种精度类型转换为另一种精度类型是一项很耗时的操作,在重排时转换精度类型会更加困难。
  • 使用GPU优化的辅助函数

    • 自定义代码的编译不太可能像CG库的内置辅助函数和Unity CG包含文件提供的其他辅助函数那样有效
    • CG标准库函数列表:http://developer.download.nvidia.cn/cg/index_stdlib.html
    • UnityCG:https://docs.unity3d.com/Manual/SL-BuiltinIncludes.html
  • 禁用不需要的特性

  • 删除不必要的输入数据

  • 只公开所需的变量

  • 减少数学计算的复杂度

  • 减少纹理采样

  • 避免条件语句

  • 减少数据依赖

    # 数据依赖
    float sum = input.color1.r;
    sum = sum + input.color1.g;
    sum = sum + input.color1.b;
    sum = sum + input.color1.a;
      
    # 非数据依赖
    float sum1, sum2, sum3, sum4;
    sum1 = input.color1.r;
    sum2 = input.color1.g;
    sum3 = input.color1.b;
    sum4 = input.color1.a;
    float sum = sum1 + sum2 + sum3 + sum4;
    
  • 表面着色器

  • 使用基于着色器的LOD

    https://docs.unity3d.com/Manual/SL-ShaderLOD.html

使用更少的纹理数据

Mip Maps是减少在VRAM和纹理缓存之间来回推送的纹理数据量的一种好方法。

测试不同的GPU纹理压缩格式

https://docs.unity3d.com/Manual/class-TextureImporterOverride.html

最小化纹理交换

减少纹理容量的方法:

  • 直接降低纹理分辨率,从而降低纹理质量(不推荐);

  • 采用不同的材质和着色器属性在不同的网格上重复使用纹理(需要不同的渲染状态,不会节省Draw Call,但可以减少内存带宽的消耗);

    PS:超级马里奥中云和灌木丛看起来很像,但颜色不同。这正是采用了相同的原理。

  • 将纹理组合到图集中,以减少纹理交换的次数;

VRAM限制

大多数从CPU到GPU的纹理传输都发生在初始化期间,但也可能发生在当前视图第一次需要某个不存在的纹理时。

这个过程通常是异步的,并使用一个空白纹理,直到完全的纹理准备好渲染为止。因此应避免在运行时过于频繁地引入新纹理。

  • 用隐藏的GameObject预加载纹理

    在异步纹理加载过程中使用的空白纹理可能会影响游戏质量。我们想要一种方法来控制和强制纹理从磁盘加载到内存,然后在实际需要之前加载到VRAM。

    解决方法:

    • 创建一个使用纹理的隐藏GameObject,并将其放在场景中一条路径的某个位置,玩家将沿着这条路径到达真正需要它的地方。一旦玩家看到该对象,就将纹理数据从内存复制到VRAM中,进行管线渲染;

    • 也可以通过脚本代码更改材质的texture属性,来控制此类行为:

      GetComponent<Render>().material.texture = textureToPreload;

  • 避免纹理抖动

    在极少数情况下,如果将过多的纹理数据加载到VRAM中而所需的纹理又不存在,则GPU需要从内存请求纹理数据,并覆盖一个或多个现有纹理,为其留出空间。这将带来一种风险,即刚从VRAM中刷新的纹理需要在同一帧内再次取出,将导致严重的内存冲突。

    必须确保任何给定时刻使用的纹理总量都低于目标硬件的可用VRAM。

照明优化

  • 谨慎使用使用阴影

    Soft Shadows所需的代价最大,Hard Shadows所需的代价最小,No Shadows不需要产生代价。

    Shadows Distance是运行时阴影渲染的全局乘数。在离相机很远的地方渲染阴影几乎没有什么意义。

    较高的Shadows Resolution和Shadows Cascades值将增加内存带宽和填充率的消耗。

  • 使用剔除遮罩

    灯光组件的Culling Mask属性是基于层的遮罩,可用于限制受给定灯光影响的对象。

  • 使用烘焙的光照纹理

    在场景中烘焙光照和阴影对处理器的计算强度要低很多,缺点是增加了应用程序的磁盘占用、内存消耗和内存带宽溢用的可能性。

优化移动设备的渲染性能

  • 避免Alpha测试

  • 最小化Draw Call

  • 最小化材质数量

  • 最小化纹理大小

  • 确保纹理是方形且大小为2的幂次方

  • 在着色器中尽可能使用最低的精度格式

    移动GPU对着色器中的精确格式特别敏感,因此我们应使用最小的精度格式,例如half,且应该完全避免精确格式的转换。

第七章 虚拟速度和增强加速度

暂时不看。

第八章 掌握内存管理

8.1 Mono平台

托管语语言,其特点是托管代码,指必须在公共语言运行时(Common Language Runtime, CLR)运行的源代码,和通过目标操作系统编译与运行的代码不同。往往表示使用任何依赖于自己运行时环境的语言或代码,可能包括自动垃圾回收,也可能不包括。

托管语言的运行时性能消耗通常比对应的本地代码更大,主要问题还是自动内存管理。

  • 内存域

    • 托管域:该域是Mono平台工作的地方,我们编写的任何C#代码都会很明确与此域交互,内存空间自动被垃圾回收管理;
    • 本地域:该域关心内存空间的分配,如为各种子系统(如纹理、音频文件和网格等)分配资源数据和内存空间,这也是大多数内建Unity类(如Transform和Rigidbody组件)保存数据的地方;
    • 外部库:例如DirectX和OpenGL库,也包括项目中包含的很多自定义库和插件;
  • 栈和堆

    运行时的内存空间分为两种类型:栈和堆

      • 内存中预留的特殊空间,专门用于存储小的、短期的数据值,这些值一旦超出作用域就会自动释放;
      • 当对当前函数完成调用栈的处理时,它跳回调用栈中之前的调用点,并从之前离开的位置继续执行剩余内容;
      • 之前内存分配的开始位置总是已知的,新的内存分配只会覆盖旧数据,因此栈相对快速、高效;
      • 堆表示所有其他的内存空间,并用于大多数内存分配;
      • 在物理上,栈和堆没有什么不同,它们都只是内存空间,包含内存于RAM中的数据字节;
      • 在本地代码中,例如用C++编写的语言,这些内存分配通过手动处理,包括分配与释放操作;
      • 在托管语言中,内存释放通过垃圾回收器自动处理;

垃圾回收

垃圾收回器只会在需要的时候回收内存。

当请求使用新的内存空间时,先检查是否有足够的空闲空间,否则扫描内存并清除不使用的内存分配,最后才是拓展当前堆空间。

GC在内存中维护所有对象的列表

应用程序维护一个独立的列表,其中仅包含全部对象中的一部分

可以安全回收的对象列表是GC的列表和程序列表之间的区别

Unity使用的Mono版本中的GC是一种追踪式的GC,它使用标记和清除策略。该算法分为两个阶段:

阶段一:每个分配的对象通过一个额外的数据位追踪,该数据位标识对象是否被标记。这些标记设置为false,标识它尚未被标记。当收集过程开始时,所有对程序可访问的对象(直接引用或者间接引用)会被标记为true,剩下的没有被标记的(即标记为false的)是可以被GC回收的对象。

阶段二:迭代对象,并基于它的标记状态决定是否应该回收。在该阶段,所有标记的对象都被跳过,但在下次垃圾回收扫描之前会将它们设置回false。

内存碎片

当以交替的顺序分配和释放不同大小的对象时,以及当释放大量小对象,随后分配大量大对象时,就有可能出现内存碎片。

内存碎片导致两个问题:首先,显著减少新对象的总可用内存空间,通常导致GC拓展堆,以便为新的分配腾出空间;其次,使新的分配花费的处理时间更长,因为需要花费额外的时间查找足以容纳对象的新内存空间。

运行时的垃圾回收

当游戏请求新的内存分配时,CPU在完成分配之前需要花费CPU周期完成下面的任务:

  • 验证是否有足够的连续空间可用于分配新对象
  • 如果没有,则迭代所有已知的直接和间接引用,标记它们是否可达
  • 再次迭代所有引用,标识未标记的对象用于回收
  • 迭代所有标识对象,以检查回收一些对象是否能为新对象创建足够大的连续空间
  • 如果还是不行,则从操作系统请求新的内存块,以便拓展堆
  • 在新分配的块前面分配新对象,并返回给调用者

多线程的垃圾回收

GC运行在两个独立线程上:主线程Finalizer Thread。当调用GC时,它运行在主线程上,并标志堆内存块为后续回收。这不会立刻发生。可能会延迟几秒后,由Mono控制的Finalizer Thread在内存最终释放并可用于重新分配。

可以通过Profiler窗口中Memory Area 的Total Allocated 块观察此行为(绿线)。

8.2 代码编译

当修改了C#代码,并回到Unity编辑器时,代码会自动编译,此时代码转换为通用中间语言(Common Intermediate Language, CIL),它是本地代码之上的一种抽象。

在运行时,中间代码通过Mono虚拟机运行,Mono虚拟机是.NET公共语言运行时(Common Language Runtime, CLR)的一个实现。

在CLR中,中间CIL代码实际上根据需要编译为本地代码。这种及时的本地编译可以通过AOT(Ahead-Of-Time)或JIT(Just-In-Time)编译器完成,选择哪一个取决于目标平台。两种编译器类型主要的区别在于代码编译的时间。

AOT编译是代码编译的典型行为,它发生于构建流程之前,在一些情况下则在程序初始化之前。代码都已经完成提前编译,没有后续运行时由于动态编译产生的消耗。

JIT编译在运行时的独立线程中动态执行,且在指令执行之前。通常,该动态编译导致代码在首次调用时,运行得稍微慢一点,因为代码必须在执行之前完成编译。

限制列表:https://docs.unity3d.com/Manual/ScriptingRestrictions.html

IL2CPP

IL2CPP是一个脚本后端,用于将Mono的CIL输出直接转换为本地C++代码。由于应用程序现在运行本地代码,因此这将带来性能提升。

IL2CPP自动在iOS和WebGL项目中启用。对于其他支持的平台,IL2CPP可以通过Edit | Project Setting | Player | Configure | Scripting Backend开启。

当前支持IL2CPP的平台列表:https://docs.unity3d.com/Manual/IL2CPP.html

8.3 分析内存

分析内存消耗

通过Profiler窗口的Memory Area观察已经分配了多少内存,以及该内存域预留了多少内存。(Unity标签的值为本地内存,Mono标签的值为托管内存)

本地内存分配显示在标记为Unity的值中,甚至可以使用Detailed Mode和采样当前帧,获取更多详细信息。

也可以使用Profiler.GetRuntimeMemorySize()方法获取特定对象的本地内存分配。也可以在运行时分别使用Profiler.GetMonoUsedSize()Profiler.GetMonoHeapSize()方法确定当前使用的和预留的堆空间。

分析内存效率

可以用于度量内存管理健康度的最佳指标是简单观察GC的行为。可以同时使用Profiler窗口的CPU Usage Area(GarbageCollector复选框)和Memory Area(GC Allocated复选框)以观察GC的工作量和执行时间。

8.4 内存管理性能增强

垃圾回收策略

最小化垃圾回收问题的一种策略是在合适的时间手动触发垃圾回收。

触发回收的好机会可以是加载场景时,当游戏暂停时,在打开菜单界面后的瞬间等等,甚至可以在运行时使用Profiler.GetMonoUsedSize()Profiler.GetMonoHeapSize()方法决定最近是否需要调用垃圾回收。

可以引发一些指定对象的释放。如果是Unity对象包装器之一,那么终结器(finalizer)将在本地域中首次调用Dispose()方法,此时本地域和托管域里消耗的内存都将被释放。如果Mono包装器实现了IDisposable接口类,那么可以真正控制该行为,并强制内存立刻释放。

其他所有资源对象提供某种类型的卸载方法以清除任何未使用的资源数据,例如Resources.UnloadUnusedAssets()

手动JIT编译

如果JIT编译导致运行时性能下降,请注意实际上有可能在任何时刻通过反射强制进行方法的JIT编译。

可以使用反射手动强制JIT编译一个方法,以获得函数指针:

// 针对Public方法
var method = typeof(MyComponent).GetMethod("MethodName");
if (method != null)
{
    method.MethodHandle.GetFunctionPointer();
    Debug.Log("JIT compilation complete!");
}

// 针对Private或Protected方法
using System.Reflection;
var method = typeof(MyComponent).GetMethod("MethodName", BindingFlags.NonPublic | BindingFlags.Instance);

使用反射通常是一个非常昂贵的过程,应该避免在运行时,或者仅在初始化或其他加载时间使用。

.NET类库中强制JIT编译的官方方法是RuntimeHelpers.PrepareMethod()

值类型和引用类型

引用类型通常在堆上分配,而值类型可以分配在栈或堆上。但一旦值类型包括在引用类型中,如类或数组,那么也必须分配在堆上,与包含它的引用类型绑定在一起。

值类型必须有一个值且不能为null。如果栈分配的类型被赋予引用类型,那么数据会被简单地复制。

  • 结构体是值类型
  • 数组是引用类型
  • 字符串是不可变的引用类型(当改变字符串时,实际上在堆上分配了一个全新的字符串以替换它)

字符串连接

  • StringBuilder

    • 如果大致知道字符串的最终大小,那么可以提前分配一个适当的缓冲区,以减少不必要的内存分配

    • StringBuilder sb = new StringBuilder(100);
      sb.Append("someword");
      string result = sb.ToString();
      
  • 字符串格式化

    • 如果不知道结果字符串的最终大小,可以使用字符串类不同格式化方式的一种。
    • string.Format()
    • string.Join()
    • string.Concat()

装箱

每当值类型以处理对象的方式隐式地处理时,CLR会自动创建一个临时对象来存储或装箱内部的值,以便将其视为典型的引用类型对象,这将导致堆分配,以创建包含的容器。

可以通过装箱基本类型将其当成对象,转换它们的类型,随后将它们拆箱回不同的类型,但每次这么做将导致堆内存分配。

数据布局的重要性

本质上,我们希望将大量引用类型和大量值类型分开。如果值类型(例如结构体)内有一个引用类型,那么GC将关注整个对象,以及它所有的成员数据、间接引用的对象。

UnityAPI中的数组

UnityAPI中有很多指令会导致堆内存分配。

每次调用Unity返回数组的API方法时,将导致分配该数据的全新版本。这些方法应该尽可能避免调用,或者缓存其调用结果,避免比实际所需更频繁的内存分配。

Unity有一些其他的API调用,需要给方法提供一个数组,接着将需要的数据写入数组。优点是可以避免重复分配大型数组,缺点是数组需要足够大,以容纳所有对象。

对字典键使用InstanceID

字典用于映射两个不同对象之间的关联,如果将MonoBehaviour或ScriptableObject引用作为字典的键,每次访问字典元素的时候,需要调用一些从UnityEngine.Object中继承的方法,这些对象类型都是从该类中继承,这使得元素的比较和映射的获取相对较慢。

这可以通过使用Object.GetInstanceID()改进,它返回一个整数,用于表示该对象的唯一标识值,在整个程序的生命周期中,该值不会发生改变。

foreach循环

很多foreach循环最终会在调用期间导致不必要的堆内存分配,因为它们在堆上分配了一个Enumerator类的对象,而不是在栈上分配结构体。这取决于集合的GetEnumerator()方法实现。

成本可以忽略不计,因为堆分配成本不会随着迭代次数的增加而增加,只分配了一个Enumerator对象,并反复使用。(除非在每次更新中都调用foreach循环,这十分危险)

协程

启动协程消耗少量内存,但注意在方法调用yield时不会再有后续消耗。

临时工作缓冲区

如果习惯于为某个任务使用大型临时工作缓冲区,就应该寻找重用它们的机会。应该将这些功能从针对特定情况的类中提取到包含大工作区的通用类上,以便多个类重用它。

预制池:https://github.com/LudoArt/PoolingStstem

第九章 提示与技巧

主要是一些使用小技巧,如快捷键、自定义编辑器等,参考文档和网上的资料等。

https://blog.csdn.net/qq_34552886/article/details/69775013

https://docs.unity3d.com/ScriptReference/EditorWindow.html