我们如何在Unity中优化脚本

有许多出色的Unity性能文章和教程。 我们不打算用本文替代或改进它们,这只是我们阅读这些文章后所采取的步骤的简要摘要,以及使我们能够解决问题的步骤。 我强烈建议您至少在https://learn.unity.com/上学习材料。

在开发游戏的过程中,我们遇到了一些问题,这些问题有时会导致游戏过程受到阻碍。 在Unity Profiler中花了一些时间之后,我们发现了两种类型的问题:

  • 未优化的着色器
  • C#中未优化的脚本

大多数问题是由第二组引起的,所以我决定在本文中重点介绍C#脚本(也许还因为我一生中没有编写任何着色器)。

寻找弱点


本文的目的不是编写有关使用事件探查器的教程; 我只是想谈一谈我们在分析过程中主要感兴趣的内容。

Unity Profiler始终是找出脚本延迟原因的最佳方法 。 我强烈建议直接在设备中而不是在编辑器中对游戏进行性能分析 。 由于我们的游戏是为iOS创建的,因此我需要连接设备并使用图像中所示的“构建设置”,然后探查器自动连接。


构建配置文件进行分析

如果您尝试通过Google搜索“ Unity中的随机滞后”或其他类似请求,就会发现大多数人建议您专注于垃圾收集 ,这正是我所做的。 每次您停止使用某个对象(类实例)时都会生成垃圾,此后,Unity垃圾收集器会不时启动以清理混乱并释放内存,这会耗费大量时间并导致帧速率下降。

如何在探查器中找到垃圾脚本?


只需选择CPU使用率->选择层次结构视图->按GC Alloc排序


垃圾收集的探查器选项

您的任务是在游戏场景的GC分配列中获得一些零。

另一个好的方法是按时间ms (运行时) 对条目进行排序并优化脚本,以使它们花费的时间尽可能少。 这一步对我们产生了巨大的影响,因为其中一个组件包含一个大的for循环 ,该循环要花很长时间才能完成(是的,我们还没有找到摆脱循环的方法),因此优化所有脚本的执行时间对我们来说绝对必要,因为我们需要在昂贵的for循环中节省运行时间,同时保持60 fps的稳定频率。

根据概要分析数据,我将优化分为两部分:

  • 垃圾处理
  • 缩短交货时间

第1部分:战斗垃圾


在这一部分中,我将告诉您我们为摆脱垃圾所做的工作。 这是任何开发人员都应该理解的最基本的知识; 在每个合并/合并请求中,它们已成为我们日常分析的重要组成部分。

第一条规则:Update方法中没有新对象


理想情况下, Update,FixedUpdate和LateUpdate方法不应包含“ new”关键字 。 您应该始终使用现有的资源。

有时在某些内部Unity方法中隐藏创建新对象的过程 ,因此它并不是那么明显。 我们稍后再讨论。

第二条规则:一次创建并重用!


本质上,这意味着您应该为Start和Awake方法中可以使用的所有内容分配内存。 此规则与第一个规则非常相似。 实际上,这只是从Update方法中消除“ new”关键字的另一种方法。

代码如下:

  • 创建新实例
  • 寻找任何游戏对象

您应该始终尝试从Update方法转到Start或Awake。

以下是我们所做更改的示例:

在Start方法中为列表分配内存,清除(清除)并在必要时重新使用。

//Bad code private List<GameObject> objectsList; void Update() { objectsList = new List<GameObject>(); objectsList.Add(......) } //Better Code private List<GameObject> objectsList; void Start() { objectsList = new List<GameObject>(); } void Update() { objectsList.Clear(); objectsList.Add(......) } 

存储链接并重新使用它们,如下所示:

 //Bad code void Update() { var levelObstacles = FindObjectsOfType<Obstacle>(); foreach(var obstacle in levelObstacles) { ....... } } //Better code private Object[] levelObstacles; void Start() { levelObstacles = FindObjectsOfType<Obstacle>(); } void Update() { foreach(var obstacle in levelObstacles) { ....... } } 

