Sine.

Unity游戏优化读书笔记

整理 Unity 游戏性能优化读书笔记,覆盖脚本、Transform、资源、纹理、网格、AssetBundle 和物理系统等常见性能问题。

基本的脚本优化

研究性能问题

脚本策略

使用最快的方法获取组件

GetComponent<T>()

移除空的回调定义

​ Update等生命周期回调,在不使用时应删除

​ 空的Star()定义会导致对象的初始化在无形中变慢,当数量足够多时,会影响场景的初始化时间,其他的生命周期回调也会有一样的问题.

​ 下面的正则表达式可搜索出代码的空Update()定义

void\s*Update\s*?\(\s*?\)\s*?\n*?\{\n*?\s*?\}

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

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

缓存组件引用

private HealthComponent _healthComponent;
private Animator _anim;

void Awake() {
    _anim = GetComponent<Animator>();
}

void TakeDamage() {
    if (_healthComponent.health < 0) {
        _anim.SetTrigger("death");
    }
}

共享计算输出

​ 让多个对象共享某些计算的结果,可节省性能开销.当然,只有这些计算都生成相同的结果才有效.这种情形通常很多容易被发现,但是重构起来很困难,因此利用这种情况将非常依赖实现方案.

​ 这种情况包括在场景中找到对象,从文件中读取数据,解析数据(如XML或JSON),在大列表或深层的信息字典中找到内容,为一组人工智能对象计算路径,复杂的数学轨迹计算,光线追踪等.

​ 如果每次执行一个消耗较大的操作时,从多个位置调用它总是得到相同的输出,那么重构就是明智的,这样就算一次结果,然后将结果分发给需要它的每个对象,以最小化重新计算的值。最大的成本通常只是牺牲了一点代码的简洁性,尽管传递值可能会造成一些额外的开销。

请注意,通常很容易养成在基类中隐藏大型复杂函数的习惯,然后定义使用该函数的派生类,完全忘记了该函数的开销,因为开发人员很少再次查看该代码。最好使用 Unity Profiler 来指出这个消耗较大的函数可能调用了多少次,像往常一样,不要预先优化那些函数,除非已经证明这是一个性能问题。无论它的消耗有多大,只要它不超出性能限制 (如帧率和内存消耗),它就不是真正的性能问题。

降低不必要的调用频率

使用计时器减少调用频率

private float _aiProcessDelay = 0.2f;
private float _timer = 0.0f;

void Update() {
    _timer += Time.deltaTime;
    if (_timer > _aiProcessDelay) {
        ProcessAI();
        _timer -= _aiProcessDelay;
    }
}

使用协程减少调用频率

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

IEnumerator ProcessAICoroutine() {
    while (true) {
        ProcessAI();
        yield return new WaitForSeconds(_aiProcessDelay);
    }
}

​ 注意事项

  • 协程会带来额外的开销(大约是标准函数调用的3倍)
  • 协程会在包含它的GameObject变成不活动的那一刻停止,如果GameObject再次设置为活动的,协程不会自动重新启动.

使用InvokeRepeating()减少调用频率

void Start()
{
    InvokeRepeating("ProcessAI", 0f, _aiProcessDelay);
}

​ 注意事项

参数定义:

  • 第一个参数:待执行的方法名 "ProcessAI"(需为字符串,确保方法名一致)
  • 第二个参数:首次执行延迟 0f(立即开始)
  • 第三个参数:重复执行间隔 _aiProcessDelay(即 AI 逻辑的执行频率)

取消执行:需在对象销毁或禁用时调用 CancelInvoke("ProcessAI"),防止逻辑异常,通常可放在 OnDestroy()OnDisable() 中。

灵活性限制:运行时无法直接修改重复间隔,需先取消调用再重新调用 InvokeRepeating;相比协程和 Update 写法,动态调整能力较弱,但会比协程稍快一些(2.6ms比2.9ms)

更快的GameObject空引用检查

​ 事实证明,对 GameObject 执行空引用检查会导致一些不必要的性能开销。

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

​ 如果发生这种情况,就可能会为对象的数据生成额外的内存分配,以便桥接复制,这需要垃圾收集器最终执行一些内存自动清理操作。目前只需要知道有许多微妙的方法会意外地触发这种额外的开销,对 GameObject 的简单空引用检查就是其中之一。

使用 System.Object.ReferenceEquals()检查空引用

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

//常用的
if (gameObject!=null) {
    // do stuff with gameObject
}
//使用 System.Object.ReferenceEquals()
if (!System.Object.ReferenceEquals(gameObject, null)) {
    // do stuff with gameObject
}

为了兼容性能与可读性,可以封装一个辅助方法:

public static class UnityUtils {
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool IsNotNull(this UnityEngine.Object obj) {
        return !System.Object.ReferenceEquals(obj, null);
    }
}

// 使用方式
if (gameObject.IsNotNull()) {
    // do stuff
}

避免从GameObject中检查字符串属性

​ 通常,从对象中检索字符串属性与检索 C# 中的任何其他引用类型属性是相同的,这种检索应该不增加内存成本。然而,从 GameObject 中检索字符串属性是另一种意外跨越本机 - 托管桥接的微妙方式。

GameObject 中受此行为影响的两个属性是 tagname。因此,在游戏过程中使用这两种属性是不明智的,应该只在性能无关紧要的地方使用它们,比如编辑器脚本。然而,tag 系统通常用于对象的运行时标识,这对于某些团队来说是一个重要问题。

​ 例如,下面的代码会在循环的每次迭代中产生额外的内存分配:

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

​ 幸运的是,tag 属性最常用于比较,而 GameObject 提供了 CompareTag() 方法,这是比较 tag 属性的另一种方法,它完全避免了本机 - 托管的桥接。

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

使用合适的数据结构

C# 在 System.Collections 名称空间中提供了许多不同的数据结构,不应该反复使用相同的名称空间。软件开发中一个常见的性能问题是简单地为了便利而使用不适当的数据结构来解决问题。最常用的两种数据结构是列表 (List<T>) 和字典 (Dictionary<K,V>)。

如果希望遍历一组对象,最好使用列表,因为它实际上是一个动态数组,对象和 / 或引用在内存中彼此相邻,因此迭代导致的缓存丢失最小。如果两个对象相互关联,且希望快速获取、插入或删除这些关联,最好使用字典。例如,可以将一个关卡编号与特定的场景文件相关联,或者将一个代表角色不同身体部分的 enum 与这些身体部分的 Collider 组件相关联。

然而,数据结构通常需要同时处理两种情况:快速找出哪个对象映射到另一个对象,同时还能遍历组。通常,该系统的开发人员使用字典,然后对其进行迭代。然而,与遍历列表相比,这个过程非常慢,因为它必须检查字典中每个可能的散列,才能对其进行完全遍历。

在这些情况下,通常最好在列表和字典中存储数据,以便更好地支持这种行为。

  • List 遍历优势:

    由于元素在内存中连续存放,CPU 缓存能一次性加载批量数据(

    缓存局部性

    ),迭代速度极快,缓存丢失(Cache Miss)最小。

  • Dictionary 遍历劣势:

    字典元素基于哈希表散列存储,内存地址不连续。遍历需计算哈希、检查所有可能的散列桶,无法利用 CPU 缓存,且每次枚举都会产生枚举器实例,可能触发额外内存分配。

