Unity优化篇 | Unity脚本代码优化策略,空引用快速检索使用合适的数据结构禁用脚本和对象等 性能优化方法

Posted 呆呆敲代码的小Y

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity优化篇 | Unity脚本代码优化策略,空引用快速检索使用合适的数据结构禁用脚本和对象等 性能优化方法相关的知识,希望对你有一定的参考价值。

📢前言

  • 本文是 Unity优化篇 系列的一篇文章,同时也包是含在 『Unity精品学习专栏⭐️ 里的文章。

  • 本专栏是我总结的Unity学习类的文章,适合Unity入门和进阶的小伙伴。

  • 订阅该专栏之后 Unity基础知识学习Unity 进阶技巧Unity 优化 篇 几个专栏的文章都可以查看。

  • 对Unity感兴趣的小伙伴千万不要错过哦,目前专栏正在优惠中,具体内容可以看该专栏的导航帖。

  • 上一篇文章中讲了 Unity中脚本代码优化策略中的 获取游戏对象 和 组件 的最佳方法https://xiaoy.blog.csdn.net/article/details/122242319

  • 那这篇文章就来继续讲一下 Unity中的脚本代码优化策略中的 空引用快速检索、缓存组件的使用、使用合适的数据结构等。


🎬Unity脚本代码优化策略

上一篇文章中介绍了 Unity中脚本代码优化策略中的 获取游戏对象 和 组件 的最佳方法

那接下来就是来看 脚本代码优化 的剩余部分内容了。

编写脚本会占据大量的开发时间,而且脚本编写是一个含义宽泛的术语。
所以本系列只限于Unity相关的脚本,包括MonoBehaviourGameObject 和 相关功能的问题。

大概内容如下所示:

获取游戏对象访问组件空引用快速检索GameObjectTransform的使用使用合适的数据结构游戏对象间通信数学计算合理反序列化行为等。

下面就来继续挨着学习一下相关内容。


🏳️‍🌈空引用快速检索

事实证明,对 GameObject进行 空引用检查 会造成一些不必要的性能开销,可能称不上性能优化。

但是有时候他可以避免一些报错提示,因为 空引用报错 在开发过程中是一件很常见的事情。

在C#对象中,GameObject 和 MonoBehaviour是特殊的对象,它们在内存中有两个表示:

  • 一个表示在于管理C#代码的相同系统管理内存中,C#代码是用户编写的(托管代码)
  • 另一个表示存在于另一个单独处理的内存空间中(本机代码)。

数据 可以在这两个内存空间之间移动,但是每次这种移动都会 导致额外的CPU开销 和 可能的额外内存分配

这种效果通常称为 跨越本机-托管 的桥接。

如果发生这种情况,就额可能会为对象的数据生成额外的内存分配,以便跨桥复制,这需要垃圾处理器最终执行一些内存自动清理操作。

检查空引用的方法有两种,最简单的一种就是对GameObject的空引用检查:

        if (gameObject != null)
        
            //执行某些gameObject的方法
        

另一种就是 System.Object.ReferenceEquals(),其运行速度大约是原来的两倍:

        if (!ReferenceEquals(gameObject,null))
        
            //执行某些gameObject的方法
        

这种方法不仅适用于GameObject,还适用于MonoBehaviour和其他的Unity对象。

经测试任何一个空引用检查方法只消耗纳秒,虽然消耗性能很少,带来的好处也不高,但是这却是一个值得的操作。


🏳️‍🌈避免从GameObject取出字符串属性

一般来说,从对象中检索字符串属性 与 在C#中检索其他的引用类型是相同的,应该不会增加内存成本才对。

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

GameObject 中受此行为影响的两个属性是 tagname
所以在游戏运行过程中使用这两种属性显然是不明智的,在一些性能无关紧要的地方使用倒是可以,比如编辑器脚本。

但事与愿违,Tag系统 我们通常用于对象运行时的标识,来做一些判断方法。

例如下面这种情况就会导致每次迭代的过程中造成额外的内存分配:

    private List<GameObject> list1; 

    void Update()
    
        for (int i = 0; i <= list1.Count; i++)
        
            if (list1[i].tag == "Player")
            
                //执行某些行为
            
        
    

根据对象的组件和类型来标识对象,以及标识不涉及字符串对象的值,是一种很好的实践。

但是如果标记系统出了问题,应该避免本地-托管桥接的开销成本。