这同样适用于FindGameObjectsWithTag方法或其他任何返回新数组的方法。

第三条规则:当心字符串,避免串联它们


当涉及到创建垃圾时,这些行很糟糕。 即使是最简单的字符串操作也会造成大量垃圾。 怎么了 字符串只是数组,而这些数组是不可变的。 这意味着每次连接两行时,都会创建一个新数组,而旧数组会变成垃圾。 幸运的是,可以使用StringBuilder来避免或减少此类垃圾的产生。

这是如何改善情况的示例:

 //Bad code void Start() { text = GetComponent<Text>(); } void Update() { text.text = "Player " + name + " has score " + score.toString(); } //Better code void Start() { text = GetComponent<Text>(); builder = new StringBuilder(50); } void Update() { //StringBuilder has overloaded Append method for all types builder.Length = 0; builder.Append("Player "); builder.Append(name); builder.Append(" has score "); builder.Append(score); text.text = builder.ToString(); } 

上面显示的示例一切都很好,但是仍有许多改进代码的可能性。 如您所见,几乎整个字符串都可以视为静态字符串。 我们将字符串分为两个UI.Text对象的两部分。 首先,一个仅包含静态文本“ Player” +名称+“具有分数” ,可以在Start方法中分配该静态文本,第二个包含分数值,该分数在每帧中都会更新。 始终使静态线真正静态,并以Start或Awake方法生成它们 。 进行此改进之后,几乎一切都井井有条,但是在调用Int.ToString(),Float.ToString()等时仍然会产生一些垃圾。