当业务同时需要 “快速查找”“高效遍历”时,最佳实践是**双数据结构维护 **:

  • 字典 (Dictionary):用于快速通过 Key 定位 Value(满足查找需求)。
  • 列表 (List):用于有序、批量遍历 Value(满足遍历需求)。
  • 同步机制:在数据增删改时,同步更新字典和列表,保证两者数据一致。

代码示例:混合数据结构实现

using System.Collections.Generic;
using UnityEngine;

public class ObjectManager : MonoBehaviour
{
    // 1. 字典:用于快速通过ID查找对象
    private Dictionary<int, GameObject> _objectDict = new Dictionary<int, GameObject>();
    
    // 2. 列表:用于高效遍历所有对象
    private List<GameObject> _objectList = new List<GameObject>();

    // 添加对象
    public void AddObject(int id, GameObject obj)
    {
        _objectDict.Add(id, obj);
        _objectList.Add(obj);
    }

    // 快速查找(O(1))
    public GameObject GetObjectById(int id)
    {
        _objectDict.TryGetValue(id, out var obj);
        return obj;
    }

    // 批量遍历(高效遍历)
    public void UpdateAllObjects()
    {
        foreach (var obj in _objectList)
        {
            // 对每个对象执行逻辑
            obj.SetActive(true);
        }
    }
}

总结

  1. 选型优先:单纯遍历选 List<T>,高频查找选 Dictionary<K,V>
  2. 规避痛点:尽量不要用 Dictionary 来做遍历,其遍历效率远低于 List
  3. 终极方案:同时维护字典和列表,兼顾查找效率与遍历性能,这是 Unity/C# 开发中处理复杂映射关系的标准优化手段。

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

​ 在 Unity 的早期版本(版本 5.3 和更早的版本)中,Transform 组件的引用通常是在内存中随机排列的。这意味着在多个 Transform 上的迭代相当慢,因为存在缓存丢失的可能性。这样做的好处是,为另一个对象修改 GameObject 的父节点并不会造成显著的性能下降,因为 Transform 操作起来很像堆栈数据结构,插入和删除的速度相对较快。这种行为是开发人员无法控制的,所以只能接受。

​ 但是,Unity 5.4 以后,Transform 组件的内存布局发生了很大的变化。从那时起,Transform 组件的父 - 子关系操作起来更像动态数组,因此 Unity 尝试将所有共享相同父元素的 Transform 按顺序存储在预先分配的内存缓冲区的内存中,并在 Hierarchy 窗口中根据父元素下面的深度进行排序。这种数据结构允许在整个组中进行更快的迭代,这对物理和动画等多个子系统特别有利。

​ 这种变化的缺点是,如果将一个 GameObject 的父对象重新指定为另一个对象,父对象必须将新的子对象放入预先分配的内存缓冲区,并根据新的深度对所有这些 Transform 排序。另外,如果父对象没有预先分配足够的空间来容纳新的子对象,就必须扩展缓冲区,以便以深度优先的顺序容纳新的子对象及其所有的子对象。对于较深、复杂的 GameObject 结构,这可能需要一些时间来完成。

​ 通过 GameObject.Instantiate () 实例化新的 GameObject 时,它的一个参数是希望将 GameObject 设置为其父节点的 Transform,它的默认值是 null,把 Transform 放在 Hierarchy 窗口的根元素下。Hierarchy 窗口根元素下的所有 Transform 都需要分配一个缓冲区来存储它当前的子元素以及以后可能添加的子元素(子 Transform 元素不需要这样做)。但是,如果实例化之后立即将 Transform 的父元素重新修改为另一个元素,它将丢弃刚才分配的缓冲区。为了避免这种情况,应该将父 Transform 参数提供给 GameObject.Instantiate () 调用,它跳过了这个缓冲区分配步骤。

另一种降低这个过程成本的方法是让根 Transform 在需要之前就预先分配一个更大的缓冲区,这样就不需要在同一帧中拓展缓冲区,重新指定另一个 GameObject 到缓冲区中。这可以通过修改 Transform 组件的 hierarchyCapacity 属性来实现。如果能够估计父元素包含的子 Transform 数量,就可以节省大量不必要的内存分配。

Instantiate 陷阱

若先 Instantiate(go)(父节点为 null),再立刻 transform.SetParent(newParent),会先分配根节点缓冲区,再丢弃并重新分配,造成双倍性能开销。

优化方案:

  1. Instantiate 时直接指定父节点

    避免先实例化再改父节点,直接在 Instantiate 中传入目标父 Transform,跳过根缓冲区分配步骤:

    // 推荐写法
    GameObject newObj = Instantiate(prefab, targetParentTransform);

    而非:

    // 不推荐写法
    GameObject newObj = Instantiate(prefab);
    newObj.transform.SetParent(targetParentTransform); // 丢弃根缓冲区,造成额外开销
  2. 预分配缓冲区容量(hierarchyCapacity)

    若能预估父节点的子 Transform 数量,提前设置 hierarchyCapacity,避免运行时频繁扩容:

    // 预估将有 50 个子对象,预先分配足够容量
    targetParentTransform.hierarchyCapacity = 50;

    这能避免同一帧中多次缓冲区扩展与重排,显著提升复杂层级操作性能。

关注缓存Transform的变化

Transform 组件只存储与其父组件相关的数据,访问和修改 Transform 组件的 positionrotation 和 / 或 scale 属性会导致大量未预料到的矩阵乘法计算.

​ 使用 localPositionlocalRotationlocalScale 的相关成本相对较小,因为这些值直接存储在给定的 Transform 中,可以进行检索,不需要任何额外的矩阵乘法。因此,应该尽可能使用这些本地属性值.

​ 当Transform组件属性更新时,会向组件(如 ColliderRigidbodyLightCamera)发送内部通知,这些组件也必须进行处理,因为物理和渲染系统都需要知道 Transform 的新值,并相应地更新

​ 如需要在同一帧中多次替换Transform组件的属性时,可以先缓存这个值,只在帧的末尾提交它们

以下是代码示例

private Vector3 _targetPosition;
private Quaternion _targetRotation;
private bool _isTransformDirty = false;

// 业务逻辑中只缓存值
public void SetTargetPosition(Vector3 pos) {
    _targetPosition = pos;
    _isTransformDirty = true;
}

// 帧末尾一次性提交
private void LateUpdate() {
    if (_isTransformDirty) {
        transform.position = _targetPosition;
        transform.rotation = _targetRotation;
        _isTransformDirty = false;
    }
}

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

​ 使用单例模式时,需避免在OnDestroy中调用单例,否则可能会导致在Destroy状态时创建对应的单例对象,从而触发报错.

​ "有些对象在关闭场景时没有清理干净.(是否从OnDestory中衍生了新的GameObjects?)"

​ 如果一定要在OnDestroy中调用单例,可使用以下方法避免

//在单例中的定义
private bool _alive = true;
void OnDestory() {_alive = false;}
void OnApplicationQuit() {_alive = false}//在应用退出前发送给所有GameObjects.

public static bool IsAlive {
	get{
		if(_Instance == null)
			return false;
		return _instance.alive;
	}
}

//其他对象调用时
void OnDestroy()
{
	if(MySingletonComponent.IsAlive)
		MySingletonComponent.Instance.SomeMethod();
}

//此处有消息系统相关的记录并未写入

禁用未使用的脚本和对象

通过可见性禁用对象