幸运的是Tag通常被用作来进行判断比较,而GameObject还提供了一个 CompareTag() 方法,该方法与上面那种方法作用一致。

而且这种比较tag属性的方法完全避免了本机-托管的桥接。

来看一个简单的测试,了解一下这两种方法的性能消耗:

    private int nums = 1000000;
    
void Update()
    
        if (Input.GetKeyDown(KeyCode.A))
        
            for (int i = 0; i <= nums; i++)
            
                if (gameObject.tag == "Player")
                
                    //执行某些行为
                
            
        

        if (Input.GetKeyDown(KeyCode.B))
        
            for (int i = 0; i <= nums; i++)
            
                if (gameObject.CompareTag("Player"))
                
                    //执行某些行为
                
            
        
    

经测试分析发现,检索tag属性 1000万次比 使用CompareTag() 的处理时间要减少了一半的时间,而且CompareTag()不会导致 内存分配垃圾回收

提示
向 CompareTag() 传递字符串字面量(如"Player")不会导致运行时内存分配
因为应用程序在初始化期间分配这样的硬编码字符串,在运行时只是引用他们。


🏳️‍🌈缓存组件的使用

在Unity中编写脚本的时候,反复计算是一个常见的错误。

比如获取组件的方法 GetComponent(),下面举例演示一下:

    void Test()
    
        Rigidbody rigidbody = GetComponent<Rigidbody>();
        Collider collider= GetComponent<Collider>();
        Animator animator= GetComponent<Animator>();
        
        if (GetComponent<Player>().hp <0 )
        
            rigidbody.gameObject.SetActive(false);
            collider.gameObject.SetActive(false);
            animator.gameObject.SetActive(false);
        
    

当我们每次执行上述代码的时候,都会重新获得几个不同的组件引用。

这样对CPU的使用是非常不友好的,尤其是在Update中调用的话问题就会更加严重。

除非是内存非常有限,否则的话就在初始化的时候获取引用并保存它们,直到需要使用他们为止。

修改如下:

    private Rigidbody rigidbody;
    private Collider collider;
    private Animator animator;

    private void Awake()
    
        rigidbody = GetComponent<Rigidbody>();
        collider = GetComponent<Collider>();
        animator = GetComponent<Animator>();
    
    void Test()
    
        if (GetComponent<Demo16>().hp <0 )
        
            rigidbody.gameObject.SetActive(false);
            collider.gameObject.SetActive(false);
            animator.gameObject.SetActive(false);
        
    

这种方式 缓存引用,就不必在每次需要他们时重新获取,每次都会节省一点CPU开销,代价是少量的额外内存消耗。

同样的技巧也适用于在运行时决定就散的任何数据块。

不需要CPU在每次执行Update() 时都重新计算相同的值,因为可以将它存储在内存中。


🏳️‍🌈使用合适的数据结构

C# 在 System.Collections 命名空间中提供了许多不同的数据结构。

其中软件开发中常见的一个性能问题就是为了便利而使用不适当的数据结构来解决问题。

最常用的两种数据结构就是 列表(List<T>)字典(Dictionary<K,V>)

如果想遍历一遍对象,那使用列表最好。
因为列表实际上是一个动态数组,对象 和/或 引用在内存中彼此相邻,因此迭代导致的缓存丢失最小。

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

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

所以说只要能用列表的情况下尽量不要使用字典即可。


🏳️‍🌈避免运行时修改Transform的父节点

Transform组件父-子关系 操作起来像动态数组,因此Unity尝试将所有共享相同元素的Transform 按顺序存储在预先分配的内存缓存区的内存中,并在Hierarchy窗口中根据父元素下面的深度进行排序。

这种数据结构允许在整个组中进行更快的迭代,这对物理和动画等多个子系统来说非常有利。

但也是有缺点的,如果将一个GameObject的父对象重新指定为另一个对象,父对象必须将新的子对象放入预先分配的内存缓存区中,并根据新的深度来对这些Transform进行排序。

如果父对象没有预先分配足够的空间来容纳新的子对象,就要扩展缓存区,以便以深度优先的顺序容纳新的子对象及其所有的子对象。对于一些较深、复杂的GameObject结构,就需要一些时间来完成。

我们在使用代码 GameObject.Instantiate() 实例化新的游戏对象时,它有一个参数是为这个新的游戏对象设置一个父物体的Transform,默认值为null,把Transform放在Hierarchy窗口的根元素下。