我们通过为所有可能的行生成并预分配内存来解决此问题。 这似乎是对内存的愚蠢浪费,但是这种解决方案非常适合我们的需求并完全解决了问题。 因此,最后,我们获得了一个静态数组,可以使用索引直接访问该数组,并采用表示数字的所需字符串:

 public static readonly string[] NUMBERS_THREE_DECIMAL = { "000", "001", "002", "003", "004", "005", "006",.......... 

第四条规则:访问方法返回的缓存值


这可能非常困难,因为即使是如下所示的简单访问器方法,也会产生垃圾:

 //Bad Code void Update() { gameObject.tag; //or gameObject.name; } 

尝试避免在Update方法中使用访问方法。 在Start方法中仅调用一次访问方法,并缓存返回值。

通常,我建议不要在Update方法中调用任何字符串访问方法或数组访问方法 。 在大多数情况下, 只需在Start方法中获取链接即可

这是另一个未优化的访问方法代码的两个更常见的示例:

 //Bad Code void Update() { //Allocates new array containing all touches Input.touches[0]; } //Better Code void Update() { Input.GetTouch(0); } //Bad Code void Update() { //Returns new string(garbage) and compare the two strings gameObject.Tag == "MyTag"; } //Better Code void Update() { gameObject.CompareTag("MyTag"); } 

第五条规则:使用不分配内存的函数


对于某些Unity功能,可以找到非内存替代方案。 在我们的案例中,所有这些功能都与物理学有关。 我们的碰撞识别基于

 Physics2D. CircleCast(); 

对于这种特殊情况,您可以找到一个名为

 Physics2D. CircleCastNonAlloc(); 

许多其他功能也有类似的替代方法,因此请始终查看NonAlloc功能的文档

第六条规则:不要使用LINQ


只是不要这样做。 我的意思是,您不需要在经常运行的任何代码中使用它。 我知道使用LINQ时,代码更易于阅读,但是在许多情况下,此类代码的性能和内存分配都很糟糕。 当然,有时可以使用它,但是老实说,在我们的游戏中,我们根本不使用LINQ。

第七条规则:创建一次然后重用,第2部分


这次我们谈论的是池化对象。 我将不讨论池的详细信息,因为已经说过很多次了,例如,学习本教程: https : //learn.unity.com/tutorial/object-pooling

在我们的例子中,使用以下对象池脚本。 我们生成的关卡充满了在一定时间内存在的障碍,直到玩家通过关卡的这一部分。 如果满足某些条件,则会从预制件中创建此类障碍的实例。 该代码在Update方法中。 就内存和运行时而言,此代码完全无效。 我们通过生成40个障碍物的池来解决该问题:如有必要,我们从池中获取障碍物,并在不再需要该对象时将其返回到池中。

第八条规则:更专注于包装转换(拳击)!


拳击产生垃圾! 但是什么是拳击? 通常,将值类型(int,float,bool等)传递给期望对象类型为Object的函数时,就会发生装箱。

这是我们需要在项目中修复的拳击示例:

我们在项目中实现了自己的消息传递系统。 每条消息可以包含无限量的数据。 数据存储在定义如下的字典中:

 Dictionary<string, object> data; 

我们还有一个设置器,用于设置此字典中的值:

 public Action SetAttribute(string attribute, object value) { data[attribute] = value; } 

这里的拳击非常明显。 您可以按以下方式调用该函数:

 SetAttribute("my_int_value", 12); 

然后对值“ 12”进行装箱并生成垃圾。

我们通过为每种原始类型创建单独的数据容器来解决该问题,并且以前的Object容器仅用于引用类型。

 Dictionary<string, object> data; Dictionary<string, bool> dataBool; Dictionary<string, int> dataInt; ....... 

对于每种数据类型,我们还有单独的设置器:

 SetBoolAttribute(string attribute, bool value) SetIntAttribute(string attribute, int value) 

所有这些设置器的实现方式都使它们调用相同的通用函数:

 SetAttribute<T>(ref Dictionary<string, T> dict, string attribute, T value) 

拳击问题已经解决!

在文章https://docs.microsoft.com/cs-cz/dotnet/csharp/programming-guide/types/boxing-and-unboxing中阅读有关此内容的更多信息。

第九条规则:周期总是令人怀疑


该规则与第一和第二非常相似。 出于性能和内存原因,只需尝试从循环中删除所有可选代码。

在一般情况下,我们努力摆脱Update方法中的循环,但是如果我们不能没有循环,那么我们至少将避免在此类循环中分配任何内存。 因此,请遵循规则1–8,并将其应用于一般的循环 ,而不仅仅是Update方法。

规则10:外部库中无垃圾


如果发现部分垃圾是由从资产存储下载的代码生成的,那么此问题有许多解决方案。 但是在进行逆向工程和调试之前,只需返回到Asset存储并更新库。 在我们的案例中,所有使用的资产仍得到作者的支持,他们继续发布可改善性能的更新,因此这解决了我们所有的问题。 依赖性必须相关! 我宁愿摆脱图书馆,也不愿不受支持。

第2部分:最大化运行时间


如果很少调用该代码,则上述某些规则会有细微的差别。 我们的代码在每个框架中运行一个大循环,因此即使这些小的更改也会产生巨大的影响。

如果使用不当或在错误的情况下使用其中的某些更改,可能会导致运行时间更糟。 在代码中输入每个优化之后,请始终检查探查器,以确保您朝正确的方向移动

老实说,其中一些规则导致可读性差得多的代码 ,有时甚至违反建议 ,例如,以下规则之一提到的代码嵌入。

其中许多规则与本文第一部分中介绍的规则重叠。 通常,与没有垃圾生成的代码相比,垃圾生成代码的性能较低。

第一条规则:正确的执行顺序


将代码从FixedUpdate,Update,LateUpdate方法移动到Start和Awake方法 。 我知道这听起来很疯狂,但是请相信我,如果您深入研究代码,会发现数百行代码可以移至只能执行一次的方法。

就我们而言,此代码通常与

  • 调用GetComponent <>
  • 在每一帧中实际返回相同结果的计算
  • 同一对象的多个实例,通常列出
  • 搜索游戏对象
  • 获取到Transform的链接并使用其他访问方法

这是我们从Update方法移动到Start方法的示例代码列表:

 //There must be a good reason to keep GetComponent in Update gameObject.GetComponent<LineRenderer>(); gameObject.GetComponent<CircleCollider2D>(); //Examples of calculations returning same result every frame 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; //Finding objects var levelObstacles = FindObjectsOfType<Obstacle>(); var levelCollectibles = FindGameObjectsWithTag("COLLECTIBLE"); //References objectTransform = gameObject.transform; mainCamera = Camera.main; 

第二条规则:仅在必要时执行代码


在我们的案例中,这主要与UI更新脚本有关。 这是我们如何更改代码实现的示例,该代码在级别上显示收集到的项目的当前状态。

 //Bad code Text text; GameState gameState; void Start() { gameState = StoreProvider.Get<GameState>(); text = GetComponent<Text>(); } void Update() { text.text = gameState.CollectedCollectibles.ToString(); } 

由于在每个级别仅收集少量项目,因此更改每个框架中的UI文本没有任何意义。 因此,我们仅在数字更改时才更改文本。

 //Better code Text text; GameState gameState; int collectiblesCount; void Start() { gameState = StoreProvider.Get<GameState>(); text = GetComponent<Text>(); collectiblesCount = gameState.CollectedCollectibles; } void Update() { if(collectiblesCount != gameState.CollectedCollectibles) { //This code is ran only about 5 times each level collectiblesCount = gameState.CollectedCollectibles; text.text = collectiblesCount.ToString(); } } 

这段代码要好得多,特别是如果操作比仅更改UI要复杂得多时。

如果您正在寻找更全面的解决方案,我建议使用C#事件( https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/events/ )来实现Observer模板

无论如何,对于我们来说这还不够,我们想实现一个完全通用的解决方案,因此我们创建了一个在Unity中实现Flux的库。 这导致了一个非常简单的解决方案,其中将游戏的整个状态存储在“ Store”对象中,并且在状态更改时通知所有UI元素和其他组件,并且无需Update方法中的代码即可对此更改做出反应。

第三条规则:周期总是令人怀疑


这与我在本文的第一部分中提到的规则完全相同。 如果代码中有一个循环迭代地绕过大量元素的循环,则为了提高循环性能,请使用本文两部分的两条规则。

第四条规则:胜于雄辩


Foreach循环很容易编写,但是执行起来“非常困难”。 在Foreach循环内,Enumerator用于迭代处理数据集并返回值。 这比在简单的For循环中遍历索引更为复杂。

因此,在我们的项目中,我们尽可能将Foreach循环替换为For:

 //Bad code foreach (GameObject obstacle in obstacles) //Better code var count = obstacles.Count; for (int i = 0; i < count; i++) { obstacles[i]; } 

在我们的for循环较大的情况下,此更改非常重要。 一个简单的for循环可使代码加速两次

第五条规则:数组比列表更好


在我们的代码中,我们发现大多数列表都是固定长度的,或者我们可以计算最大元素数。 因此,我们基于数组重新实现了它们,在某些情况下,这导致对数据进行迭代的速度加快了两倍。

在某些情况下,无法避免列表或其他复杂的数据结构。 碰巧您经常必须添加或删除元素,在这种情况下,最好使用列表。 但通常, 数组应始终用于定长列表

第六条规则:浮动操作优于矢量操作


如果您不执行数千个这样的操作(在我们的案例中就是这种情况),则这种差异几乎不会引起注意,因此对我们而言,生产率的提高被认为是巨大的。

我们进行了类似的更改:

 Vector3 pos1 = new Vector3(1,2,3); Vector3 pos2 = new Vector3(4,5,6); //Bad code var pos3 = pos1 + pos2; //Better code var pos3 = new Vector3(pos1.x + pos2.x, pos1.y + pos2.y, ......); Vector3 pos1 = new Vector3(1,2,3); //Bad code var pos2 = pos1 * 2f; //Better code var pos2 = new Vector3(pos1.x * 2f, pos1.y * 2f, ......); 

第七条规则:正确寻找物体


始终考虑一下是否真的需要使用GameObject.Find()方法。 这种方法很重并且要花费大量的时间。 您永远不要在Update方法中使用此方法。 我们发现,我们的大多数Find调用都可以在编辑器中替换为直接链接 ,这当然更好。

 //Bad Code GameObject player; void Start() { player = GameObject.Find("PLAYER"); } //Better Code //Assign the reference to the player object in editor [SerializeField] GameObject player; void Start() { } 

如果无法做到这一点,则至少考虑使用标签(Tag)并使用GameObject.FindWithTag通过其标签搜索对象

因此,在一般情况下: 直接链接> GameObject.FindWithTag()> GameObject.Find()

第八条规则:仅适用于相关对象


在我们的案例中,这对于使用RayCast-s(CircleCast等)识别碰撞非常重要。 无需识别冲突并确定其中哪些冲突在代码中很重要, 我们将游戏对象移到了适当的层,这样我们就可以仅为必要的对象计算冲突。

这是一个例子

 //Bad Code 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]); } } } //Better Code //We added all objects with tag FOO into the same layer void DetectCollision() { //8 is number of the desired layer var mask = 1 << 8; var count = Physics2D.CircleCastNonAlloc( position, radius, direction, results, distance, mask); for (int i = 0; i < count; i++) { ProcessCollision(results[i]); } } 

第九条规则:正确使用标签


毫无疑问,标签非常有用并且可以提高代码性能,但是请记住, 只有一种正确的方法可以比较对象标签

 //Bad Code gameObject.Tag == "MyTag"; //Better Code gameObject.CompareTag("MyTag"); 

第十规则:当心用相机的把戏!


使用Camera.main非常容易,但是此操作的性能非常差。 原因是在每次调用Camera.main的幕后,Unity引擎实际上执行了FindGameObjectsWithTag()结果,因此我们已经知道您不需要经常调用它,最好通过在Start方法中缓存链接来解决此问题。还是醒着

 //Bad code void Update() { Camera.main.orthographicSize //Some operation with camera } //Better Code private Camera cam; void Start() { cam = Camera.main; } void Update() { cam.orthographicSize //Some operation with camera } 

第十一条规则:LocalPosition比位置更好


尽可能将Transform.LocalPosition用于getter和setter而不是Transform.Position 。 在每个Transform.Position调用中,执行更多操作,例如,在getter调用的情况下计算全局位置,在setter调用的情况下从全局计算局部位置。 在我们的项目中,事实证明您可以在99%的情况下使用Transform.Position使用LocalPositions,而无需在代码中进行任何其他更改。

第十二条规则:不要使用LINQ


在第一部分中已经对此进行了讨论。 只是不使用它,仅此而已。

第十三条规则:不要害怕(有时)违反规则


有时,甚至调用一个简单函数也可能太昂贵。 在这种情况下,您应该始终考虑嵌入代码(代码内联)。 这是什么意思? 实际上,我们只是从函数中获取代码,然后将其直接复制到我们要使用该函数的位置,以避免调用其他方法。

在大多数情况下,这不会产生任何效果,因为代码的嵌入是在编译阶段自动执行的,但是编译器通过某些规则来决定是否嵌入代码(例如,从不嵌入虚拟方法;有关更多详细信息,请参见https: //docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html )。 因此,只需打开事件探查器,在目标设备上启动游戏,看看是否可以改进。

在我们的案例中,我们决定集成一些功能来提高性能,尤其是在大型for循环中。

结论


应用本文中列出的规则,即使在iPhone 5S上,我们也可以轻松地在iOS游戏中获得稳定的60 fps。 也许某些规则可能仅特定于我们的项目,但是我认为在编写代码或检查代码时应记住大多数规则,以避免将来出现问题。 始终根据性能不断编写代码总是比以后重构大型代码更好。

Source: https://habr.com/ru/post/zh-CN481980/


All Articles