​ 通过 OnBecameVisible()OnBecameInvisible() 回调,监听物体是否被相机渲染可见,实现可见 / 不可见时的自定义逻辑(如激活 / 休眠、加载 / 卸载、显隐控制)。

  • 物体必须挂载可渲染组件MeshRenderer / SkinnedMeshRenderer / SpriteRenderer
  • 脚本必须挂载在带渲染组件的同一物体上(父 / 子物体不生效)

示例代码

using UnityEngine;

/// <summary>
/// 物体可见性监听脚本
/// 挂载到带 MeshRenderer/SkinnedMeshRenderer/SpriteRenderer 的物体上
/// </summary>
public class VisibilityListener : MonoBehaviour
{
    // 可选:物体可见时执行的逻辑
    private void OnBecameVisible()
    {
    	enabled = true;//启用自身
    }

    // 可选:物体不可见时执行的逻辑
    private void OnBecameInvisible()
    {
    	enabled = false;//禁用自身
    }
}

通过距离禁用对象

​ 下面的代码是一个简单的协程,它定期检查与给定目标对象的总距离,如果它偏离目标太远,就禁用它自己:

[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();
        }
    }
}

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

​ 可以肯定地说,CPU 比较擅长将浮点数相乘,但是不擅长计算它们的平方根。每次使用 magnitude 属性或 Distance() 方法要求 Vector3 计算距离时,都要求它执行平方根计算(根据勾股定理),与许多其他类型的向量数学计算相比,这会消耗大量的 CPU 资源。

​ 然而,Vector3 类也提供了 sqrMagnitude 属性,它提供了同样可作为距离的结果,只是该值是平方。这意味着如果将需要比较的距离进行平方,就可以执行基本相同的比较,而不需要进行开销较大的平方根计算。

​ 例如,如下代码:

float distance = (transform.position - other.transform.position).Distance();
if (distance < targetDistance) {
    // do stuff
}

​ 可以用下面的代码替换,得到近乎一致的结果:

float distanceSqrd = (transform.position - other.transform.position).sqrMagnitude;
if (distanceSqrd < (targetDistance * targetDistance)) {
    // do stuff
}

​ 结果几乎相同

​ 唯一的问题在于浮点精度。可能会降低一些使用平方根值的精度,因为该值调整为具有不同密度的可表示数字区域:它可以准确地落在(或更接近)一个更精确的可表示数字区域,更有可能落在一个精度较低的数字区域。结果并不完全相同,但是在大多数情况下,两者非常接近,对于以这种方式替换的每条指令,性能收益可能相当可观。

​ 如果这个精度损失不重要,那么应该考虑采用这个性能技巧。然而,如果精度是非常重要的(例如运行一个精确的大型星系空间模拟),就可能要忽略这个技巧。

最小化反序列化行为

Unity 的序列化系统主要用于场景、预制件、ScriptableObjects 和各种资产类型(往往派生自 ScriptableObject)。当其中一种对象类型保存到磁盘时,就使用 YAML (Yet Another Markup Language,另一种标记语言) 格式将其转换为文本文件,稍后可以将其反序列化为原始对象类型。所有的 GameObject 及其属性都会在序列化预制件或者场景时序列化,包括私有的和受保护的字段、它们的所有组件,及其子 GameObjects 和组件等。

​ 构建应用程序时,这些序列化的数据会捆绑在大型二进制数据文件中,这些文件在 Unity 内部被称为序列化文件。在运行时,从磁盘读取和反序列化数据是一个非常慢的过程(相对而言),因此所有的反序列化活动都要产生显著的性能成本。

​ 这种反序列化在调用 Resources.load() 时发生,用于在名为 Resources 的文件夹中查找文件路径。一旦数据从磁盘加载到内存中,以后重新加载相同的引用会快得多,但是在第一次访问时总是需要从磁盘加载。当然,需要反序列化的数据集越大,此过程所需的时间就越长。由于预制组件的每个组件都是序列化的,因此层次结构越深,需要反序列化的数据就越多。

​ 这对于具有很深层次结构、带有许多空 GameObject 对象的预制体(因为每个 GameObject 对象总是至少包含一个 Transform 组件)来说是一个问题,对于用户界面预制体来说尤其是一个问题,因为它们往往能够比典型的预制体容纳更多的组件。

​ 加载大型序列化数据集可能会在第一次加载时造成 CPU 的显著峰值,如果在场景开始时立即调用它们,则会增加加载时间。更重要的是,如果在运行时加载它们,可能会导致掉帧。可以使用以下方法来最小化反序列化的成本:

减小序列化对象

  • 移除不必要的序列化字段,避免在 MonoBehaviour 中序列化大对象(如 Texture2D、string 数组)
  • 将大对象拆分为独立小 Prefab/Asset,按需加载
  • 清理未使用的资源引用,防止 “隐性依赖” 导致的包体膨胀
  • 嵌套预制体,如一个房屋预制体有多个相同的门,将门作为一个单独的预制体嵌套到房屋预制体内,其反序列化开销将降为一个门的开销,而不是多个门的开销,并且内存也只需要一份

异步加载序列化对象

​ 可以通过 Resources.LoadAsync() 以异步方式加载预制块和其他序列化的内容

​ 通过 ResourceRequest.isDone 可轮询加载完成状态,也可通过 yield returnasync/await 等待加载完成。

​ 适合游戏运行中非即时需要的资源(如后续关卡的场景物件、弹窗 UI 等),不推荐用于游戏启动时必须立即显示的核心资源。

在内存中保存之前加载的序列化对象

Unity 加载的序列化对象会常驻内存,直到主动调用卸载接口或场景切换(部分资源)。

内存权衡

  • 保留内存:减少重复加载开销,适合高频使用的预制体(如子弹、UI 弹窗)。
  • 风险:长期累积会导致内存占用过高,可能触发 OOM(内存溢出)。

卸载方式

  • Resources.UnloadUnusedAssets():卸载未被引用的资源(更安全)。
  • Resources.UnloadAsset(Object):精确卸载指定资源。
  • SceneManager.LoadSceneAsync(scene, LoadSceneMode.Single):切换场景时自动卸载旧场景资源。

将公共数据移入ScriptableObject

​ 如果有许多不同的预制件,其中的组件包含许多倾向于共享数据的属性,例如游戏设计值(包括命中率、力量、速度等),那么所有这些数据都将序列化到使用它们的每个预制件中。更好的方法是将这些公共数据序列化到 ScriptableObject 中,然后加载并使用它。这样就减少了存储在预制文件中的序列化数据量,并可以避免过多的重复工作,显著减少场景的加载时间。

叠加,异步地加载场景

核心价值:避免场景切换卡顿,让玩家持续操作;支持将大场景拆分为多个子场景(如关卡区块、UI 层),按需加载,优化内存与加载效率。

实现逻辑:通过LoadSceneAsync()配合LoadSceneMode.Additive参数实现,可结合Trigger Volumes(触发体积)检测玩家位置,在接近目标场景时自动触发加载;异步加载需将内容分散到多帧执行,避免对象瞬间弹出影响体验。

卸载方式:

  • 同步卸载:SceneManager.UnloadScene()(易阻塞,不推荐);
  • 异步卸载:SceneManager.UnloadSceneAsync()(推荐,后台释放资源,不阻塞主线程)。

卸载注意事项:

  • 无法卸载单一场景的 “小块”:若原场景较大,卸载时会清除所有内容,需提前拆分场景;
  • 卸载时机:仅当玩家确定不再需要该场景时卸载,避免物体凭空消失;
  • 内存释放:卸载后需调用Resources.UnloadUnusedAssets()释放残留资源,配合 GC 回收内存,减少内存波动。