在Hierarchy窗口根元素下所有的Transform都需要分配一个缓冲区来存储他当下的子元素以及以后可能添加的子元素。

但是如果在实例化之后立即将Transform的父元素重新修改为另一个元素,它将丢弃刚才分配的缓冲区。为了避免这种情况,应该在实例化游戏对象的时候直接将父元素的Transform 参数赋值给GameObject.Instantiate() 跳过这个缓冲区分配步骤。

还有一种方法是让根Transform在需要之前就预先分配一个更大的缓冲区,这样就不需要在同一帧中拓展缓冲区了。


🏳️‍🌈禁用未使用的脚本和对象

我们在开发的过程中场景会慢慢变得越来越复杂,特别是一些大型项目更是如此。

当我们在Update()中调用代码的对象越多,整个项目跑起来就会越慢。

然而在我们的程序实际运行中的时候,有些在玩家视野之外的游戏对象就显得不是特别重要了。

比如一些 第一人称之类的游戏,一些不在我们视野中的对象就可以选择性的临时禁用,而不会对游戏过程产生明显的影响。

一般来说有两种情况可以选择禁用:通过可见性禁用对象通过距离禁用对象

🌻通过可见性禁用对象

这种情况就是我们上面说的那种情况,当组件或者游戏对象不可见时我们就禁用它。

Unity带有内置的 渲染 功能,以避免渲染对玩家的相机视图不可见的对象(视锥剔除),避免渲染隐藏在其他对象后面的对象(遮挡剔除)。

但这些只是渲染层面的优化,不影响在CPU是哪个执行任务的组件,比如AI脚本、用户界面和游戏逻辑等等

所以我们需要自己去控制这种行为,比如使用 OnBecameVisiable()OnBecameInvisiable回调。

这两个回调方法的作用是 在可渲染对象对于场景中的 任何相机变得可见不可见时调用的

由于可见性回调与渲染管线通信,因此GameObject必须附加一个可渲染的组件,比如 MeshRendererSkinnedMeshRenderer

下面来看一个示例:

    public GameObject Test2;
    private void OnBecameInvisible()
    
        Test2.SetActive(false);
        Debug.Log("处于不可见范围");

    
    private void OnBecameVisible()
    
        Test2.SetActive(true);
        Debug.Log("处于可见范围");
    

效果如下:

当物体不在 Game视图Scene视图 的时候 就会执行OnBecameInvisible的回调进行一些处理,反之亦然。

这里有一个点要注意:

在编辑器中执行时,这个对象或者组件必须 既不在Game视图,也不再Scene视图时 才能执行回调。
而且我们应该将组件放到另一个游戏对象上(如示例所示),使可渲染的对象始终可见。

意思就是说,摄像机需要一个图形来查看和触发回调,我们不能在禁用的对象上执行该回调方法。

🌻通过距离禁用对象

除了上面那个不在可视范围内禁用对象之外,还有一种是根据距离来判断。

比如我们在程序运行的时候,某些AI对象离我们很远,我们只需要看到它即可,并不需要它处理一些操作。

那这个时候就可以在脚本中加一个判定了,定期检查与目标的总距离,如果偏远就禁用自己。

示例如下:

    public GameObject _target;

    private float _maxDis;
    private float _dis;

    private void Start()
    
        StartCoroutine(DisableDistance());
    
    IEnumerator DisableDistance()
    
        while (true)
        
            //计算目标位置与自身位置的距离平方
            _dis = (transform.position - _target.transform.position).sqrMagnitude;
            if (_dis < (_maxDis * _maxDis))
            
                enabled = true;
            
            else
            
                enabled = false;
            
               yield return new WaitForSeconds(2);
        
    

上述代码使用了协程来判断,如果距离超过就禁用,如果回到这个范围内就重新启用。

这里计算距离的时候使用了距离的平方来比较而不是远距离,下面就来说一下原因。


🏳️‍🌈使用距离的平方而不是距离

由于CPU比较擅长将浮点数相乘,但是不擅长计算它们的平方根

每次使用 magnitude属性或者 Distance() 方法来求Victor3的距离时,都会要求他执行平方根计算(根据勾股定理)

与一些其他类型的向量数学计算 相比 这会消耗大量的CPU开销。

Vector3类 提供了 aqrMagnitude 属性,它提供同样可作为距离的结果,就是平方值。

