zoukankan      html  css  js  c++  java
  • 优化Unity游戏项目的脚本(下)

    金秋9月,我们祝所有的老师们:教师节快乐 !

    今天,我们继续分享来自捷克的开发工程师Ondřej Kofroň,分享C#脚本的一系列优化方法。

    优化Unity游戏项目的脚本(上)中,我们介绍了如何查找C#脚本中的问题,以及垃圾回收的处理。本文我们将介绍如何减少C#脚本的执行时间

    第二部分:减少脚本的执行时间

    如果代码不经常调用,这部分提到的一些规则可能不会产生明显的作用。在我们的项目中,我们有一个每帧执行的大型循环,因此在该代码中,即使做很小的改动,也会产生很明显的作用。

    如果使用方法不当或在不合适的情形下使用,部分改动可能会产生更差的执行时间。每次对代码进行优化方面的改动后,要记得查看性能分析器,确保改动有理想的效果。

    这部分的一些规则可能会导致代码变得难以理解,甚至可能会破坏最佳编程实践。这部分的许多规则和第一部分的规则有所重复。和不分配垃圾的代码相比,垃圾分配代码通常有更差的执行效果,建议在阅读这部分内容之前,先仔细阅读本文的第一部分

    规则1:使用合适的执行顺序

    将代码从FixedUpdate,Update和LateUpdate方法转移到Start和Awake方法中。虽然这听起来不现实,但如果深入分析代码,你会发现有数百行代码可以移动到仅执行一次的方法中。

    在我们的项目中,这类代码通常和下面操作有关:

    • GetComponent<> Calls

    • 每帧返回相同结果的计算过程

    • 重复实例化相同的对象,通常这类对象为List列表

    • 寻找某些游戏对象

    • 获取对Transform的引用,使用其它访问器

    下面是我们从Update方法移动到Start方法的代码。

    //将GetComponent留在Update方法必须有很好的理由
    
    gameObject.GetComponent<LineRenderer>();
    
    gameObject.GetComponent<CircleCollider2D>();
    
     
    
    //每帧返回相同结果的计算过程示例
    
    Mathf.FloorToInt(Screen.width / 2);
    
     
    
    var width = 2f * mainCamera.orthographicSize * mainCamera.aspect;
    
     
    
    var castRadius = circleCollider.radius * transform.lossyScale.x;
    
     
    
    var halfSize = GetComponent<SpriteRenderer>().bounds.size.x / 2f;
    
     
    
    //寻找对象
    
    var levelObstacles = FindObjectsOfType<Obstacle>();
    
    var levelCollectibles = FindGameObjectsWithTag("COLLECTIBLE");
    
     
    
    //引用
    objectTransform = gameObject.transform;
    mainCamera = Camera.main;

    规则2:仅在需要时运行代码

    在我们的项目中,这种情况大多和更新UI的脚本有关。下面是我们修改的代码实现,它们作用是显示关卡中“可收集物品”的当前状态。

    //未优化的代码 
    
    Text text;
    GameState gameState;void Start()
    {
        gameState = StoreProvider.Get<GameState>();    
        text = GetComponent<Text>();
    }void Update()
    {
        text.text = gameState.CollectedCollectibles.ToString();
    }
    

      

    因为我们在每关中只有少量可收集物品,每帧都修改UI文本不会产生很大作用,因此我们仅在实际数字变化时改变文本。

    //优化后的代码 
    
    Text text;
    GameState gameState;
    int collectiblesCount;
    
     
    
    void Start()
    {
        gameState = StoreProvider.Get<GameState>();    
        text = GetComponent<Text>();
        collectiblesCount = gameState.CollectedCollectibles;
    }
    
     
    
    void Update()
    {
        if(collectiblesCount != gameState.CollectedCollectibles) {
    
    
            //该代码每关只运行5次
            collectiblesCount = gameState.CollectedCollectibles;
            text.text = collectiblesCount.ToString();
        }
    
    }
    
     
    

      

    这样的的代码性能会更好,特别在代码作用不只是简单的UI变化时,会有更好的效果。如果想要更复杂的解决方法,建议通过使用C#事件实现观察者设计模式。

    但对于对我们而言还不够好,我们希望实现完全通用的解决方案,因此我们创建了在Unity实现Flux架构的代码库。

    这样可以实现非常简单的解决方案,我们把所有游戏状态都存储到“Store”对象,在任何状态发生改变时,所有UI元素和其它组件都会得到通知,然后它们会对改变做出反应,整个过程不需要在Update方法使用任何代码。

    了解C#事件:

    https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/events/

    了解观察者设计模式:

    https://en.wikipedia.org/wiki/Observer_pattern

    了解Flux架构:

    https://facebook.github.io/flux/

    规则3:小心循环代码

    这条规则和第一部分的第9条规则相同。如果代码中有循环会迭代大量元素,请使用本文第二个部分提到的所有规则,来改进循环代码的性能。

    规则4:使用For循环代替Foreach循环

    Foreach循环很容易编写,但是执行起来却很复杂。Foreach循环在内部会使用枚举器来迭代特定数据集,并返回数值。

    这比在For循环迭代索引复杂很多。因此在我们的项目中,只要可以的话,我们都会把Foreach循环改为For循环,如下所示。

    //未优化的代码
    foreach (GameObject obstacle in obstacles)

    //优化后的代码

    var count = obstacles.Count;
    for (int i = 0; i < count; i++) {
        obstacles[i];
    }

    在我们的大型For循环中,这项改动的效果非常明显。通过使用简单的For循环,我们实现了速度比原来快2倍的代码。

    规则5:使用Array数组代替List列表

    在代码中,我们发现大多数列表有固定的长度,或是可以计算出最大成员数量。因此我们使用数组重新实现了这些列表,在特定情况下,可以在迭代数据的时候得到原先2倍的速度。

    在某些情况下,我们无法避免使用列表或其它复杂的数据结构。常见的情况是:需要经常添加或移除元素的时候,使用列表的效果更好的时候。通常来说,我们会对固定大小的列表使用数组。

    规则6:使用Float运算替代Vector运算

    Float运算和Vector运算的区别不是很明显,除非像我们一样进行上千次运算,因此对我们来说,这项改动的性能提升效果非常明显。

    改动如下所示。

    Vector3 pos1 = new Vector3(1,2,3);

    Vector3 pos2 = new Vector3(4,5,6);

    //未优化的代码

    var pos3 = pos1 + pos2;

    //优化后的代码

    var pos3 = new Vector3(pos1.x + pos2.x, pos1.y + pos2.y, ......);

    Vector3 pos1 = new Vector3(1,2,3);

    //未优化的代码
    var pos2 = pos1 * 2f;

    //优化后的代码
    var pos2 = new Vector3(pos1.x * 2f, pos1.y * 2f, ......);

    规则7:寻找对象属性

    一定要考虑是否必须使用GameObject.Find()方法。该方法会产生很大开销,占据大量时间。最好不在Update方法中使用GameObject.Find()方法。

    我们发现大多数GameObject.Find()调用可以替换为编辑器中直接引用的关联,这是更好的方法。

    //未优化的代码

    GameObject player;

    void Start()
    {
        player = GameObject.Find("PLAYER");
    }

    //优化后的代码

    //在编辑器中把该引用指定给玩家对象

    [SerializeField]
    GameObject player;

    void Start()
    {
    }

    如果无法这样处理,开发者至少应该考虑使用Tag标签,通过使用GameObject.FindWithTag,根据标签来找到对象。

    总体上说,三种方法的优先级为:直接引用 > GameObject.FindWithTag() > GameObject.Find()。

    规则8:只处理相关对象

    在我们的项目中,该规则对使用RayCasts和CircleCasts等函数的碰撞检查功能有明显的效果。我们不必在代码中检测所有碰撞并决定哪些对象是相关的,只需把游戏对象都移动到合适的图层即可,这样我们可以只对相关对象计算碰撞。

    下面是示例代码。

    //未优化的代码
    
    void DetectCollision()
    
    {
    
        var count = Physics2D.CircleCastNonAlloc(
    
           position, radius, direction, results, distance);
    
        for (int i = 0; i < count; i++) {
    
           var obj = results[i].collider.transform.gameObject;
    
           if(obj.CompareTag("FOO")) {
    
               ProcessCollision(results[i]);
    
           }
    
        }
    
    }
    
     
    //优化后的代码
    
    //我们把所有带有标签FOO的对象都放入了相同的图层
    
    void DetectCollision()
    
    {
    
      
    
        //8是相应图层的编号
    
        var mask = 1 << 8;
    
        var count = Physics2D.CircleCastNonAlloc(
    
           position, radius, direction, results, distance, mask);
    
        for (int i = 0; i < count; i++) {
    
           ProcessCollision(results[i]);
    
        }
    
    }
    

      

    规则9:使用标签属性

    标签非常实用,可以提升代码的性能,但请记住:只有一种正确方法适合比较对象标签。

    //未优化的代码

    gameObject.Tag == "MyTag";

    //优化后的代码

    gameObject.CompareTag("MyTag");

    规则10:小心棘手的摄像机

    使用Camera.main很简单,但是这种操作的性能非常糟糕。这是因为在每个Camera.main调用背后,Unity其实会执行FindGameObjectsWithTag()来获取结果,因此频繁调用Camera.main并不好。

    最好的解决方法是在Start或Awake方法中缓存Camera.main的引用。

    //未优化的代码

    void Update()

    {

        Camera.main.orthographicSize //对摄像机的一些操作

    }

    //优化后的代码

    private Camera cam;

    void Start()

    {

        cam = Camera.main;

    }

    void Update()

    {

        cam.orthographicSize //对摄像机的一些操作

    }

    规则11:使用LocalPosition替代Position

    在代码允许的位置,为获取函数(Getter)和设置函数(Setter)使用Transform.LocalPosition替代Transform.Position。

    这样的原因是:每次Transform.Position调用的背后,都会有更多操作要执行,包括在调用获取函数时计算全局位置,或是在调用设置函数时从全局位置计算出本地位置。

    在项目中,我们发现在出现Transform.Position的几乎所有情况中都可以用LocalPosition替代Transform.Position,无需在代码中做其它改动。

    规则12:不要使用LINQ

    这条规则已经第一部分讲解,别使用LINQ就好。

    规则13:不要害怕破坏最佳实践

    有时候,即使是一个简单的函数调用,都可能造成过多的开销。在这种情况下,我们应该考虑使用代码内联。

    代码内联是什么?代码内联意味着,我们可以把代码从函数中取出,把这些代码直接复制到打算使用函数的位置,从而避免额外的方法调用。

    因为代码内联会在编译时自动完成,所以在大多数情况下,这样不会产生太明显的效果。在编译器决定代码是否内联时,会使用特定的规则。例如:Virtual方法从不会内联,更多详细信息,请访问:

    https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html。

    我们需要打开性能分析器,在实际设备上运行游戏,从而了解是否仍有改进的空间。

    在项目中,我们发现一些函数可以通过代码内联实现更好的性能,特别是游戏的大型For循环中的函数。

    小结

    通过使用本文介绍的规则,我们在iOS游戏中轻松实现了稳定的60fps,即使在iPhone 5S也有这样的运行效果。

    本文的部分规则针对我们的用例而使用,但我认为在进行编程或代码评审时,开发者应该考虑这些规则,从而避免在后期阶段出现问题。在考虑性能因素的同时编写代码会比之后重构大量代码更简单。

  • 相关阅读:
    使用 Dockerfile 定制镜像
    UVA 10298 Power Strings 字符串的幂(KMP,最小循环节)
    UVA 11090 Going in Cycle!! 环平均权值(bellman-ford,spfa,二分)
    LeetCode Best Time to Buy and Sell Stock 买卖股票的最佳时机 (DP)
    LeetCode Number of Islands 岛的数量(DFS,BFS)
    LeetCode Triangle 三角形(最短路)
    LeetCode Swap Nodes in Pairs 交换结点对(单链表)
    LeetCode Find Minimum in Rotated Sorted Array 旋转序列找最小值(二分查找)
    HDU 5312 Sequence (规律题)
    LeetCode Letter Combinations of a Phone Number 电话号码组合
  • 原文地址:https://www.cnblogs.com/jiahuafu/p/11643032.html
Copyright © 2011-2022 走看看