创建自定义的Update()层

​ 本章前面讨论了如何使用这些 Unity Engine 特性来避免在大多数帧中出现过多 CPU 工作负载。不管采用哪种方法,都存在一个额外的风险,即需要编写大量的 MonoBehaviour 来定期调用某个函数,这意味着在同一帧中同时触发了太多的方法。

想象一下,成千上万的 MonoBehaviour 在场景开始时一起初始化,每个 MonoBehaviour 同时启动一个协程,每 500 毫秒处理一次 AI 任务。它们极有可能在同一帧内触发,导致 CPU 使用率在一段时间内出现一个巨大的峰值,接着会下降,然后在处理下一轮 AI 时再次出现峰值。理想情况下,开发人员希望分散这些调用。

下面是这个问题的可能解决方案:

  • 每次计时器过期或协程触发时,生成一个随机等待时间。
  • 将协程的初始化分散到每个帧中,这样每个帧中只会启动少量的协程初始化。
  • 将调用更新的职责传递给某个 God 类,该类对每个帧的调用数量进行了限制。

前两个解决方案很有吸引力,因为它们相对简单,而且协程可以潜在地减少大量不必要的开销。然而,如前所述,这种剧烈的设计更改会带来许多危险和意想不到的副作用。

优化更新的一个更好的可能方法是根本不使用 Update (),或者更准确地说,只使用一次。当 Unity 调用 Update () 时,实际上是调用它的任何回调,都要经过前面提到的本机 - 托管的桥接,这可能是一个代价高昂的任务。换句话说,执行 1000 个单独的 Update () 回调的处理成本比执行一个 Update () 回调的成本要高,后者调用 1000 个常规函数。调用 Update () 数千次的工作量并不是 CPU 能够轻易承担的,这主要是因为桥接。因此,让一个 God 类 MonoBehaviour 使用它自己的 Update () 回调来调用自定义组件使用的自定义更新样式的系统,可以最小化 Unity 需要跨越桥接的频率。

事实上,许多 Unity 开发人员更喜欢从项目一开始就实现这个设计,因为它可以让自己更好地控制更新何时以及如何在整个系统中传播。这可以用于菜单暂停、冷却时间操控效果,或对重要任务进行优先级排序,以及如果发现即将达到当前帧的 CPU 预算,就暂停低优先级任务。

所有想要与这样一个系统集成的对象必须有一个公共的入口点。为此,可以使用以 interface 为关键字的接口类。接口类本质上建立了一个契约,任何实现接口类的类都必须提供一系列特定的方法。换句话说,如果知道对象实现了一个接口类,就可以确定哪些方法是可用的。在 C# 中,类只能从单个基类派生,但可以实现任意数量的接口类(这避免了 C++ 程序员所熟悉的 “死亡之钻” 问题)。

代码实现

定义一个接口

public interface IUpdateable
{
	void OnUpdate(float dt)//函数命名为OnUpdate是为了与Update区分开来,避免混淆,dt表示Time.deltaTime的值
}

定义一个实现该接口的MonoBehaviour的类,为方便讲解,后续我们将他称为类A

public class UpdateableComponent : MonoBehaviour,IUpdateable
{
	public virtual void OnUpdate(float dt)
	{
		
	}
}

此时这个函数还没有被调用,因此我们需要一个God类来调用它,这个God类可以是单例,也可以说是其他任何适合的设计模式

以下是我们定义的God类

public class GameLogicSingletonComponent : SingletonComponent<GameLogicSingletonComponent> {
    public static GameLogicSingletonComponent Instance {
        get { return ((GameLogicSingletonComponent)_Instance); }
        set { _Instance = value; }
    }

    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);
        }
    }
}

此时我们需要再次修改我们定义的类A,为其添加注册到God类的代码

public class UpdateableComponent : MonoBehaviour,IUpdateable
{
	public virtual void OnUpdate(float dt)
	{
		
	}
	void Start() 
	{
    	GameLogic.Instance.RegisterUpdateableObject(this);//注册到God类
	}

	void OnDestroy() {
    	if (GameLogic.Instance.IsAlive) 
    	{
        	GameLogic.Instance.DeregisterUpdateableObject(this);//移除出队列
    	}
    }
}

注意,在此处我们使用了Start()方法,如果后续的类继承自类A,当它们需要使用Start()方法时,会覆盖掉类A的逻辑.

因此,最好我们再定义一个Initialize()的虚方法,后续继承自类A的类,统一使用 Initialize()方法代替Start()方法,OnDestroy也是同理,我们也可以再定义一个Destroy()的虚方法,代替掉原来的OnDestroy

以下是修改后的类A实现

public class UpdateableComponent : MonoBehaviour,IUpdateable
{
	public virtual void OnUpdate(float dt)
	{
		
	}
	void Start() 
	{
    	GameLogic.Instance.RegisterUpdateableObject(this);//注册到God类
    	Initialize()
	}
	
	protected virtual void Initialize()
	{
	
	}

	void OnDestroy() {
    	if (GameLogic.Instance.IsAlive) 
    	{
        	GameLogic.Instance.DeregisterUpdateableObject(this);//移除出队列
    	}
    	Destory();
    }
    
    protected virtual void Destory()
	{
		
	}

}

​ 如果确保所有自定义组件都继承自 UpdateableComponent 类,那么实际上用一个 Update() 回调和 N 个虚函数调用替换了 Update() 回调的 N 次调用。这可以节省大量的性能开销,因为虽然调用虚函数(开销比非虚拟函数调用略多,因为它需要调用重定向到正确的地方),仍然将Update行为的绝大多数放在托管代码中,尽可能避免本地 - 托管的桥接。这个类甚至可以扩展为提供优先级系统,如果它检测到当前帧花费的时间太长,就可以跳过低优先级任务,还有许多其他的可能性。

图形优化

Draw Call

​ 在Unity中,Draw Call被称为Set Pass Call,这表示在GPU阶段进行了一次渲染状态修改

材质和着色器

​ 在Unity中,渲染状态本质上是通过材质呈现给开发者的,材质是着色器的容器.着色器是一种用于定义GPU应该如何渲染输入的顶点和纹理数据的简短程序.

​ 如果想要最小化渲染状态修改的频率,可以减少场景中使用的材质数量.

批处理

动态批处理

静态批处理

美术资源优化

音频文件

Force To Mono: 强制为单声道

Load In Background : 开启Load In Background 会将音频加载更改为异步任务,因此,加载不会阻塞主线程.但需要注意在播放前提前将音频加载到内存,以避免音频加载导致的延迟.

Preload Audio Data : 开启后,在场景初始化时会将音频加载到内存中.因此关闭此选项有助与加快场景初始化,但也意味着第一次播放音频文件时,CPU需要立即访问磁盘,检索文件,将其加载到内存,解压缩并播放.