这样的话我们可以将进行比较的距离也进行平方处理,然后两者进行比较即可,避免昂贵的平方根开销。

示例如下:

        //使用普通的方式,即需要平方根计算
        float _dis = (transform.position - _target.transform.position).magnitude;
        if (_dis < targatDistance)
        
            //逻辑处理
        

上述代码使用下面这个进行替换,得到的结果基本一致,避免了进行复杂计算。

       //使用距离平方的值进行比较
       float _dis = (transform.position - _target.transform.position).sqrMagnitude;
        if (_dis < (targatDistance * targatDistance))
        
            //逻辑处理
        

结果可能会失去一些使用平方根的精度,因为该值调整为具有不同密度的可表示数字区域

在大多数情况下,两者的值非常接近,而且这样计算带来的性能收益还是很客观的。

但是也是有缺陷的,如果这个精度带来的损失不重要,那我们值得使用这种方法。
如果精度非常重要,那我们就要有所取舍了,视情况而定选择比较方法。


💬总结

  • 本文接着介绍了 Unity引擎 中许多脚本编写的实践方法,目的 是已经证实他们是导致性能问题的原因之后,用来改正提升性能

  • 其中有些技术需要在实现某些功能之前就进行一些预先的考虑和分析调查,因为通常会给新开发的人员带来额外的风险和混淆代码库。

  • 还是那个问题,我们在选择进行优化时,需要考虑 优化带来的资源损耗,比如时间、人力等等。

  • 脚本代码优化策略 就到这里结束了,本部分共有两篇文章
    上一篇在这:【Unity优化篇】 | Unity脚本代码优化策略,快速获取 游戏对象 和 组件 的方法

  • 将脚本中的一些优化策略学习了一下,当然还有更多的优化技巧,两篇文章很难全部都来细细说明。

  • 还是那句话,实践出真知,有些问题只有当我们真正遇到的时候才能真正的去认识和学会它。

  • 那本篇文章到这里就结束了,后续关于脚本部分研究深刻的话还考虑会再出一篇的,后面就先接着之前的优化篇大纲来学习啦!


🚀往期优质文章分享


🚀 优质专栏分享 🚀
  • 🎄如果感觉文章看完了不过瘾,可以来我的其他 专栏 看一下哦~
  • 🎄比如以下几个专栏:Unity基础知识学习专栏Unity游戏制作专栏Unity实战类项目 算法学习专栏
  • 🎄可以学习更多的关于Unity引擎的相关内容哦!直接点击下面颜色字体就可以跳转啦!
❤️ 游戏制作专栏 ❤️
🧡 Unity系统学习专栏 🧡
💛 Unity实战类项目 💛
💚 算法千题案例 💚
💙 Python零基础到入门 💙

【游戏开发爱好者社区】活动进行中,每周打卡送书籍等礼品,期待你的加入

🚀 社区活动,重磅来袭 🚀

【游戏开发爱好者社区】在本周重磅新推出【每日打卡】活动

🎁 新玩法,奖励升级!游戏开发爱好者社区:https://bbs.csdn.net/forums/unitygame

社区中心思想今天你学到了什么?

在社区你可以做些什么: 每日强化知识点,白嫖书籍礼品!

一个人可以走的很快,一群人才能走的更远!🔥爆C站的游戏开发爱好者社区欢迎您的加入!

更多白嫖活动详情:https://bbs.csdn.net/forums/unitygame?typeId=19603


温馨提示: 点击下面卡片可以获取更多编程知识,包括各种语言学习资料,上千套PPT模板和各种游戏源码素材等等资料。更多内容可自行查看哦!

以上是关于Unity优化篇 | Unity脚本代码优化策略,空引用快速检索使用合适的数据结构禁用脚本和对象等 性能优化方法的主要内容,如果未能解决你的问题,请参考以下文章

Unity 优化篇 | 优化专栏《导航帖》,全面学习Unity优化技巧,让我们的Unity技术上升一个档次

Unity 优化篇 | 优化专栏《导航帖》,全面学习Unity优化技巧,让我们的Unity技术上升一个档次

Unity优化篇| Unity3D场景 常用优化策略,遮挡剔除层消隐距离技术 和 LOD多层次细节

Unity优化篇| Unity3D场景 常用优化策略,遮挡剔除层消隐距离技术 和 LOD多层次细节

Unity技巧Unity中的优化技术

Unity3D性能优化之资源导入标准和属性设置篇