​ Load Type选项指示音频数据如何加载,有3种选择:

  • Decompress On Load : 此设置压缩磁盘上的文件以节省空间,并在首次加载时将其解压缩到内存中.这是加载音频文件的标准方法,大多数情况均使用此方法.解压缩文件需要一段时间,这会导致加载过程中的额外开销,但是会减少播放音频文件时所需的工作量

  • Compressed In Memory: 此设置在加载音频时只是将其直接从磁盘复制到内存中.只有在播放音频文件时,才会在运行期间对其进行解压缩.这将在播放音频剪辑时牺牲运行时CPU,但在音频剪辑保持休眠状态时,提高了加载速度,减少了运行时内存消耗.因此,此选项最适合频繁使用的大型音频文件,或者在内存消耗上遇到瓶颈,并且愿意牺牲一些CPU周期来播放音频剪辑 .

  • Streaming: 此设置(也称为缓冲)将在运行时加载,解码,和播放文件.具体做法是逐步将文件推送到一个小缓冲区,在缓冲区中一次只留存整个文件的一小部分数据.采用此方法,特定音频剪辑使用的内存量最小,但运行时CPU使用的内存量最大.由于文件的每个回放实例都需要生成自己的缓冲区,因此此设置有一个缺点,即多次引用音频剪辑,会导致内存中的同一个音频剪辑的多个副本必须单独处理,如果胡乱使用,会导致运行时CPU成本过高.因此,此选项最好用于定期播放的单实例音频剪辑.例如背景音效和环境音效.(因为加载和播放是一起进行的,因此在选择Streaming模式时,Preload Audio Data选项是禁用的)

编码格式与品质级别

​ Unity支持3种音频剪辑编码格式,Compressed,PCM和ADPCM.

​ 与Compressed一起使用的压缩算法取决于目标平台.独立的应用程序和其他非移动平台将文件转换为OggVorbis 格式,而移动平台使用MP3格式.

​ OGG Vorbis 压缩格式在解压缩后,其大小通常是压缩后的10倍左右.

​ ADPCM压缩格式在解压缩后,其大小通常是压缩后的4倍左右.

​ 所使用的编码/压缩格式会在运行期间对音频文件的质量,大小和内存消耗产生显著印象,只有Compress格式才能在不影响文件采样率的情况下改变质量.而PCM和ADPCM设置并没有提供这种功能,只能有压缩格式界定文件大小.

​ 每种压缩格式的印制损失,大小,质量和用法如下所示.

格式损失大小质量用法
PCM极短暂且需要高清晰度的音效
ADPCM非常小压缩会产生相当大的噪声,因此将其作为较嘈杂的短声音效果,例如爆炸,碰撞和冲击声音
Compressed通常是小/中可变解码使用额外的运行时CPU.大多是情况下都应该使用这种格式.此选项允许自定义压缩算法的效果质量级别,以根据文件大小调整质量

音频性能增强

  • 最小化活动音频数量

  • 为3D声音启用强制为单声道

    ​ 在立体声音频文件上启用Force To Mono(强制为单声道) 设置会将来自两个音频通道的数据混合到一个通道中,文件的总磁盘和内存空间使用量有效地降低了50%.一般不要给二维音效启用此选项,二维音效通常用于创建特定的音频体验.但是,在两个通道相同的3D位置音频剪辑上可以启用此选项,以节省一些空间.这些音频源类型让音频源和播放器的方向决定如何将音频文件播放到左/右耳,在这种情况下(两个声道实际相同)播放立体声效果通常没有意义.

    ​ 如果不需要立体声,2D音效也可以强制为单声道

  • 重新采样到较低的频率

    ​ 将音频文件的Sample Rate 设置为 Override Sample Rate,此时可以通过Sample Rate配置采样率.有些文件需要高采样率,例如高音调文件和大多数音乐文件.但大多数情况下,较低的采样率设置可减小文件的大小,而不会明显降低质量.

    ​ 22050Hz 是人类语言和古典音乐的一个常见采样率,在最终确定采样率之前,最好花时间进行一些测试

  • 考虑所有的压缩格式

    ​ 如前所述,Compressed,PCM和ADPCM压缩格式都有各自的优缺点,可根据情况对不同的文件使用不同的编码格式,在内存占用,磁盘占用,CPU使用和音频质量方面做出一妥协.

  • 注意流媒体

    ​ Streaming 加载类型的优点是运行时内存成本低,因为给它分配了一个小的缓冲区,文件像数据队列一样连续地推入缓冲区.这看上去很有吸引力,但是从磁盘流式传输文件应该仅限于大型的单实例文件,因为它需要运行时的硬盘访问,这是可用的最慢的数据访问形式之一(仅次于通过网络拉取文件). 使用Steaming选项时,分层或转换的音乐剪辑可能会遇到严重的问题,此时,最好考虑使用另一个加载类型并手动控制加载或卸载.

    ​ 还应该避免一次传输过个文件,因为可能会在磁盘上造成大量缓存丢失,从而打断游戏.这就是为什么该选项主要用于背景音乐/环境音效,因为一次只需要一个背景音乐/环境音效.

  • 通过混音器组应用过滤效果以减少重复

    ​ 利用Unity的音频混音器(AudioMixer)生成通用的过滤效果模版,这些模版可以被多个音频源引用,以最小化内存开销.

  • 谨慎使用远程内容流

  • 考虑用于背景音乐的Mod音频文件

    ​ 音频模块也称为音轨模块,是节省大量空间,并且没有任何明显质量损失的音频格式.Unity中支持的文件扩展名哟.it,.s3m,.xm,和.mod.(关于mod的介绍,等待我后续补充)

纹理文件

​ 在游戏开发中经常混淆纹理和精灵的概念,因此需要区分它们-----纹理只是简单的图像文件,一个颜色数据的大列表,以告知插值程序,图像的每个像素应该是什么颜色;而精灵是网格的2D等价物,它定义了图像在游戏场景中出现的方式和位置,通常只是一个四边形,用于渲染面向当前相机的平面.

纹理压缩

​ PC 推荐 : RGBA Crunched DXT5|BC3

​ Android推荐 : ASTC6x6

纹理性能增强

  • 减少纹理文件的大小

    ​ 给定的纹理文件越大,推送文件所消耗的GPU内存带宽就越多.如果每秒推送的总内存超过图形卡的总内存带宽,就会产生瓶颈,因为在下一个渲染过程开始之前GPU必须等待所有纹理都上传完毕.小纹理自然闭大纹理更容易通过管线推送到GPU,因此需要找出高质量和性能之间的平衡点.

    ​ 为了确定内存带宽是否存在瓶颈, 一个简单的测试是降低游戏中最丰富,最大的纹理文件的分辨率,并重启场景. 如果帧速率突然提高,则应用程序很可能受到纹理吞吐量的限制.如果帧数率没有改善或者改善得很少,则要么任然有一些内存带宽可以利用,要么管线渲染的其他地方存在瓶颈,所以看不啊到进一步的改进

  • 谨慎地使用MipMap

    ​ MipMap消除了电子游戏的混叠问题,以及减少了远处物体的贴图采样开销,但是它会增加33%的纹理大小.

    ​ 在Unity2018.2以后的版本中有了另一种加载Mipmap的方法,TextureStreaming,纹理流送.可以节省多达30%的纹理内存.

    ​ 流式传输MipMap的原理是先传输最低一级的纹理,然后一级级提升,直到获取到当前所需的级别,例如,我当前需要2级mipmap,那他就会从最低级别的lod一直加载到2级,剩下的1级和0级的mipmap就不需要加载,内存就省下来了.

    ​ 然而,这是有代价的.首先,流式传输MipMap在瞬间的箭头切割或快速移动时,因为MipMap的加载,就可以注意到纹理质量从低到高的变化.这可以通过Mipmap流API来缓解,以便在目标位置预加载Mipmap.

    ​ 在内置渲染管线中,将DrawMode切换为MipMap,可以观察应用程序此时使用了哪个Mipmap级别. 在玩家的当前视图中,如果纹理大于它们合适大小,就以红色高亮显示,如果纹理太小,则会以蓝色高亮显示.

    ​ 纹理只有需要在巨量相机很远或很近的地方渲染时,Mipmap才是有用的.如果纹理一直都和主相机保持固定的距离,开启Mipmap只是对空间的浪费

    ​ UI,天空盒,以及远处的背景纹理,都不需要打开Mipmap

    ​ 一直出现在玩家周围的对象,例如以玩家为中心的粒子特效,只出现在玩家周围的角色,对象,只有玩家可以持有/携带的对象等.也不需要打开Mipmap

  • 从外部进行分辨率的压缩管理

    ​ 分辨率的压缩应尽可能在外部工具进行,不要过度依赖Unity的分辨率压缩,Unity的降采样效果无法与专业的图像处理软件媲美.

  • 调整Anisotropic Filtering 等级

    ​ 各项异性开销很大,有时没有必要使用.如果场景中的一些纹理肯定不会从倾斜的角度看到(例如远处的对象,UI元素,公告板粒子效果纹理),就可以安全地禁用各项异性.也可以考虑调整每个纹理的各向异性强度,在品质和性能之间找到平衡点

  • 考虑使用图集

    ​ 图集是一种技术,它将许多较小的,独立的纹理合并到一个较大的纹理文件中,从而最小化材质的数量,因此最小化所需使用的DrawCall数量.

    ​ 一些设备(特别是移动设备)对纹理的大小有一个相对较低的限制,这些纹理可以拖到GPU的最低内存缓存中.如果打包到图集中的纹理文件太大,则必须将其分解为更小的纹理,以适应目标内存空间.如果设备的GPU在每次执行DrawCall时都需要来自图集不同部分的纹理,那么不仅会造成大量缓存丢失,还可能阻塞内存带宽,因为纹理总是从VRAM和较低级别缓存中提取.

    ​ 如果图集是单独的纹理,就可能不会有这个问题.同样的纹理交换也会发生,但是会交换小得多的文件,代价是要执行额外的Drawcall.在这个阶段,最好的选择是降低图集的分辨率,或者生成多个较小的图集,以便更好地控制它们进行动态批处理的方式.

  • 贴图的分辨率限制

    ​ 应尽量避免非2的n次幂分辨率的纹理和长方形纹理(256*512),一些GPU可能需要方形纹理,因此Unity将自动拓展长方形纹理到额外的空白处进行补偿,以适应GPU期望的格式,这将消耗额外的内存带宽.

  • 稀疏纹理

    ​ 稀疏纹理也称为区块纹理或超级纹理,提供了一种运行时从磁盘传输纹理数据流的方式.相对而言,如果CPU以秒为单位执行操作,那么磁盘将以天为单位执行操作.因此,通常的建议是,应该尽可能避免游戏运行时的硬盘访问.

    ​ 然而,如果明智地在需要之前开始传输纹理的部分数据,则稀疏纹理提供了一些有趣的性能提升技术.稀疏纹理是通过将许多纹理组合成一个巨大的纹理文件来实现的,这个文件太大了,无法作为一个纹理文件加载到图形内存中.这类似于图集的概念,只是包含纹理的文件非常大,并且包含相当多的颜色细节,比如每个箱数32位.其理念是手动悬着要从磁盘中动态加载的小片段纹理,在游戏中需要它们之前将它们从磁盘中取出,来节省大量的运行时内存和内存带宽.这种技术的主要成本是文件大小需求和潜在的连续磁盘访问.

    ​ 现在大部分的开放世界的地形图就是使用类似稀疏纹理的方式存储的.

  • 程序化材质

    ​ 程序化材质也称为Substance,是一种在运行时通过使用自定义数学公式混合小型高质量的纹理样本,通过程序化方式生成纹理的手段.程序化材质的目标是在初始化期间以额外的运行时内存和CPU处理为代价,极大地减少应用程序的磁盘占用,以便通过数学操作而不是静态颜色数据来生成纹理.

    ​ 对于Unity2019之后,程序化材质不再是Unity的一部分,而是作为一个单独的插件提供

  • 异步纹理上传

    ​ 一般情况下,纹理的read/write开关是关闭的,禁用该选项的好处是纹理可以使用 Asynchronous Texture Uploading 特性,该特性有两个优势 : 纹理会从磁盘异步上传到RAM中.当GPU需要纹理数据时,传输发生在渲染线程,而不是主线程.纹理会被推送到环形缓冲区中,一旦缓冲区中包含新数据,数据就会持续不断地推送到GPU.如果缓冲区中没有新数据,就提前退出并等待,直到请求新的纹理数据.

    ​ 然而,开启问了的读写访问功能,本质上是告知Unity想要随时读取和编辑该纹理,这暗示着GPU需要随时属性对它的访问,因此禁用该纹理的异步上传功能: 所有上传任务必须在主线程中执行.开发人员可能想要开启该选项,以模拟在画布上画画,或者将网络上的图像数据写入已有纹理.

    ​ 另外,由于异步纹理上传特性仅适用于明确导入到项目中且在构建时存在的纹理,因为该特性仅在纹理打包到可流式传输的特殊资源中才会生效,所以,任何通过LoadImage(byte[])生成的纹理,由外部位置导入或下载的纹理,或者通过Resources.Load从Resource文件夹加载的纹理(它们都隐含LoadImage(byte[])调用)都不会转换为可流式传输的内容,因此无法使用异步纹理上传特性.

    ​ 异步纹理上传特性允许花费的时间上限和Unity为了推送要上传的纹理而使用的循环缓冲区总大小都是可以调整的,可以在Edit|Project Settings | Quality | Other 菜单下进行设置,设置选项分别为Async Upload Time Slice 和 Async Upload Buffer Size . Async Upload Time Slice 的值可以设置为期望Unity在渲染线程中花费在异步纹理上传上的最大毫秒数. 将Async Upload Buffer Size的值设置为可能需要使用的最大纹理文件的大小或许是明智的,如果需要在同一帧加载多个新的纹理,就需要再增加一点额外的缓冲区. 复制纹理数据的循环缓冲区会根据需要拓展大小,但开销通常比较大.由于可能已经提前知道了所需循环缓冲区的大小,因此也可以将它设置为期望大小的最大值,以避免需要重新调整缓冲区大小时导致的潜在帧率下降.

    ​ 防止 resize 的核心是让 buffer 能容纳最大单个上传资源;

网格和动画文件

  • 减少多边形数量

    ​ 由于不能对使用Skinned Mesh Renderer 的网格对象进行批处理,这是减少动画对象的CPU和GPU运行时开销的好方法之一

    ​ 目前,对象的大部分细节几乎完全通过精细的纹理和复杂的阴影来提供,所以经常可以在网格上去掉很多顶点,而大部分用户无法分辨它们之间的区别.

  • 调整网格压缩

    ​ Unity为导入的网格文件提供了4种不同的网格压缩设置,增加次设置将把浮点数据转换为固定值,降低顶点位置/法线方向的精度,简化顶点颜色信息等,这对包含许多彼此相邻的小部件(比如栅栏或格栅)的网格有明显的影响.如果通过程序生成网格,就可以通过调用MeshRenderer组件的Optimize()方法来实现相同类型的压缩(当然,这需要一些时间来完成)

    ​ Edit|ProjectSettings|Player|OtherSettings 中也有两个全局设置,可以影响网格数据的导入方式.这两个设置的选项如下.

    • Vertex Compression

    • Optimize Mesh Data

      ​ 在各种情况下,这样做的好处是减少了应用程序的磁盘占用,却要花费额外的时间来加载网格,因为在需要数据之前必须花费额外的时间解压缩数据.

  • 恰当使用Read-Write Enable

    ​ 开启读写将导致内存中也会存储网格副本,这意味着它的内存占用会比不开启读写增加一倍

    ​ 如果网格不会在运行时改变,则禁用它

    ​ 如果网格进场在运行时以不同比例重新出现,那么Unity需要将这些数据保存在内存中,以便更快地重新计算新的网格,因此启用读写是明智的.如果禁用,Unity不仅需要在每次重新引入网格时重新加载网格数据,还需要同时制作重新缩放的副本,这会导致潜在的性能问题这种情况通常在粒子系统中较为常见

    ​ 当网格在运行时以动态的方式被实例化和缩放时,建议打开读写开关,这将提高对象的实例化速度,但会导致一些内存开销.

  • 考虑烘焙动画

    ​ Unity并未提供现成的烘焙动画工具,需要开发者自行开发

    ​ 常见的烘焙动画流程有VAT流程,通过将顶点的位置和法线信息写入贴图,并通过Shader去读取,在一些顶点数量不多的模型上,通常会获得比蒙皮网格更低的开销,并且因为使用MeshRenderer组件,就可以使用GPU Install等合批技术

  • 合并网格

Asset Bundle 和 Resource

​ Resource系统在原型建立阶段和项目的早期阶段很有益处,也能在有限范围的游戏中相当高效地使用.

​ 然而,专业的Unity项目应该支持AssetBundle系统.原因有很多,首先,当涉及构建时,Resource系统的可伸缩性不是很大.所有资源都合并大一个大型序列化文件二进制数据blob中,其中包含一个索引列表,列出了可以在其中找到的各种资产.向列表中添加更多的数据时可能很难管理,并且需要很长时间来构建.

​ 其次,Resource系统以Nlog(N)的方式从序列化文件中获取数据,所以需要警惕N的值.再次,Resource系统使应用程序难以基于每个设备提供不同的素材数据,而Asset Bundle 系统很容易实现这一点.最后, Asset Bundle 系统可用于为应用程序提供小型的,定期的自定义内容更新,而Resource系统需要完全替换整个应用程序,才能达到相同的效果.

​ Asset Bundle 系统与Resource系统拥有许多相同的功能,比如从文件中加载数据和卸载不再需要的数据.然而,Asset Bundle 系统还提供了更多的功能,如内容流式传输,内容更新,内容生成和共享,这些都可极大地提高应用程序的性能,还可以提供占用更小磁盘空间的应用程序,让用户在开始游戏之前或游戏运行过程中下载额外的内容,在运行时流式传输素材以最小化应用程序的首次加载事件,基于每个平台提供更优化的素材,而不是给用户推送完整的应用程序.

​ 然而,Asset Bundle 系统也有缺点. 它的建立和维护比Resource 系统更复杂,理解起来也更复杂,因为它使用比Resource系统更复杂的系统来访问资产数据,充分利用其功能其功能需要更多额外的QA测试,以确保服务器正常交付内容,确保游戏读取和更新对应的内容.因此,最好只有当团队的规模能够支持所需的额外工作负载时,才使用AssetBundle系统

加速物理引擎

物理引擎的内部工作情况

​ Unity技术有两种不同的物理引擎: 用于3D物理的Nvidia的 PhysX 和用于2D物理的开源项目Box2D. 然而,Unity对它们的实现是高度抽象的,从通过主Unity引擎配置的更高级别UnityAPI的角度来看,两个物理引擎解决方案以功能相同的方式运行.

物理和时间

​ 物理引擎通常是在时间按固定值进行的假设下运行的,Unity的两个物理引擎都以这种方式运行.每个迭代称为时间步长.物理引擎将只使用特定的时间值来处理每个时间步长,这与渲染上一帧所花费的时间无关.该时间步长在Unity中被称为 Fixed Update Time, 它的值默认设置为20毫秒(每秒50次更新)

​ Fixed Update() 回调适用于任何期望独立于帧率的游戏行为.

最大运行的时间步长

​ 需要注意的是,如果自上次固定更新(例如游戏暂时卡顿)以来已经过了很长时间,那么固定更新将继续在相同的固定更新循环中计算,知道物理运营期赶上当前时间.如果上一帧花了100毫秒用于渲染,那么物理引擎将需要更新5次.由于默认固定更新的时间步长为20毫秒,在再次调用Update()之前还需要调用5次FixedUpdate()方法.当然,如果在这5次固定更新时有很多物理活动需要处理,例如总共花费超过了20毫秒.那么物理引擎将继续调用第6次更新.

​ 因此,在物理活动较多时,物理引擎处理固定更新的时间可能比模拟的时间要长.例如,如果一个游戏处理一个FixedUpdate需要30毫秒,而它的一个Fixed Update设置为20毫秒,它就已经落后了,需要它处理更多的时间步长.在这些情况下,物理引擎永远无法摆脱固定的更新循环,并运行另一帧进行渲染,这个问题通常被称为死亡螺旋.但是,为了防止物理引擎在这些时刻锁定游戏,存在允许物理引擎处理每个固定更新循环的最长时间,此阈值被称为允许的最大时间步长.如果当前一批固定更新的处理时间太长,则它将停止并放弃进一步的处理,直到下一次渲染更新完成.这种设计运行渲染管线至少将当前状态进行渲染,并允许用户输入以及游戏逻辑在物理引擎出现一次的罕见时刻做出一些决策.

​ 该设置可以通过Edit|Project Settings| Time|Maximum Allowed Timestep 来访问.

物理更新和运行时变化

​ 当物理引擎以改定的时间步长处理时,它必须移动激活的刚体对象(带有Rigidbody组件的GameObject),检测新的碰撞,并调用相应对象的碰撞回调.Unity文档明确指出,应在FixedUpdate()和其他物理回调中处理对刚体对象的更改,原因正是如此.这些方法与物理引擎的更新频率精密耦合,而不是游戏循环的其他部分,如Update().

​ 这意味着,诸如FixedUpdate()和OnTriggerEnter()的回到函数能够安全更改Rigidbody的位置,而诸如Update()和对WaitForSeconds或WaitForEndOfFrame的协程则不能.忽略这一建议可能导致意想不到的物理行为,因为在物理引擎有机会捕获和处理所有这些对象之前,可能会对同一对象进行多次更改.

​ 对Update() 回调中的对象应用力或脉冲而不考虑这些调用的频率是特别危险的. 例如,在玩家按住一个键时,给Update功能应用10牛顿的力,会导致两个不同设备之间的合成速度完全不同于在固定更新中执行相同的操作. 事实上,不能依赖Update调用的次数是一致的.但是,在FixedUpdate()回调中这样做会更加一致. 因此,必须确保在适当的回调中处理所有与物理引擎相关的行为,否则可就可能会引入一些令人困惑,很难重现的游戏漏洞.

​ 从逻辑上讲,在任何给定的固定更新迭代中花费的时间越多,在下一次游戏逻辑和渲染过程中花费的时间就越少.由于物理引擎几乎没有任何工作要做,而且FixedUpdate()回调有很多时间来完成它们的工作,因此大多数情况下这会导致一些小的,不明显的后台处理任务.然而,在某些游戏中,物理引擎可能在每次固定更新期间执行大量计算.这种物理处理时间上的瓶颈会影响帧率.

​ 因此,为了保持平滑,一致的帧率,需要通过最小化物理引擎处理任何给定时间步长所需的时间,来为渲染释放尽可能多的时.可以在物理引擎中调整一些与时间相关的特性和值,以避免这些性能缺陷.

静态碰撞器和动态碰撞器

​ 动态碰撞器只意味着GameObject包含Collider组件和Rigidbody组件,它会对外部的力(例如重力)和与其他Rigidbody的碰撞做出反应.

​ 静态碰撞体只意味着GameObject包含Collider组件,它不会对任何力做出响应.

​ 物理引擎自动将动态碰撞器和静态碰撞器分为两种不同的数据结构,每种结构都经过优化以处理现有碰撞器的类型.这有助与简化未来的处理任务,例如,解析两个静态碰撞器之间的碰撞和脉冲.

碰撞检测

​ Unity中的碰撞检测有3种设置,可以在Rigidbody组件的Collision Detection 属性中设置 Discrete(离散), Continuous(连续),Continuous Dynamic(连续动态).

​ Discrete 离散碰撞检查可以 简单理解为在每个fixedUpdate都检测一次碰撞体的位置,如果发生重叠,就视为发生了碰撞,因此如果物体的运的速度过快,则可能丢失碰撞.

​ 其余两个设置都将启用连续碰撞检测,其工作方式是从当前时间步长的起始和结束位置插入碰撞器,并检查在这个时间段中是否有任何碰撞.这降低了错过碰撞的风险,生成了更精确的模拟,但代价是CPU开销显著高于离散碰撞检测.

​ Continuous 设置仅在给定碰撞器和静态碰撞器之间启用连续碰撞检测,同一碰撞器与动态碰撞器之间的碰撞仍然将使用离散碰撞检测.

​ 同时,Continuous Dynamic 设置使碰撞器能够与所有金泰和动态碰撞器进行连续碰撞检测,其在资源消耗方面最大.

碰撞器类型

​ Unity中有4种不同类型的3D碰撞体,其性能成本从最小到最大依次为球体(Sphere),胶囊体(Capsule),立方体(Box),网格(Mesh).

​ 另外,有两种不同的网格碰撞器,Convex(凸)和Concave(凹).两种网格碰撞器类型都使用相同的组件(MeshCollider组件),这种网格碰撞器类型是通过切换Convex复选框选项生成的.

​ 如果为凹形的网格碰撞器启用了Convex复选框,则物理引擎将自动简化该网格碰撞器,生成的碰撞器具有能将其包围的最接近的凸形.上限为255个顶点,如果目标网格的顶尖数超过此值,则在网格生成过程中会引发错误.

​ 注意 : 由于处理物体间碰撞的复杂性,凹形网格碰撞器不能是动态碰撞器,只能用作静态碰撞器或触发体积.如果试图将Rigidbody组件添加到凹形网格碰撞器中,Unity将完全忽略它

​ 如果真的需要将凹形网格碰撞器作为Rigidbody组件,则解决方案是将对象分割成独立的凸形网格碰撞器的组合. 例如,想利用两个凸形来组合一个L形刚体.不幸的是,因为这是一个微妙的决定,所以没有自动的方法来实现这一点,需要手动执行这样的分解.

碰撞矩阵

​ 物理引擎具有一个碰撞矩阵,该矩阵定义允许哪些对象与哪些其他对象发生碰撞.当处理边界提交重叠和碰撞时,物理引擎将自动忽略不适合此矩阵的对象.这节省了碰撞检测阶段的物理处理,还允许对象彼此移动而不发生任何碰撞.

​ 碰撞矩阵可以通过Edit|Project Settings | Physics| Layer Collision Matrix 访问.

​ 碰撞矩阵系统通过Unity的层系统工作.矩阵表示层与层可能的组合,启用复选框意味着在碰撞检测阶段将检查这两个层中的碰撞器.注意,不可能只允许两个对象中的一个对碰撞做出响应,如果一个层可以与另一个层碰撞,那么它们都必须对碰撞做出响应.但是,静态碰撞器是一个例外,因为它们不允许对碰撞进行物理响应(尽管它们仍然可以收到OnCollision ... () 回调).

​ 请注意,对于整个项目,总共只能有32个层(因为物理引擎使用32位掩码来确定层间冲突的机会),因此必须将对象组织为对层敏感,这些层将在整个项目生命周期中进行拓展.无论出于什么原因,如果32个层对于项目来说不够,就可能需要找到巧妙的方法俩重用层或删除不必要的层.

Rigidbody 激活和休眠状态

​ Unity的物理引擎通过评估物体的质量归一化动能来工作,这基本上可以认为其取决于物体速度平方的大小.

​ 可以在Edit|Project Settings | Physics | Sleep Threshold 下修改控制休眠状态的阈值,还可以从Profiler窗口的Physics Area 中获取活动Rigidbody 对象的总数.

​ 如果阈值设置得太低,意味着对象不太可能进入休眠状态,因此继续在物理引擎中为每次固定更新消耗少量的处理成本,即使它不做任何重要的事情.同时,如果阈值设置得太高,则意味着一旦物理引擎决定缓慢移动的物体需要进入休眠状态,它们就会突然停止.

​ 请注意,休眠对象不会完全从模拟中删除.如果移动的Rigidbody 接近休眠对象,则它必须执行检查,以查看附近的对象是否与之碰撞,从而重新唤醒休眠对象,并将其重新引入模拟进行处理.

射线和对象投射

​ 物理引擎的另一个常见特征是能够将射线从一个点投射到另一个点,并用路径中的一个或多个对象生成碰撞信息,这就是所谓的射线投射.

调试物理

​ 可以使用Physics Debugger, 它可以通过Window|Physics Debugger 打开. 这个工具有助与从Scene窗口中过滤出不同类型的碰撞器,从而更好地了解哪些对象相互碰撞.

物理性能优化

场景设置

​ 首先,可以将许多最佳实践应用到场景中,以提高物理模拟的一致性. 请注意,这些技术并不一定都会提高CPU或内存的使用率,但它们会降低物理引擎的不稳定性.

  1. 缩放

    ​ 应该尽可能地使游戏世界中的所有物理物体的缩放接近(1,1,1).默认情况下,Unity假设试图模拟的游戏发生在地球表面.地球表面的重力加速度约为9.81米/秒,因此默认重力值设置为-9.81 以匹配地球重力.Unity世界空间中的1个单位等于1米,负号意味着它会把物体向下拉.物体大小应反映真实世界的尺度,因为缩放过大会导致物体因重力而移动的速度比预期要慢得多.如果所有物理都放大了5倍,那么重力就会减弱5倍,反之,亦然.对象缩放太小会使它们看起来下降得太快,看起来不真实.

    ​ 可以通过Edit|Project Settings | Physics/Physics2D | Gravity 修改重力强度,来调整隐含的世界缩放.但是,请注意,任何浮点运算在数值接近0时都会更精确,因此,如果一些对象的比例值远高于(1,1,1), 即使它们与隐含的世界缩放匹配,任然会有不稳定的物理行为.因此,在项目早期,应该导入与缩放最场景的物理对象,使其比例值为(1,1,1), 然后调整合适的重力值.这将在引入新对象时提供一个参考点.

    同样,保持所有对象在世界空间的位置接近(0,0,0),将具有更好的浮点数精度,提高模拟的一致性.