Unity 性能问题的优化

文章目录


文章翻译自 Fixing Performance Problems

1. 优化Unity中的脚本

简介

游戏运行时,设备的中央处理器(CPU)会执行指令。我们游戏的每一帧都需要执行数以百万计的这些CPU指令。为了保持平稳的帧速率,CPU必须在设定的时间内执行其指令。当CPU无法及时执行完所有指令时,我们的游戏可能会变慢,卡顿或者卡死。

许多事情都会导致CPU要做太多的工作。例如,要求苛刻的渲染代码,过于复杂的物理模拟或过多的动画回调。本文仅关注以上原因中的一种:即,由我们在脚本中编写的代码引起的CPU性能问题。

用我们的代码诊断问题

由于对CPU的过多请求而导致的性能问题可能表现为帧速率低,性能不稳定或间歇性卡顿。但是,其他问题也可能导致类似的症状。如果我们的游戏遇到这样的性能问题,我们要做的第一件事就是使用Unity的Profiler窗口来确定我们的性能问题是否是由于CPU无法及时完成其任务所致。一旦确定了这一点,就必须确定用户脚本是否是问题的原因,还是问题是由游戏的其他部分(例如,复杂的物理原理或动画)引起的。

要了解如何使用Unity的Profiler窗口查找性能问题的原因,请阅读“诊断性能问题”教程。

关于Unity如何构建和运行游戏的简要介绍

要了解为什么我们的代码可能无法很好地执行,我们首先需要了解Unity构建游戏时发生了什么。了解幕后发生的事情将有助于我们就如何改善游戏性能做出明智的决定。

建立过程

在构建游戏时,Unity将运行游戏所需的所有内容打包到可以由目标设备执行的程序中。CPU只能运行以非常简单的语言(称为机器代码 machine code 或本机代码 native code)编写的代码。他们无法运行以更复杂的语言(如C#)编写的代码。这意味着Unity必须将我们的代码翻译成其他语言。此翻译过程称为编译

Unity首先将我们的脚本编译成一种称为通用中间语言(CIL)的语言。CIL是一种易于编译成各种不同的本机代码语言的语言。然后将CIL编译为适用于我们特定目标设备的本机代码。这第二步发生在我们构建游戏(称为提前编译AOT编译)时,或者发生在目标设备本身上,即在代码运行之前(称为即时编译JIT编译)。我们的游戏使用AOT还是JIT编译通常取决于目标硬件。

我们编写的代码和已编译的代码之间的关系

尚未编译的代码称为源代码。我们编写的源代码确定了编译后代码的结构和内容。

在大多数情况下,结构良好且高效的源代码将导致结构良好且高效的已编译代码。但是,了解一些本机代码对我们来说还是很有用的,这样我们可以更好地理解为什么某些源代码可以编译成更高效的本机代码。

首先,某些CPU指令要比其他指令花费更多时间。一个例子是计算平方根。例如,与将两个数字相乘相比,此计算花费的CPU时间更多。单条快速CPU指令和单条慢速CPU指令之间的差异对我们人类来说确实微乎其微,但是从根本上来说,对我们了解某些指令比其他指令更快是有用的。

我们需要了解的下一件事是,在源代码中看起来非常简单的某些操作在编译为代码时可能会异常复杂。这样的一个示例是将元素插入列表。例如,与通过索引从数组访问元素相比,执行此操作需要更多的指令。同样,当我们考虑一个单独的示例时,这个单独的示例尽管运行起来总体上多出不了多少时间,但重要的是要了解某些操作比其他操作导致更多的指令。

理解这些思想将有助于我们理解为什么某些代码的性能要优于其他代码,即使两个示例所做的事情都非常相似。甚至对事物在低水平如何运作的有限背景了解也可以帮助我们编写性能良好的游戏。

Unity Engine代码与脚本代码之间的运行时通信

对于我们来说,了解用C#编写的脚本与构成Unity Engine大部分代码的运行方式稍有不同是很有用的。Unity Engine的大多数核心功能都是用C ++编写的,并且已经被编译为本地代码。这些已编译的引擎代码是安装Unity时所安装的内容的一部分。

编译到CIL的代码(例如我们的源代码)被称为托管代码。将托管代码编译为本机代码后,它将与称为托管运行时的内容集成在一起。托管运行时负责自动内存管理和安全检查等工作,以确保我们代码中的错误将导致异常而不是设备崩溃。

当CPU在正在运行的引擎代码和托管代码之间转换时,必须完成设置这些安全检查的工作。将数据从托管代码传递回引擎代码时,CPU可能需要做一些工作,以将数据从托管运行时使用的格式转换为引擎代码所需的格式。这种转换称为编组。同样,托管代码和引擎代码之间的任何单个调用所产生的开销也不是特别昂贵,但是重要的是,我们必须了解这一成本的存在。

代码性能不佳的原因

现在我们了解了Unity在构建和运行游戏时代码发生了什么,我们可以理解,当我们的代码性能不佳时,是因为它在运行时给CPU带来了太多工作。让我们思考一下造成这种情况的不同原因。

第一种可能性是我们的代码只是单纯的浪费或结构不良。这样的一个例子可能是代码中,当一个函数只能进行一次调用时,它会重复进行同一函数的调用。本文将介绍一些结构较差的常见示例,并提供示例解决方案。

第二种可能性是我们的代码看起来结构合理,但是对其他代码的调用却不必要地昂贵。这样的示例可能是导致托管代码与引擎代码之间不必要调用的代码。本文将提供示例Unity API调用的示例,这些示例调用的开销可能出乎意料,并建议使用更有效的替代方法。

下一个可能性是我们的代码高效,但是在不需要时被调用。例如,可以模拟敌人视线的代码。代码本身可能表现良好,但是当玩家离敌人很远时,运行该代码是浪费的。本文包含一些技术示例,这些技术可以帮助我们编写仅在需要时运行的代码。

最终的可能性是我们的代码太过苛刻了。例如,非常详细的模拟,其中大量代理使用复杂的AI。如果我们已经穷尽了其他可能性并尽可能地优化了此代码,那么我们可能只需要重新设计游戏以使其要求不高:例如,伪装模拟元素而不是计算它们。实现这种优化超出了本文的讨论范围,因为它极大地依赖于游戏本身,但是阅读本文并考虑如何使我们的游戏尽可能发挥性能仍将使我们受益。

改善代码的性能

一旦确定游戏性能问题归因于我们的代码,就必须仔细考虑如何解决这些问题。优化要求苛刻的功能似乎是一个不错的起点,但可能是所讨论的功能已经达到了最佳状态,并且本质上很昂贵。除了可以修改函数之外,我们可以在数百个GameObjects使用的脚本中节省少量的效率,从而使性能提高得多。此外,提高代码的CPU性能可能是有代价的:更改可能会增加内存使用或将工作分担给GPU。

由于这些原因,本文不是要遵循的一组简单步骤。相反,本文提供了一系列改进代码性能的建议,并举例说明了可以应用这些建议的情况。与所有性能优化一样,没有硬性规定。最重要的事情是概要介绍我们的游戏,了解问题的性质,尝试使用不同的解决方案并衡量变更的结果。

编写高效的代码

编写高效的代码并明智地进行构造可以改善我们游戏的性能。虽然显示的示例是在Unity游戏的上下文中进行的,但这些常规最佳实践建议并非特定于某个Unity项目或Unity API调用。

尽可能将代码移出循环

循环是效率低下的常见地方,特别是在嵌套时。如果效率低下的情况经常发生在经常运行的循环中,尤其是在我们的游戏中许多GameObjects上都找到了此代码的情况下,效率低下的情况就会加起来。

在下面的简单示例中,无论是否满足条件,每次调用 Update() 时,我们的代码都会在循环中进行迭代。

void Update()
{
    for (int i = 0; i < myArray.Length; i++)
    {
        if (exampleBool)
        {
            ExampleFunction(myArray[i]);
        }
    }
}

通过简单的更改,仅在满足条件的情况下,代码才会在循环中迭代。

void Update()
{
    if (exampleBool)
    {
        for (int i = 0; i < myArray.Length; i++)
        {
            ExampleFunction(myArray[i]);
        }
    }
}

这是一个简化的示例,但它说明了我们可以优化。我们应该检查我们的代码,以发现循环结构不良的地方。

考虑代码是否必须每帧运行

Update() 是由Unity每帧运行一次的函数。Update() 是放置需要频繁调用的代码或必须响应频繁更改的代码的便利位置。但是,并非所有这些代码都需要在每个帧中运行。将代码移出 Update() 以便仅在需要时才运行是提高性能的一种好方法。

仅在情况发生变化时运行代码

让我们看一个最简单的代码优化示例,以使其仅在情况发生变化时运行。在下面的代码中,在 Update() 中调用 DisplayScore() 。但是,分数的值可能不会随每一帧改变。这意味着我们不必要地调用 DisplayScore()

private int score;

public void IncrementScore(int incrementBy)
{
    score += incrementBy;
}

void Update()
{
    DisplayScore(score);
}

通过简单的更改,我们现在确保仅在score值更改时才调用 DisplayScore()

private int score;

public void IncrementScore(int incrementBy)
{
    score += incrementBy;
    DisplayScore(score);
}

同样,上面的示例是故意简化的,但原理很明确。如果我们在整个代码中都采用这种方法,则可以节省CPU资源。

每[x]帧运行一次代码

如果代码需要频繁运行并且不能被事件触发,则并不意味着它需要运行每一帧。在这些情况下,我们可以选择每[x]帧运行一次代码。

在此示例代码中,昂贵的功能每帧运行一次。

void Update()
{
    ExampleExpensiveFunction();
}

实际上,这足以满足我们每3帧运行一次此代码的需要。在下面的代码中,我们使用模运算符来确保昂贵的函数仅在每三帧上运行一次。

private int interval = 3;

void Update()
{
    if (Time.frameCount % interval == 0)
    {
        ExampleExpensiveFunction();
    }
}

这种技术的另一个好处是,很容易将昂贵的代码分散到各个帧中,从而避免出现峰值。在下面的示例中,每个函数每3帧调用一次,而不是在同一帧上调用。

private int interval = 3;

void Update()
{
    if (Time.frameCount % interval == 0)
    {
        ExampleExpensiveFunction();
    }
    else if (Time.frameCount % interval == 1)
    {
        AnotherExampleExpensiveFunction();
    }
}
使用缓存

如果我们的代码反复调用返回结果的昂贵函数,然后丢弃这些结果,则这可能是优化的机会。存储和重用对这些结果的引用可能会更有效率。这种技术称为缓存。

在Unity中,常见的是调用GetComponent()来访问组件。在下面的示例中,我们将Update()中的GetComponent()调用以访问Renderer组件,然后再将其传递给另一个函数。该代码可以工作,但是由于重复调用GetComponent()导致效率低下。

void Update()
{
    Renderer myRenderer = GetComponent<Renderer>();
    ExampleFunction(myRenderer);
}

以下代码仅调用一次GetComponent(),因为该函数的结果被缓存。缓存的结果可以在Update()中重用,而无需进一步调用GetComponent()。

private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent<Renderer>();
}

void Update()
{
    ExampleFunction(myRenderer);
}

在频繁调用返回结果的函数的情况下,我们应该检查代码。我们有可能通过使用缓存来降低这些调用的成本。

使用正确的数据结构

我们如何构造数据会极大地影响代码的性能。没有适合所有情况的理想数据结构,因此为了在我们的游戏中获得最佳性能,我们需要为每个任务使用正确的数据结构。

为了正确地决定使用哪种数据结构,我们需要了解不同数据结构的优缺点,并仔细考虑我们希望代码做什么。我们可能有成千上万个元素需要每帧迭代一次,或者我们可能有少量需要频繁添加和删除的元素。这些不同的问题将通过不同的数据结构得到最佳解决。

在这里做出正确的决定取决于我们对该主题的了解。如果对你来说这是一门新的知识领域,那么最好的起点是学习“大O符号”。大O表示法是讨论算法复杂度的方式,理解这一点将有助于我们比较不同的数据结构。本文是该主题的清晰且适合初学者的指南。然后,我们可以了解有关可用数据结构的更多信息,并进行比较以找到针对不同问题的正确数据解决方案。此MSDN的C#集合和数据结构指南提供了有关选择合适的数据结构的一般指南,并提供了指向更深入文档的链接。

关于数据结构的单一选择不太可能对我们的游戏产生重大影响。但是,在涉及大量此类集合的数据驱动游戏中,这些选择的结果确实可以加起来。对算法复杂性以及不同数据结构的优缺点的理解将有助于我们创建性能良好的代码。

尽量减少垃圾收集的影响

垃圾收集是一项操作,它是Unity管理内存的一部分。我们的代码使用内存的方式决定了垃圾回收的频率和CPU成本,因此了解垃圾回收的工作原理非常重要。在下一步中,我们将深入讨论垃圾收集主题,并提供几种不同的策略来最大程度地减少其影响。

使用对象池

实例化和销毁对象通常比停用和重新激活对象要昂贵得多。如果对象包含启动代码,例如在 Awake()Start() 函数中对 GetComponent() 的调用,则尤其如此。如果我们需要产生并处置同一物体的许多副本,例如射击游戏中的子弹,那么我们可以从物体池中受益。

对象池是一种技术,在该技术中,对象被临时停用,然后根据需要进行回收和重新激活,而不是创建和破坏对象的实例。尽管作为管理内存使用情况的技术而众所周知,但是对象池也可以用作减少过多CPU使用率的技术。

关于对象池的完整指南不在本文讨论范围之内,但这是一种非常有用的技术,值得学习。This tutorial on object pooling on the Unity Learn site是在Unity中实现对象池系统的重要指南。

避免对Unity API的昂贵调用

有时,我们的代码对其他函数或API的调用可能会出乎意料地昂贵。可能有很多原因。看起来像变量的东西实际上可以是访问器。)它包含附加代码,触发事件或从托管代码到引擎代码进行调用。

在本节中,我们将介绍一些Unity API调用的示例,这些示例的成本比可能要高得多。我们将考虑如何减少或避免这些成本。这些示例说明了造成成本的不同根本原因,并且建议的解决方案可以应用于其他类似情况。

重要的是要理解,没有应该避免的Unity API调用列表。每个API调用在某些情况下可能很有用,而在其他情况下则没有用。在所有情况下,我们都必须仔细分析我们的游戏,找出导致代码昂贵的原因,并仔细考虑如何以最适合我们游戏的方式解决问题。

SendMessage()

SendMessage()BroadcastMessage() 是非常灵活的函数,几乎不需要了解项目的结构,并且执行起来非常快。因此,这些功能对于原型设计或初学者级脚本编写非常有用。但是,它们使用起来非常昂贵。这是因为这些功能利用了反射。反射是指代码在运行时而不是在编译时检查并对其做出决定的术语。使用反射的代码比不使用反射的代码为CPU带来更多的工作。

建议仅将 SendMessage()BroadcastMessage() 用于原型设计,并尽可能使用其他功能。例如,如果我们知道要在哪个组件上调用函数,则应直接引用该组件并以这种方式调用该函数。如果我们不知道要调用哪个组件,可以考虑使用事件委托

Find()

Find() 和相关功能强大但昂贵。这些功能需要Unity遍历内存中的每个GameObject和Component。这意味着在小型,简单项目中对它们的要求不是特别高,但随着项目复杂性的增加,使用起来会变得更加昂贵。

最好不经常使用 Find() 和类似函数,并尽可能地缓存结果。一些简单的技术可以帮助我们减少在代码中使用 Find() 的方法,包括在可能的情况下使用“检查器”面板设置对对象的引用,或创建用于管理对常用搜索对象的引用的脚本。

Transform

设置变换的位置或旋转会导致内部 OnTransformChanged 事件传播到该变换的所有子级。这意味着设置变换的位置和旋转值相对昂贵,尤其是在具有多个子级的变换中。

为了限制这些内部事件的数量,我们应该避免过多地设置这些属性的值。例如,我们可能执行一个计算来设置转换的x位置,然后执行另一个计算来设置其在Update()中的z位置。在此示例中,我们应该考虑将变换的位置复制到Vector3,对该Vector3进行所需的计算,然后将变换的位置设置为Vector3的值。这将仅导致一个OnTransformChanged事件。

Transform.position 是访问器的一个示例,该访问器在后台进行计算。这可以与Transform.localPosition进行对比。localPosition的值存储在转换中,调用Transform.localPosition仅返回此值。但是,每次我们调用Transform.position时,都会计算变换的世界位置。

如果我们的代码频繁使用Transform.position,并且可以用使用Transform.localPosition替换,那么这将减少CPU指令的数量,最终可能会提高性能。如果我们频繁使用Transform.position,则应尽可能对其进行缓存。

update()

Update()LateUpdate() 和其他事件函数看起来像简单的函数,但是它们具有隐藏的开销。这些函数每次在引擎代码和托管代码之间都需要通信。除此之外,Unity在调用这些函数之前会执行许多安全检查。安全检查可确保GameObject处于有效状态,未被销毁等。对于任何一次调用,此开销并不是特别大,但是在具有成千上万个MonoBehaviours的游戏中,它可能会加起来。

因此,空的 Update() 调用可能特别浪费。我们可以假设由于该函数为空,并且我们的代码不包含对其的直接调用,因此该空函数将不会运行。事实并非如此:在幕后,即使 Update() 函数的主体为空,这些安全检查和本机调用仍然会发生。为避免浪费CPU时间,我们应确保我们的游戏不包含空的 Update() 调用。

如果我们的游戏中有大量活跃的MonoBehaviours在调用 Update() ,那么我们可能会从结构不同的代码中受益,以减少这种开销。有关此主题的Unity博客文章对此主题进行了更详细的介绍。

Vector2和Vector3

我们知道,某些操作只是比其他操作导致更多的CPU指令。向量数学运算就是一个例子:它们比浮点或整数运算复杂得多。尽管两次这样的计算所花费的时间实际差异很小,但是在足够的规模下,这样的操作会影响性能。

使用Unity的Vector2和Vector3结构进行数学运算是常见且方便的,尤其是在处理转换时。如果我们在代码中执行许多频繁的Vector2和Vector3数学运算,例如在许多GameObjects的 ``Update()` 中的嵌套循环中,则很可能会为CPU创建不必要的工作。在这些情况下,我们可以通过执行int或float运算来节省性能。

在本文的前面,我们了解到执行平方根计算所需的CPU指令要比用于简单乘法的指令慢。Vector2.magnitude和Vector3.magnitude都是示例,因为它们都涉及平方根计算。此外,Vector2.Distance和Vector3.Distance在幕后使用magnitude。

如果我们的游戏广泛且频繁地使用幅度或距离,则可能可以改为使用Vector2.sqrMagnitude和Vector3.sqrMagnitude来避免相对昂贵的平方根计算。同样,替换单个调用只会导致很小的差异,但是在足够大的规模下,可能可以节省有用的性能。

Camera.main

Camera.main是一个方便的Unity API调用,它返回对第一个启用的Camera组件的引用,该组件用“ Main Camera”标记。这是另一个看起来像变量但实际上是访问器的示例。在这种情况下,访问器在幕后调用类似于 Find() 的内部函数。因此,Camera.main与 Find() 存在相同的问题:它会搜索内存中的所有GameObjects和Components,并且使用起来非常昂贵。

为了避免这种潜在的昂贵调用,我们应该缓存Camera.main的结果,或者完全避免使用它,并手动管理对我们摄像机的引用。

其他Unity API调用和进一步优化

我们考虑了一些常见的Unity API调用示例,这些调用可能会出乎意料地昂贵,并了解了造成此开销的各种原因。但是,这绝不是提高Unity API调用效率的详尽方法列表。This article on performance in Unity 这篇文章广泛介绍了Unity中的优化技巧,其中包含许多其他有用的Unity API优化。此外,该文章对进一步的优化进行了相当深入的讨论,这超出了这篇相对高级且对初学者友好的文章的范围。

仅在需要运行时运行代码

编程中有一句话:“最快的代码是不运行的代码”。通常,解决性能问题的最有效方法不是使用先进的技术:而是删除不需要的代码。让我们看几个例子,看看在哪里可以进行这种节省。

剔除

Unity包含用于检查对象是否在相机的视锥中的代码。如果它们不在摄像机的视锥范围内,则与渲染这些对象有关的代码将不会运行。术语是视锥剔除

我们可以对脚本中的代码采取类似的方法。如果我们具有与对象的视觉状态相关的代码,则当播放器无法看到该对象时,我们可能不需要执行此代码。在具有许多对象的复杂场景中,这可以节省大量的性能。

在下面的简化示例代码中,我们有一个巡逻敌人的示例。每次调用 Update() 时,控制此敌人的脚本都会调用两个示例函数:一个与移动敌人有关,一个与敌人的视觉状态有关。

void Update()
{
    UpdateTransformPosition();
    UpdateAnimations();
}

在下面的代码中,我们现在检查敌人的渲染器是否在任何摄像机的视锥范围内。仅当敌人可见时,与敌人的视觉状态相关的代码才会运行。

private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent<Renderer>();
}

void Update()
{
    UpdateTransformPosition();

    if (myRenderer.isVisible)
    {
        UpateAnimations();
    }
}

当玩家看不到东西时,可以通过几种方法来禁用代码。如果我们知道场景中的某些对象在游戏的特定位置不可见,则可以手动将其禁用。当我们不太确定并需要计算可见性时,我们可以使用粗略的计算(例如,检查播放器后面的对象),OnBecameInvisible()OnBecameVisible() 之类的函数,或更详细的光线投射。最佳实现很大程度上取决于我们的游戏,因此实验和性能分析至关重要。

细节级别

细节级别(也称为LOD , Level of detail)是另一种常见的渲染优化技术。使用详细的网格和纹理以最逼真的方式渲染距离播放器最近的对象。远处的对象使用不太详细的网格和纹理。我们的代码可以使用类似的方法。例如,我们可能有一个敌人使用AI脚本来确定其行为。这种行为的一部分可能涉及昂贵的操作,以确定它可以看到和听到的内容,以及它应如何响应此输入。我们可以使用详细程度系统,根据敌人与玩家的距离来启用和禁用这些昂贵的操作。在具有许多此类敌人的场景中,如果仅最近的敌人执行最昂贵的操作,我们可以节省大量性能。

Unity的 CullingGroup API使我们能够加入Unity的LOD系统来优化代码。CullingGroup API的“手册”页面包含几个示例,说明了如何在我们的游戏中使用它。与以往一样,我们应该测试,描述并找到适合我们游戏的解决方案。

总结

我们已经了解了在构建和运行Unity游戏时编写的代码将如何处理,为什么我们的代码会导致性能问题,以及如何最大程度地减少昂贵的游戏影响。我们已经在代码中了解了许多导致性能问题的常见原因,并考虑了几种不同的解决方案。利用这些知识和配置工具,我们现在应该能够诊断,理解和修复与游戏代码相关的性能问题。

2. 优化Unity游戏中的垃圾回收

简介

当我们的游戏运行时,它使用内存来存储数据。当不再需要此数据时,将释放存储该数据的内存,以便可以重用它。垃圾是用于存储数据但不再使用的内存术语。垃圾回收是使该内存可再次使用的过程的名称。

Unity使用垃圾回收作为其管理内存的一部分。如果垃圾回收发生得太频繁或有太多工作要做,我们的游戏性能可能会很差,这意味着垃圾回收是性能问题的常见原因。

在本文中,我们将学习垃圾收集的工作原理,何时进行垃圾收集以及如何有效地使用内存,以最大程度地减少垃圾收集对游戏的影响。

诊断垃圾收集问题

由垃圾收集引起的性能问题可能表现为帧速率低,性能不稳定或间歇性冻结。但是,其他问题也可能导致类似的症状。如果我们的游戏遇到这样的性能问题,我们应该做的第一件事就是使用Unity的Profiler窗口来确定我们看到的问题是否实际上是由于垃圾收集引起的。

要了解如何使用Profiler窗口查找性能问题的原因,请参考本教程

Unity中的内存管理简介

要了解垃圾收集的工作原理以及何时发生,我们必须首先了解Unity中的内存使用情况。首先,我们必须了解,在运行自己的核心引擎代码以及运行在脚本中编写的代码时,Unity使用不同的方法。

运行Unity自己的核心Unity Engine代码时的Unity管理内存的方式称为手动内存管理。这意味着核心引擎代码必须明确说明如何使用内存。手动内存管理不使用垃圾回收,因此本文将不对其进行进一步介绍。

运行我们的代码时的Unity管理内存的方式称为自动内存管理。这意味着我们的代码无需明确告诉Unity如何详细管理内存。Unity会为我们解决这个问题。

在最基本的级别上,Unity中的自动内存管理工作如下:

  • Unity可以访问两个内存池:(也称为托管堆)。栈用于短期存储小块数据,堆用于长期存储和大数据。
  • 创建变量后,Unity会从堆栈或堆中请求一块内存。
  • 只要该变量在范围内(仍可通过我们的代码访问),就意味着分配给它的内存仍在使用。我们就说这个内存已经分配了。我们将保存在栈内存中的变量描述为栈上的对象,将保存在堆内存中的变量描述为堆上的对象
  • 当变量超出范围时,不再需要该内存,并且可以将其返回给它所来自的池。当内存返回到其池时,我们说该内存已被释放。一旦堆栈所引用的变量超出范围,就将其释放。但是,堆中的内存不会被释放,即使它所引用的变量超出范围,它仍处于分配状态。
  • 垃圾收集器识别并释放未使用的堆内存。垃圾收集器会定期运行以清理堆。

现在我们了解了事件的流程,下面让我们仔细看看栈分配和释放与堆分配和释放的区别。

在栈分配和释放期间会发生什么?

栈分配和取消分配快速而简单。这是因为栈仅用于在短时间内存储少量数据。分配和解除分配始终以可预测的顺序发生,并且具有可预测的大小。

栈的工作方式类似于栈数据类型:它是元素的简单集合,在这种情况下是内存块,其中元素只能按照严格的顺序添加和删除。这种简单性和严格性使其变得如此之快:将变量存储在栈中时,只需从栈的“末端”分配其内存即可。当栈变量超出范围时,用于存储该变量的内存将立即返回到栈以供重用。

堆分配期间会发生什么?

堆分配比栈分配要复杂得多。这是因为堆可用于存储长期和短期数据以及许多不同类型和大小的数据。分配和解除分配并非总是以可预测的顺序发生,并且可能需要大小不同的内存块。

创建堆变量后,将执行以下步骤:

  • 首先,Unity必须检查堆中是否有足够的可用内存。如果堆中有足够的可用内存,则会为该变量分配内存。
  • 如果堆中没有足够的可用内存,则Unity会触发垃圾回收器,以尝试释放未使用的堆内存。这可能是一个缓慢的操作。如果堆中现在有足够的可用内存,则会为该变量分配内存。
  • 如果垃圾回收后堆中没有足够的可用内存,则Unity会增加堆中的内存量。这可能是一个缓慢的操作。然后分配该变量的内存。

堆分配可能很慢,尤其是在必须运行垃圾回收器并且必须扩展堆的情况下。

垃圾回收期间会发生什么?

当堆变量超出范围时,用于存储该变量的内存不会立即释放。仅当垃圾回收器运行时,才会释放未使用的堆内存。

每次运行垃圾回收器时,都会发生以下步骤:

  • 垃圾回收器检查堆上的每个对象。垃圾回收器搜索所有当前对象引用,以确定堆上的对象是否仍在作用域内。
  • 任何不再在作用域中的对象都标记为删除。
  • 标记的对象将被删除,分配给它们的内存将返回到堆中。

垃圾回收可能是一项昂贵的操作。堆上的对象越多,它必须执行的工作就越多,而在我们的代码中引用的对象越多,则它必须执行的工作就越多。

什么时候进行垃圾回收?

导致垃圾回收器运行的三件事:

  • 每当请求使用堆中的可用内存无法完成的堆分配时,垃圾回收器就会运行。
  • 垃圾回收器会不时自动运行(尽管频率因平台而异)。
  • 可以强制垃圾回收器手动运行。

垃圾回收可能是经常进行的操作。每当无法从可用堆内存中完成堆分配时,就会触发垃圾回收器,这意味着频繁的堆分配和释放可能导致频繁的垃圾回收。

垃圾回收问题

既然我们了解了垃圾回收在Unity的内存管理中所扮演的角色,那么我们可以考虑可能发生的问题的类型。

最明显的问题是垃圾回收器可能需要花费大量时间才能运行。如果垃圾回收器在堆上有很多对象和/或要检查的对象引用很多,那么检查所有这些对象的过程可能会很慢。这可能会导致我们的游戏停顿或运行缓慢。

另一个问题是垃圾回收器可能会在不方便的时间运行。如果CPU在我们的游戏中对性能至关重要的部分中已经在努力工作,那么即使垃圾回收产生的少量额外开销也可能导致我们的帧速率下降并且性能发生明显变化。

另一个不太明显的问题是堆碎片。当从堆中分配内存时,它会从可用空间中分出不同大小的块,具体取决于必须存储的数据大小。当这些内存块返回到堆时,堆可以分成许多小的空闲块,这些空闲块由分配的块分隔。这意味着尽管可用内存总量可能很高,但由于没有一个现有的内存块足够大,因此我们无法在不运行垃圾回收器和/或扩展堆的情况下分配较大的内存块。

零散的堆有两个后果。首先是我们游戏的内存使用量将超过需要的内存使用量,其次是垃圾回收器将更频繁地运行。有关堆碎片化的更详细讨论,请参见有关性能的Unity最佳实践指南

查找堆分配

如果我们知道垃圾回收会导致游戏中出现问题,那么我们需要知道代码的哪些部分正在生成垃圾。当堆变量超出范围时会生成垃圾,因此首先我们需要知道是什么导致变量在堆上分配。

在栈和堆上分配了什么?

在Unity中,值类型的局部变量分配在栈上,其他所有内容分配在堆上。如果不确定在Unity中值和引用类型之间的区别,请参阅本教程。以下代码是栈分配的示例,因为变量localInt既是本地的,也是值类型的。该函数完成运行后,将立即从栈中释放为该变量分配的内存。

void ExampleFunction()
{
    int localInt = 5;
}

以下代码是堆分配的示例,因为变量localList是本地的,但引用类型。当垃圾收集器运行时,为该变量分配的内存将被释放。

void ExampleFunction()
{
    List localList = new List();
}
使用探查器窗口查找堆分配

我们可以在Profiler窗口中看到代码在何处创建堆分配。
在这里插入图片描述
选择了CPU使用情况探查器后,我们可以选择任意框架以在Profiler窗口的底部查看有关该框架的CPU使用情况数据。数据列之一称为GC分配。此列显示在该帧中进行的堆分配。如果选择列标题,则可以根据此统计信息对数据进行排序,从而很容易查看游戏中哪些函数导致了最多的堆分配。

一旦知道哪个函数导致堆分配,就可以检查该函数。一旦知道函数中的哪些代码导致产生垃圾,就可以决定如何解决此问题并最大程度地减少垃圾产生量。

减少垃圾收集的影响

广义上讲,我们可以通过三种方式减少垃圾回收对游戏的影响:

  • 我们可以减少垃圾回收器运行所需的时间。
  • 我们可以减少垃圾回收器运行的频率。
  • 我们可以故意触发垃圾回收器,以使其在性能要求不高的时间运行,例如在加载屏幕期间。

考虑到这一点,以下三种策略将对我们有帮助:

  • 我们可以组织我们的游戏,以此我们有更少的堆分配和更少的对象引用。堆上的对象越少,要检查的引用越少,这意味着在触发垃圾回收时,运行时间会减少。
  • 我们可以减少堆分配和释放的频率,尤其是在性能关键的时刻。较少的分配和重新分配意味着较少的触发垃圾回收的机会。这也降低了堆碎片的风险。我
  • 们可以尝试对垃圾回收和堆扩展进行计时,以便它们在可预测的方便时间发生。这是一种更困难,更不可靠的方法,但是当用作整体内存管理策略的一部分时,可以减少垃圾回收的影响。
减少创建的垃圾量

让我们研究一些技术,这些技术可以帮助我们减少代码产生的垃圾数量。

缓存

如果我们的代码反复调用导致堆分配的函数,然后丢弃结果,则会创建不必要的垃圾。相反,我们应该存储对这些对象的引用并重用它们。这种技术称为缓存。在下面的示例中,代码在每次调用时都会导致堆分配。这是因为创建了一个新数组。

void OnTriggerEnter(Collider other)
{
    Renderer[] allRenderers = FindObjectsOfType<Renderer>();
    ExampleFunction(allRenderers);
}

以下代码仅导致一次堆分配,因为创建并填充了阵列一次然后将其缓存了。缓存的数组可以一次又一次地重用,而不会产生更多的垃圾。

private Renderer[] allRenderers;

void Start()
{
    allRenderers = FindObjectsOfType<Renderer>();
}


void OnTriggerEnter(Collider other)
{
    ExampleFunction(allRenderers);
}
不要在经常调用的函数中执行分配

如果必须在MonoBehaviour中分配堆内存,最糟糕的地方是经常运行的函数。例如,Update()LateUpdate() 每帧调用一次,因此,如果我们的代码在此处生成垃圾,它将很快累加。我们应该考虑在可能的情况下在 Start()Awake() 中缓存对对象的引用,或者确保导致分配的代码仅在需要时运行。

让我们来看一个非常简单的移动代码示例,该代码仅在情况发生变化时才运行。在下面的代码中,每次调用 Update() 时都会调用导致分配的函数,从而经常创建垃圾:

void Update()
{
    ExampleGarbageGeneratingFunction(transform.position.x);
}

通过简单的更改,我们现在确保仅在transform.position.x的值更改时才调用分配函数。现在,我们仅在必要时进行堆分配,而不是在每个帧中进行分配。

private float previousTransformPositionX;

void Update()
{
    float transformPositionX = transform.position.x;
    if (transformPositionX != previousTransformPositionX)
    {
        ExampleGarbageGeneratingFunction(transformPositionX);
        previousTransformPositionX = transformPositionX;
    }
}

减少 Update() 中生成的垃圾的另一种技术是使用计时器。当我们有一些代码生成必须定期运行的垃圾(但不一定要在每个帧上运行)时,这非常适合。

在以下示例代码中,生成垃圾的函数每帧运行一次:

void Update()
{
    ExampleGarbageGeneratingFunction();
}

在下面的代码中,我们使用计时器来确保生成垃圾的函数每秒运行一次。

private float timeSinceLastCalled;

private float delay = 1f;

void Update()
{
    timeSinceLastCalled += Time.deltaTime;
    if (timeSinceLastCalled > delay)
    {
        ExampleGarbageGeneratingFunction();
        timeSinceLastCalled = 0f;
    }
}

对频繁运行的代码进行这样的细微更改,可以大大减少产生的垃圾数量。

清理集合对象

创建新集合会导致在堆上进行分配。如果发现在代码中多次创建新的集合,则应缓存对该集合的引用,并使用 Clear() 清空其内容,而不是重复调用new。在以下示例中,每次使用new时都会进行新的堆分配。

void Update()
{
    List myList = new List();
    PopulateList(myList);
}

在以下示例中,仅当创建集合或必须在后台调整集合的大小时才进行分配。这大大减少了产生的垃圾量。

private List myList = new List();
void Update()
{
    myList.Clear();
    PopulateList(myList);
}
对象池

即使我们减少脚本中的分配,但是如果在运行时创建和销毁许多对象,我们仍然可能会遇到垃圾回收问题。对象池是一种可以通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术。对象池在游戏中被广泛使用,最适合于我们频繁生成和销毁相似对象的情况;例如,用枪射击子弹时。

关于对象池的完整指南不在本文讨论范围之内,但这是一种非常有用的技术,值得学习。 This tutorial on object pooling on the Unity Learn site 是在Unity中实现对象池系统的重要指南。

不必要的堆分配的常见原因

我们知道,值类型的局部变量分配在堆栈上,其他所有内容都分配在堆上。但是,在很多情况下,堆分配可能会让我们感到惊讶。让我们看一下不必要的堆分配的一些常见原因,并考虑如何最好地减少这些分配。

string

在C#中,字符串似乎是引用类型,而不是值类型,即使它们似乎保留字符串的“值”。这意味着创建和丢弃字符串会创建垃圾。由于许多代码中通常都使用字符串,因此这种垃圾确实会加起来。

C#中的字符串也是不可变的,这意味着它们的值在首次创建后就无法更改。每次我们操作一个字符串(例如,使用+运算符连接两个字符串)时,Unity都会使用更新后的值创建一个新字符串,并丢弃旧字符串。这会产生垃圾。

我们可以遵循一些简单的规则将字符串中的垃圾最小化。让我们考虑这些规则,然后看一个如何应用它们的示例。

  • 我们应该减少不必要的字符串创建。如果我们多次使用同一字符串值,则应创建一次字符串并缓存该值。
  • 我们应该减少不必要的字符串操作。例如,如果我们有一个Text组件,该组件经常更新并且包含一个串联字符串,我们可以考虑将其分为两个Text组件。
  • 如果必须在运行时构建字符串,则应使用StringBuilder类。StringBuilder类设计用于构建没有分配的字符串,并且将节省我们在连接复杂字符串时产生的大量垃圾。
  • 一旦出于调试目的不再需要对Debug.Log()的调用,则应将其删除。即使没有输出任何内容,对Debug.Log()的调用仍会在我们游戏的所有版本中执行。调用Debug.Log()会创建并处理至少一个字符串,因此,如果我们的游戏包含许多此类调用,则垃圾会累加起来。

我们来看一个示例代码,该示例通过无效使用字符串而产生不必要的垃圾。在下面的代码中,我们通过将字符串“ TIME:”与浮动计时器的值组合在一起,为 Update() 中的 string 显示创建了一个字符串,这会产生不必要的垃圾。

public Text timerText;
private float timer;

void Update()
{
    timer += Time.deltaTime;
    timerText.text = "TIME:" + timer.ToString();
}

在下面的示例中,我们已经进行了很多改进。我们将单词“ TIME:”放在单独的Text组件中,然后在 Start() 中设置其值。这意味着在 Update() 中,我们不再需要组合字符串。这大大减少了产生的垃圾量。

public Text timerHeaderText;
public Text timerValueText;
private float timer;

void Start()
{
    timerHeaderText.text = "TIME:";
}

void Update()
{
    timerValueText.text = timer.toString();
}
Unity函数调用

请务必注意,无论何时我们调用自己未编写的代码,无论是在Unity本身还是在插件中,都可能产生垃圾。一些Unity函数调用会创建堆分配,因此应谨慎使用,以避免产生不必要的垃圾。

没有应避免的功能列表。每个功能在某些情况下可能有用,而在其他情况下则没有那么有用。与以往一样,最好仔细分析我们的游戏,确定在何处创建垃圾并仔细考虑如何处理。在某些情况下,缓存函数的结果可能是明智的。在其他情况下,最好不频繁调用该函数;在其他情况下,最好将我们的代码重构为使用其他函数。话虽如此,我们来看几个导致堆分配的Unity函数的常见示例,并考虑如何最好地处理它们。

每次我们访问返回数组的Unity函数时,都会创建一个新数组并将其作为返回值传递给我们。这种现象并不总是很明显或无法预期,尤其是在该函数是访问器时(例如Mesh.normals)。在下面的代码中,为循环的每次迭代创建一个新的数组。

void ExampleFunction()
{
    for (int i = 0; i < myMesh.normals.Length; i++)
    {
        Vector3 normal = myMesh.normals[i];
    }
}

在这种情况下,减少分配很容易:我们可以简单地缓存对数组的引用。当我们这样做时,只会创建一个数组,并且相应地减少了创建的垃圾量。以下代码演示了这一点。在这种情况下,我们在循环运行之前调用Mesh.normals并缓存引用,以便仅创建一个数组。

void ExampleFunction()
{
    Vector3[] meshNormals = myMesh.normals;
    for (int i = 0; i < meshNormals.Length; i++)
    {
        Vector3 normal = meshNormals[i];
    }
}

可以在函数GameObject.name或GameObject.tag中找到堆分配的另一个意外原因。这两个都是返回新字符串的访问器,这意味着调用这些函数将产生垃圾。缓存值可能有用,但是在这种情况下,我们可以使用一个相关的Unity函数。要针对某个值检查GameObject的标签而不会产生垃圾,我们可以使用 GameObject.CompareTag() 。在以下示例代码中,通过对GameObject.tag的调用创建了垃圾:

private string playerTag = "Player";

void OnTriggerEnter(Collider other)
{
    bool isPlayer = other.gameObject.tag == playerTag;
}

如果我们使用 GameObject.CompareTag() ,则此函数不再生成任何垃圾:

private string playerTag = "Player";

void OnTriggerEnter(Collider other)
{
    bool isPlayer = other.gameObject.CompareTag(playerTag);
}

GameObject.CompareTag 不是唯一的;许多Unity函数调用具有其他版本,这些版本不会导致堆分配。例如,我们可以使用 Input.GetTouch()Input.touchCount 代替 Input.touches ,或者使用 Physics.SphereCastNonAlloc() 代替 Physics.SphereCastAll()

装箱

装箱是指使用值类型的变量代替引用类型的变量时发生的情况。当我们将值类型变量(例如ints或floats)传递给具有对象参数(例如Object.Equals())的函数时,通常会发生装箱。例如,函数String.Format()需要一个字符串和一个对象参数。当我们传递一个字符串和一个int时,必须将int装箱。因此,以下代码包含装箱示例:

void ExampleFunction()
{
    int cost = 5;
    string displayString = String.Format("Price: {0} gold", cost);
}

由于幕后发生的事情,装箱会产生垃圾。将值类型的变量装箱后,Unity在堆上创建一个临时System.Object来包装值类型的变量。System.Object是一个引用类型的变量,因此,当该临时对象被处置时,将创建垃圾。

装箱是不必要的堆分配的极其常见的原因。即使我们不在代码中直接装箱变量,也可能会使用导致装箱的插件,也可能发生在其他功能的幕后。最佳做法是尽可能避免装箱,并删除所有导致装箱的函数调用。

协程

调用 StartCoroutine() 会产生少量垃圾,因为Unity必须创建用于管理协程的实例的类。考虑到这一点,当我们的游戏是交互式的并且性能是一个问题时,应该限制对 StartCoroutine() 的调用。为了减少以这种方式创建的垃圾,必须提前启动必须在性能关键时刻运行的协程,并且在使用可能包含对 StartCoroutine() 的延迟调用的嵌套协程时,我们应格外小心。

协程中的yield语句本身不会创建堆分配;但是,我们在yield语句中传递的值可能会创建不必要的堆分配。例如,以下代码创建垃圾:

yield return 0;

这段代码会产生垃圾,因为装箱的值为0的int。在这种情况下,如果我们希望仅等待一帧而不会引起任何堆分配,那么最好的方法是使用以下代码:

yield return null;

协程的另一个常见错误是在多次产生相同值时使用new。例如,以下代码将在每次循环迭代时创建然后处置WaitForSeconds对象:

while (!isComplete)
{
    yield return new WaitForSeconds(1f);
}

如果我们缓存并重用W​​aitForSeconds对象,则将创建更少的垃圾。以下代码作为示例显示:

WaitForSeconds delay = new WaitForSeconds(1f);

while (!isComplete)
{
    yield return delay;
}

如果我们的代码由于协程而产生大量垃圾,我们可能希望考虑重构我们的代码以使用协程以外的东西。重构代码是一个复杂的主题,每个项目都是唯一的,但是我们可能需要牢记一些协程的常见替代方法。例如,如果我们主要使用协程来管理时间,则我们可能希望在 Update() 函数中简单地跟踪时间。如果我们主要使用协程来控制游戏中事物发生的顺序,我们可能希望创建某种消息传递系统以允许对象进行通信。没有一种方法可以适合所有情况,但是要记住,在代码中通常有不止一种方法可以实现相同的目的。

foreach循环

在5.5之前的Unity版本中,每次循环结束时,遍历除数组之外的任何内容的foreach循环都会生成垃圾。这是由于幕后发生装箱造成的。循环开始时在堆上分配一个System.Object,循环结束时将其丢弃。此问题已在Unity 5.5中修复。例如,在5.5之前的Unity版本中,以下代码中的循环会生成垃圾:

void ExampleFunction(List listOfInts)
{
    foreach (int currentInt in listOfInts)
    {
            DoSomething(currentInt);
    }
}

如果我们无法升级我们的Unity版本,则有解决此问题的简单方法。for和while循环不会在后台造成装箱,因此不会产生任何垃圾。当遍历不是数组的集合时,我们应该赞成使用它们。以下代码中的循环不会生成垃圾:

void ExampleFunction(List listOfInts)
{
    for (int i = 0; i < listOfInts.Count; i ++)
    {
        int currentInt = listOfInts[i];
        DoSomething(currentInt);
    }
}
方法引用

无论是引用匿名方法还是命名方法,对函数的引用都是Unity中引用类型的变量。它们将导致堆分配。将匿名方法转换为闭包(其中匿名方法在创建时可以访问作用域中的变量)会大大增加内存使用量和堆分配数量。

函数引用和闭包如何分配内存的确切细节因平台和编译器设置而异,但是,如果要考虑垃圾回收,则最好在游戏过程中尽量减少使用函数引用和闭包。有关性能的Unity最佳实践指南详细介绍了该主题的技术细节。

LINQ和正则表达式

LINQ和正则表达式都会由于在后台发生装箱而生成垃圾。最好的做法是在性能方面必须有所考虑时避免使用它们。同样,有关性能的Unity最佳实践指南详细介绍了该主题的技术细节。

结构化代码以最大程度地减少垃圾收集的影响

我们的代码的结构方式可能会影响垃圾回收。即使我们的代码没有创建堆分配,它也可能会增加垃圾回收器的工作量。

我们的代码不必要地增加垃圾回收器工作量的一种情况是,要求代码检查不需要检查的内容。结构是值类型的变量,但是如果我们有一个包含引用类型的变量的结构,则垃圾回收器必须检查整个结构。如果我们有大量的这些结构,那么这会为垃圾回收器增加很多工作。

在此示例中,该结构包含一个字符串,该字符串是引用类型的。现在,垃圾回收器在运行时必须检查整个结构数组。

public struct ItemData
{
    public string name;
    public int cost;
    public Vector3 position;
}
private ItemData[] itemData;

在此示例中,我们将数据存储在单独的数组中。当垃圾收集器运行时,它只需要检查字符串数组即可忽略其他数组。这减少了垃圾收集器必须执行的工作。

private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;

我们的代码不必要地增加垃圾回收器工作量的另一种情况是拥有不必要的对象引用。当垃圾收集器搜索对堆上对象的引用时,它必须检查代码中的每个当前对象引用。即使我们不减少堆中的对象总数,代码中对象引用的减少也意味着要做的工作更少。

在此示例中,我们有一个填充对话框的类。用户查看对话框后,将显示另一个对话框。我们的代码包含对应该显示的DialogData的下一个实例的引用,这意味着垃圾回收器必须在操作过程中检查此引用:

public class DialogData
{
    private DialogData nextDialog;

    public DialogData GetNextDialog()
    {
        return nextDialog;
    }
}

在这里,我们对代码进行了重组,以使其返回用于查找DialogData的下一个实例的标识符,而不是实例本身。这不是对象引用,因此不会增加垃圾收集器所花费的时间。

public class DialogData
{
    private int nextDialogID;

    public int GetNextDialogID()
    {
        return nextDialogID;
    }
}

单独来看,这个例子很简单。但是,如果我们的游戏包含许多持有对其他对象的引用的对象,则可以通过以这种方式重组代码来显着降低堆的复杂性。

定时垃圾收集
手动强制垃圾收集

最后,我们不妨自己触发垃圾回收。如果我们知道堆内存已分配但不再使用(例如,如果我们的代码在加载资产时生成了垃圾),并且知道垃圾收集冻结不会影响播放器(例如,在加载屏幕时仍在显示),我们可以使用以下代码请求垃圾回收:

System.GC.Collect();

这将迫使垃圾收集器运行,并在方便的时候释放未使用的内存。

总结

我们已经了解了垃圾回收在Unity中的工作原理,垃圾回收为什么会导致性能问题以及如何最大程度地减少垃圾回收对我们游戏的影响。利用这些知识和配置工具,我们可以解决与垃圾回收相关的性能问题,并构建游戏结构,以便它们有效地管理内存。

下面的链接提供了有关本文涵盖的主题的更多信息。

延申阅读

Unity中的内存管理和垃圾回收
Unity Manual: Understanding Optimization in Unity

Unity Manual: Understanding Automatic Memory Management

Gamasutra: C# Memory Management for Unity Developers by Wendelin Reich

Gamasutra: C# memory and performance tips for Unity by Robert Zubek

Gamasutra: Reducing memory allocations to avoid Garbage Collection on Unity by Grhyll JDD

Gamasutra: Unity Garbage Collection Tips and Tricks by Megan Hughes

Boxing

MSDN: Boxing and Unboxing (C# Programming Guide)

对象池
Unity Learn: Object Pooling Tutorial

Wikipedia: Object Pool Pattern

Strings
Best Practices for Using Strings in the .NET Framework

3. 优化Unity游戏中的图形渲染

简介

在本文中,我们将学习Unity渲染框架时幕后发生的情况,渲染时可能发生的性能问题以及如何解决与渲染相关的性能问题。

在阅读本文之前,至关重要的是要了解,没有一种适合所有大小的方法可以改善渲染性能。渲染性能受我们游戏中许多因素的影响,并且还高度依赖于我们游戏所运行的硬件和操作系统。要记住的最重要的事情是,我们通过研究,试验和严格分析试验结果来解决性能问题。

本文包含有关最常见的渲染性能问题的信息,以及有关如何解决这些问题的建议以及进一步阅读的链接。我们的游戏可能有一个问题-或问题的组合-此处未涵盖。但是,本文仍然可以帮助我们理解问题,并为我们提供有效搜索解决方案的知识和词汇。

渲染简介

在开始之前,让我们快速简单地了解一下Unity渲染框架时发生的情况。了解事件的流向和事物的正确术语将有助于我们理解,研究和努力解决性能问题。

注意:在本文中,我们将使用“对象”一词来表示可以在我们的游戏中渲染的对象。具有Renderer组件的任何GameObject将被称为对象。

在最基本的层次上,渲染可以描述如下:

  • 称为CPU的中央处理单元可以计算出必须绘制的内容以及必须绘制的内容。
  • CPU将指令发送到称为GPU的图形处理单元。
  • GPU根据CPU的指示进行绘制。

现在,让我们仔细看看会发生什么。我们将在本文的后面部分更详细地介绍每个步骤,但是现在让我们熟悉所用的词语,并了解CPU和GPU在渲染中扮演的不同角色。

通常用于描述渲染的短语是渲染管道,可以尝试在脑海中想象这样一个形象,会对后面的理解非常有用。高效的渲染就是保持信息畅通无阻。

对于渲染的每一帧,CPU都会执行以下工作:

  • CPU检查场景中的每个对象,以确定是否应渲染该对象。仅当满足特定条件时才渲染对象。例如,其边界框的某些部分必须在相机的视锥中。据说不会渲染的对象会被剔除。有关视锥和视锥剔除的更多信息,请参阅此页面
  • CPU收集有关将要渲染的每个对象的信息,并将此数据分类为称为绘制调用(Draw Call)的命令。绘制调用包含有关单个网格以及应如何渲染该网格的数据;例如,应使用哪种纹理。在某些情况下,共享设置的对象可以合并到同一绘制调用中。将不同对象的数据合并到同一绘制调用中称为批处理
  • CPU为每个绘图调用创建一个称为批处理的数据包。批处理有时可能包含绘图调用以外的数据,但是这些情况不太可能导致常见的性能问题,因此我们在本文中将不予考虑。

对于每一个包含绘图调用的批处理,CPU现在必须执行以下操作:

  • CPU可以向GPU发送命令以改变被统称为渲染状态的多个变量。此命令称为SetPass调用。SetPass调用告诉GPU使用哪些设置来渲染下一个网格。仅当下一个要渲染的网格需要从上一个网格更改渲染状态时,才发送SetPass调用。
  • CPU将绘图调用发送到GPU。draw调用指示GPU使用最近的SetPass调用中定义的设置来渲染指定的网格。
  • 在某些情况下,批处理可能需要不止一次通过。传递是着色器代码的一部分,而新传递则需要更改渲染状态。对于批处理中的每个通过,CPU必须发送一个新的SetPass调用,然后必须再次发送绘图调用。

同时,GPU执行以下工作:

  • GPU按照发送顺序处理来自CPU的任务。
  • 如果当前任务是SetPass调用,GPU将更新渲染状态。
  • 如果当前任务是绘制调用,则GPU渲染网格。这是分阶段进行的,由着色器代码的不同部分定义。渲染的这一部分很复杂,我们不会对其进行详细介绍,但是对于我们来说,了解一段称为“顶点着色器”的代码告诉GPU如何处理网格的顶点,会很有用。片段着色器告诉GPU如何绘制单个像素。
  • 重复此过程,直到GPU处理完从CPU发送的所有任务为止。

现在我们了解了Unity渲染框架时发生的情况,让我们考虑一下渲染时可能发生的问题。

渲染问题的类型

关于渲染,最重要的要了解的是:CPU和GPU都必须完成其所有任务才能渲染帧。如果这些任务中的任何一项花费太长时间才能完成,则将导致帧渲染延迟。

渲染问题有两个根本原因。第一类问题是由于管道效率低下引起的。当渲染管线中的一个或多个步骤花费太长时间来完成,从而中断了数据的平稳流动时,就会发生效率低下的管线。管道内的低效率被称为瓶颈。第二种问题是由于简单地试图通过管道推送过多数据而引起的。即使是最有效的管道,也限制了在一个帧中可以处理多少数据。

当我们的游戏由于执行CPU的渲染所需的时间太长而无法渲染帧时,我们的游戏就是所谓的CPU绑定。当我们的游戏由于执行GPU渲染任务花费的时间太长而无法渲染帧时,我们的游戏就是所谓的GPU绑定

了解渲染问题

在进行任何更改之前,使用性能分析工具了解性能问题的原因至关重要。不同的问题需要不同的解决方案。衡量所做的每项变更的效果也很重要;解决性能问题是一种平衡,而提高性能的一个方面可能会对另一方面产生负面影响。

我们将使用两种工具来帮助我们理解和解决渲染性能问题:Profiler窗口和Frame Debugger。这两个工具都内置在Unity中。

Profiler窗口

Profiler窗口使我们可以查看有关游戏性能的实时数据。我们可以使用Profiler窗口查看有关游戏许多方面的数据,包括内存使用情况,渲染管道和用户脚本的性能。

如果您还不熟悉使用Profiler窗口,则Unity手册的此页是不错的介绍,本教程将详细介绍如何使用它。

帧调试器

帧调试器使我们可以逐步查看帧的渲染方式。使用帧调试器,我们可以查看详细信息,例如在每个绘制调用期间绘制的内容,每个绘制调用的着色器属性以及发送到GPU的事件的顺序。这些信息有助于我们了解游戏的呈现方式以及可以在哪里提高性能。

如果您还不熟悉使用帧调试器,那么Unity手册的这一页是非常有用的指南,它可以指导您进行操作,并且本教程视频将介绍如何使用它。

查找导致性能问题的原因

在尝试改善游戏的渲染性能之前,必须确保由于渲染问题我们的游戏运行缓慢。如果导致问题的真正原因是过于复杂的用户脚本,则没有必要尝试优化渲染性能!如果不确定性能问题是否与渲染有关,则应遵循本教程

一旦确定问题与渲染有关,就必须了解我们的游戏是受CPU限制还是受GPU限制。这些不同的问题需要不同的解决方案,因此至关重要的是,在尝试解决问题之前,我们必须先了解问题的原因。如果您不确定自己的游戏是CPU绑定还是GPU绑定,则应遵循本教程。如果我们确定我们的问题与渲染有关,

并且知道我们的游戏是受CPU限制还是受GPU限制,那么我们就可以继续阅读。

如果游戏是 CPU 绑定

概括地说,为了渲染帧必须由CPU执行的工作分为三类:

  • 确定必须画什么
  • 为GPU准备命令
  • 将命令发送到GPU

这些广泛的类别包含许多单独的任务,并且这些任务可以跨多个线程执行。线程允许单独的任务同时发生。一个线程执行一项任务时,另一线程可以执行完全独立的任务。这意味着可以更快地完成工作。将渲染任务分配到单独的线程中时,这称为多线程渲染

Unity的渲染过程涉及三种线程:主线程渲染线程工作线程。主线程是我们游戏中大多数CPU任务的发生地,其中包括一些渲染任务。渲染线程是将命令发送到GPU的专用线程。每个工作线程执行一个任务,例如剔除或网格蒙皮。哪个线程执行哪些任务取决于我们游戏的设置和游戏运行的硬件。例如,目标硬件拥有的CPU核心越多,可以产生更多的工作线程。因此,在目标硬件上配置我们的游戏非常重要。我们的游戏在不同设备上的表现可能会大不相同。

由于多线程渲染是复杂的且依赖于硬件,因此在尝试提高性能之前,我们必须了解哪些任务导致游戏受到CPU限制。如果我们的游戏运行缓慢是因为在一个线程上执行清除操作花费的时间太长,那么这将无法帮助我们减少在另一个线程上向GPU发送命令所花费的时间。

注意:并非所有平台都支持多线程渲染。在撰写本文时,WebGL不支持此功能。在不支持多线程渲染的平台上,所有CPU任务都在同一线程上执行。如果我们在这样的平台上受CPU约束,那么优化任何CPU工作都会提高CPU性能。如果我们的游戏是这种情况,我们应该阅读以下所有部分,并考虑哪些最适合我们的游戏的优化。

Graphics jobs

“Player Settings”中的“Graphics jobs”选项确定Unity是否使用工作线程来执行渲染任务,否则这些渲染任务将在主线程上完成,在某些情况下,将在渲染线程上完成。在具有此功能的平台上,它可以显着提高性能。如果我们希望使用此功能,则应该在启用和不启用“图形”作业的情况下对游戏进行概要分析,并观察其对性能的影响。

找出导致问题的任务

我们可以使用Profiler窗口来确定哪些任务导致我们的游戏受到CPU限制。本教程显示了如何确定问题所在。

现在我们了解了导致我们的游戏受CPU限制的任务,让我们看一些常见的问题及其解决方案。

将命令发送到GPU

将命令发送到GPU所花费的时间是游戏受CPU限制的最常见原因。尽管在某些平台(例如,PlayStation 4)上,此任务可以在大多数平台上的渲染线程上执行,但可以由工作线程执行。

向GPU发送命令时发生的最昂贵的操作是SetPass调用。如果我们的游戏由于向GPU发送命令而受到CPU限制,则减少SetPass调用次数可能是提高性能的最佳方法。

我们可以在Unity的Profiler窗口的Rendering Profiler中查看发送了多少SetPass调用和批处理。在性能下降之前可以发送的SetPass调用的数量在很大程度上取决于目标硬件。与移动设备相比,高端PC可以在性能下降之前发送更多的SetPass呼叫。

SetPass调用的数量及其与批处理数量的关系取决于几个因素,我们将在本文后面详细介绍这些主题。但是,通常情况是:

  • 在大多数情况下,减少批处理的数量和/或使更多的对象共享相同的渲染状态将减少SetPass调用的数量。
  • 在大多数情况下,减少SetPass调用的次数将提高CPU性能。

如果减少批次数量并不能减少SetPass调用的数量,则仍然可以单独改善性能。这是因为即使包含相同数量的网格数据,CPU也可以比几个批次更有效地处理一个批次。广泛地讲,有三种减少批处理和SetPass调用数量的方法。我们将更深入地探讨以下每个方面:

  • 减少要渲染的对象数量可能会减少批处理和SetPass调用。
  • 减少必须渲染每个对象的次数通常会减少SetPass调用的次数。
  • 将必须渲染的对象中的数据组合成更少的批处理将减少批处理的数量。

不同的技术将适用于不同的游戏,因此我们应该考虑所有这些选项,确定哪些选项可以在我们的游戏中进行实验。

减少渲染对象的数量

减少必须呈现的对象数是减少批处理和SetPass调用数的最简单方法。我们可以使用几种技术来减少渲染对象的数量。

  • 简单地减少场景中可见对象的数量可以是一种有效的解决方案。例如,如果我们要在人群中渲染大量不同的角色,我们可以尝试在场景中减少这些角色的数量。如果场景看起来仍然不错并且性能得到改善,那么这将是比更复杂的技术更快的解决方案。
  • 我们可以使用相机的Far Clip Plane属性减少相机的绘制距离。此属性是摄像机不再渲染对象的距离。如果我们想掩盖远处的物体不再可见的事实,我们可以尝试使用雾气隐藏远处的物体的不足。
  • 为了更精细地根据距离隐藏对象,我们可以使用相机的“Layer Cull Distances”属性为单独图层上的对象提供自定义剔除距离。如果我们有很多小的前景装饰细节,则此方法很有用;我们可以将这些细节隐藏在比大型地形要素短得多的距离处。
  • 我们可以使用一种称为遮挡剔除的技术来禁用其他对象隐藏的对象的渲染。例如,如果我们的场景中有一栋大建筑物,我们可以使用遮挡剔除来禁用其背后对象的渲染。Unity的遮挡剔除不适用于所有场景,可能会导致额外的CPU开销,并且设置起来可能很复杂,但可以大大提高某些场景的性能。这篇有关遮挡剔除最佳实践的Unity博客文章是该主题的绝佳指南。除了使用Unity的遮挡剔除,我们还可以通过手动停用玩家知道无法看到的对象来实现自己的遮挡剔除形式。例如,如果场景中包含用于过场动画的对象,但在此之前或之后均不可见,则应禁用它们。利用我们自己的游戏知识,总是比要求Unity动态解决问题更有效。
减少必须渲染每个对象的次数

实时照明,阴影和反射为游戏增添了很多真实感,但代价可能非常昂贵。使用这些功能可能导致对象被多次渲染,从而极大地影响性能。

这些功能的确切影响取决于我们为游戏选择的渲染路径。渲染路径是绘制场景时执行计算顺序的术语,渲染路径之间的主要区别在于它们处理实时光,阴影和反射的方式。通常,如果我们的游戏在高端硬件上运行并使用大量实时光,阴影和反射,则延迟渲染可能是一个更好的选择。如果我们的游戏在低端硬件上运行并且不使用这些功能,则正向渲染可能更适合。但是,这是一个非常复杂的问题,如果我们希望利用实时的灯光,阴影和反射,最好研究主题和实验。Unity手册的此页面提供了有关Unity中可用的不同渲染路径的更多信息,并且是有用的起点。本教程包含有关Unity中照明主题的有用信息。

无论选择哪种渲染路径,实时灯光,阴影和反射的使用都会影响我们的游戏性能,因此了解如何对其进行优化非常重要。

  • Unity中的动态照明是一个非常复杂的主题,而深入讨论不在本文讨论范围之内,但是本教程是对该主题的出色介绍,并且Unity手册的此页面包含有关常见照明优化的详细信息。
  • 动态照明很昂贵。当我们的场景包含不动的对象(例如风景)时,我们可以使用一种称为“烘焙”的技术来预先计算该场景的照明,因此不需要运行时照明计算。本教程介绍了该技术, Unity手册的这一部分详细介绍了烘焙的照明。
  • 如果我们希望在游戏中使用实时阴影,则可能是可以提高性能的领域。Unity手册的这一页很好地指导了可以在“质量设置”中进行调整的阴影属性以及这些阴影将如何影响外观和性能。例如,我们可以使用“阴影距离”属性来确保只有附近的物体才能投射阴影。
  • 反射探针可产生逼真的反射,但批次成本很高。最好在考虑性能的情况下尽量减少使用反射,并在使用它们的地方尽可能地优化它们。Unity手册的这一页是优化反射探针的有用指南。
将对象组合成更少的批次

当满足某些条件时,批处理可以包含多个对象的数据。为了有资格进行批处理,对象必须:

  • 共享相同材料的相同实例
  • 具有相同的材质设置(即纹理,材质球和材质球参数)

批处理合格的对象可以提高性能,尽管与所有优化技术一样,我们必须仔细分析以确保批处理成本不超过性能提升。

批处理合格对象有几种不同的技术:

  • 静态批处理是一项技术,允许Unity批处理附近不会移动的合格对象。可以从静态批处理中受益的一个很好的例子是一堆类似的物体,例如巨石。Unity手册的此页面包含有关在我们的游戏中设置静态批处理的说明。静态批处理可能会导致更高的内存使用率,因此在对游戏进行性能分析时,我们应该牢记这一成本。
  • 动态批处理是另一种允许Unity批处理合格对象的技术,无论对象是否移动。使用此技术可以批处理的对象存在一些限制。在Unity手册的此页上列出了这些限制以及说明。动态批处理会影响CPU使用率,从而导致其花费的CPU时间多于节省的时间。在尝试这项技术时,我们应该牢记这一成本,并谨慎使用。
  • 批处理Unity的UI元素要复杂一些,因为它会受到我们UI布局的影响。这个来自Unite Bangkok 2015的视频很好地概述了这一主题,并且该优化Unity UI的指南提供了有关如何确保UI批处理按预期工作的深入信息。
  • GPU实例化是一种技术,它可以非常高效地批处理大量相同的对象。它的使用存在局限性,并非所有硬件都支持它,但是如果我们的游戏一次在屏幕上有许多相同的对象,我们也许可以从这项技术中受益。Unity手册的此页面包含Unity中的GPU实例化的介绍,以及如何使用它,支持它的平台以及在什么情况下它可以使我们的游戏受益的详细信息。
  • 纹理地图集是一种将多个纹理合并为一个较大纹理的技术。它通常用于2D游戏和UI系统,但也可以用于3D游戏。如果在为游戏创建艺术品时使用此技术,则可以确保对象共享纹理,因此可以进行批处理。Unity有一个内置的纹理地图集工具,称为Sprite Packer,可用于2D游戏。
  • 可以在Unity编辑器中或在运行时通过代码手动组合共享相同材质和纹理的网格。当以这种方式组合网格时,我们必须意识到阴影,光照和剔除仍将在每个对象级别上起作用。这意味着通过合并网格来提高性能可以通过不再能够剔除那些本来就不会渲染的对象来抵消。如果我们希望研究这种方法,则应检查Mesh.CombineMeshes函数。Unity的Standard Assets package的CombineChildren脚本就是这种技术的一个示例。
  • 在脚本中访问Renderer.material时,我们必须非常小心。这将复制材料并返回对新副本的引用。如果渲染器是批处理的一部分,这样做将破坏批处理,因为渲染器不再具有对相同材质实例的引用。如果我们希望通过脚本访问批次对象的材料,则应使用Renderer.sharedMaterial。
剔除,分类和批处理

剔除,收集将要绘制的对象上的数据,将这些数据分为几批并生成GPU命令都可以促进CPU绑定。这些任务将在主线程或单个工作线程上执行,具体取决于我们游戏的设置和目标硬件。

  • 剔除本身不太可能会造成很高的成本,但是减少不必要的剔除可能会提高性能。所有活动场景对象(即使是未渲染的图层上的对象)也存在每个摄像机每个对象的开销。为了减少这种情况,我们应该禁用相机并停用或禁用当前未使用的渲染器。
  • 批处理可以大大提高向GPU发送命令的速度,但有时可能会在其他地方增加不必要的开销。如果批处理操作导致我们的游戏受CPU限制,我们可能希望限制游戏中手动或自动批处理操作的数量。
蒙皮网格

当我们通过使用称为骨骼动画的技术使网格变形来对网格进行动画处理时,将使用SkinnedMeshRenderers。最常用于动画角色。与渲染蒙皮网格物体渲染相关的任务通常会在主线程或单个工作线程上执行,具体取决于我们游戏的设置和目标硬件。

渲染蒙皮的网格可能是一项昂贵的操作。如果我们在Profiler窗口中看到渲染蒙皮的网格正在使我们的游戏受到CPU限制,那么我们可以尝试一些改善性能的方法:

  • 我们应该考虑是否需要为当前使用每个对象的每个对象使用SkinnedMeshRenderer组件。例如,可能是我们导入了使用SkinnedMeshRenderer组件的模型,但实际上并未对其进行动画处理。在这种情况下,用MeshRenderer组件替换SkinnedMeshRenderer组件将有助于提高性能。将模型导入Unity时,如果我们选择不导入模型的“导入设置”中的动画,则模型将具有MeshRenderer而不是SkinnedMeshRenderer。
  • 如果仅在某些时间为对象设置动画(例如,仅在启动时或仅在距相机一定距离内),则可以将其网格切换为不太详细的版本,或将其SkinnedMeshRenderer组件切换为MeshRenderer零件。SkinnedMeshRenderer组件具有BakeMesh函数,该函数可以以匹配的姿势创建网格,这对于在不同的网格或渲染器之间进行交换而对对象没有任何可见更改非常有用。
  • Unity手册的此页面包含有关优化使用蒙皮网格物体的动画角色的建议,SkinnedMeshRenderer组件上的Unity手册页面包含可提高性能的调整。除了这些页面上的建议外,还应牢记的是,每个顶点的网格蒙皮成本会增加;因此,在我们的模型中使用较少的顶点会减少必须完成的工作量。在某些平台上,皮肤可以由GPU而不是CPU处理。如果我们在GPU上有很多容量,则可以尝试使用此选项。我们可以在Player设置中为当前平台和质量目标启用GPU外观。
与渲染无关的主线程操作

请务必了解,许多与渲染无关的CPU任务都是在主线程上执行的。这意味着,如果我们在主线程上绑定了CPU,则可以通过减少花在与渲染无关的任务上的CPU时间来提高性能。

例如,我们的游戏可能在游戏的某个时刻在主线程上执行了昂贵的渲染操作和昂贵的用户脚本操作,这使我们受到了CPU的限制。如果我们在不损失视觉保真度的情况下尽可能地优化了渲染操作,则有可能我们可以减少自己脚本的CPU成本来提高性能。

如果我们的游戏是 GPU 绑定

如果我们的游戏绑定GPU,那么要做的第一件事就是找出导致GPU瓶颈的原因。GPU的性能通常受填充率的限制,尤其是在移动设备上,但内存带宽和顶点处理也是要考虑的问题。让我们检查所有这些问题,并了解导致问题的原因,诊断方法和解决方法。

填充率

填充率是指GPU每秒可渲染到屏幕的像素数。如果我们的游戏受到填充率的限制,这意味着我们的游戏试图在每帧上绘制比GPU可以处理的像素更多的像素。

检查填充率是否导致我们的游戏受GPU限制很简单:

  • 分析游戏并记录GPU时间。
  • 在Player Settings 中降低显示分辨率。
  • 再次分析游戏。如果性能有所提高,则填充率很可能是问题所在。

如果填充率是导致问题的原因,则有几种方法可以帮助我们解决问题。

  • 片段着色器是着色器代码的各个部分,它们告诉GPU如何绘制单个像素。此代码由GPU针对必须绘制的每个像素执行,因此,如果代码效率低下,则很容易堆积性能问题。复杂的片段着色器是填充率问题的非常常见的原因。
  • 如果我们的游戏使用内置的着色器,则我们应力争使用最简单,最优化的着色器,以实现所需的视觉效果。例如,Unity随附的移动着色器经过了高度优化。我们应该尝试使用它们,看看是否可以在不影响游戏外观的情况下提高性能。这些着色器是为在移动平台上使用而设计的,但它们适用于任何项目。如果非移动平台可提供项目所需的视觉保真度,则在非移动平台上使用“移动”着色器可以提高性能,这是完全可以的。
  • 如果我们游戏中的对象使用Unity的标准着色器,请务必了解Unity会根据当前的材质设置来编译此着色器。仅编译当前正在使用的功能。这意味着删除诸如细节图之类的功能可以减少复杂得多的片段着色器代码,从而大大提高性能。同样,如果在我们的游戏中是这种情况,我们应该尝试设置,看看是否能够在不影响视觉质量的情况下提高性能。
  • 如果我们的项目使用定制的着色器,则我们应力求尽可能地优化它们。优化着色器是一个复杂的主题,但是Unity手册的此页面以及Unity手册的此页面的Shader优化部分包含用于优化我们的着色器代码的有用起点。
  • OverDraw 是指多次绘制同一像素的术语。当将对象绘制在其他对象之上时,就会发生这种情况,并且极大地有助于填充率问题。要了解透支,我们必须了解Unity在场景中绘制对象的顺序。对象的着色器通常通过指定对象位于哪个渲染队列中来确定其绘制顺序。Unity使用此信息以严格的顺序绘制对象,如Unity手册的此页所述。此外,在绘制对象之前,不同渲染队列中的对象将进行不同的排序。例如,Unity在Geometry队列中对项目进行从前到后排序,以最大程度地减少透支,但在Transparent队列中对对象进行从前到后排序,以实现所需的视觉效果。这种从头到尾的排序实际上具有使透明队列中的对象的透支最大化的效果。过度绘制是一个复杂的主题,没有一种方法可以解决所有过度绘制问题,但是减少Unity无法自动排序的重叠对象的数量是关键。开始调查此问题的最佳位置是Unity的“场景”视图;有一个绘制模式,可以让我们看到场景中的透支,并从那里确定可以在哪里进行缩小的地方。过度透支的最常见罪魁祸首是透明材料,未优化的粒子和重叠的UI元素,因此我们应该尝试优化或减少这些。Unity Learn网站上的本文主要关注Unity UI,但也包含有关透支的良好常规指导。
  • 使用图像效果会极大地导致填充率问题,尤其是在我们使用多个图像效果的情况下。如果我们的游戏使用图像效果并且在填充率问题上苦苦挣扎,我们不妨尝试使用不同的设置或图像效果的更优化版本(例如Bloom(Optimized)代替Bloom)。如果我们的游戏在同一台摄像机上使用多个图像效果,则会导致多次着色器传递。在这种情况下,最好将用于图像效果的着色器代码合并到一个通道中,例如在Unity的PostProcessing Stack中。如果我们优化了图像效果,但仍然存在填充率问题,则可能需要考虑禁用图像效果,尤其是在低端设备上。
内存带宽

内存带宽是指GPU可以读写其专用内存的速率。如果我们的游戏受到内存带宽的限制,这通常意味着我们使用的纹理太大,GPU无法快速处理。

要检查内存带宽是否有问题,我们可以执行以下操作:

  • 分析游戏并记录GPU时间。
  • 在“质量设置”中降低当前平台的纹理质量和质量目标。
  • 再次分析游戏并记下GPU时间。如果性能有所提高,则可能是内存带宽问题。

如果内存带宽是我们的问题,则需要减少游戏中纹理内存的使用。同样,最适合每种游戏的技术会有所不同,但是有几种方法可以优化纹理。

  • 纹理压缩是一种可以大大减小磁盘和内存中纹理大小的技术。如果内存带宽是我们游戏中需要考虑的问题,则使用纹理压缩来减小内存中纹理的大小可以提高性能。Unity中提供了许多不同的纹理压缩格式和设置,并且每个纹理可以具有单独的设置。作为一般规则,应尽可能使用某种形式的纹理压缩。但是,尝试并尝试找到每种纹理的最佳设置的效果最佳。Unity手册中的此页面包含有关不同压缩格式和设置的有用信息。
  • Mipmap是Unity可以在远处对象上使用的纹理的较低分辨率版本。如果场景中包含的物体距离相机较远,则可以使用mipmap减轻内存带宽问题。“场景”视图中的Mipmap绘制模式使我们能够看到场景中哪些对象可以从mipmap中受益,并且Unity手册的此页面包含有关为纹理启用mipmap的更多信息。
顶点处理

顶点处理是指GPU必须执行的工作才能渲染网格中的每个顶点。顶点处理的成本受到两方面的影响:必须渲染的顶点数和必须在每个顶点上执行的操作数。

如果我们的游戏受GPU限制,并且我们确定游戏不受填充率或内存带宽的限制,则很可能是顶点处理引起了问题。在这种情况下,尝试减少GPU必须执行的顶点处理量可能会提高性能。

我们可以考虑采用几种方法来减少顶点数量或在每个顶点上执行的操作数量。

  • 首先,我们应该努力减少不必要的网格复杂度。如果我们使用的网格物体具有游戏中无法看到的细节水平,或者由于创建时由于错误而导致顶点过多的低效网格物体,那么这将浪费GPU的工作。降低顶点处理成本的最简单方法是在我们的3D艺术程序中创建具有更少顶点数量的网格。
  • 我们可以尝试一种称为法线贴图的技术,该技术使用纹理在网格上创建更大的几何复杂性的错觉。尽管此技术有一些GPU开销,但在许多情况下会提高性能。 Unity手册的这一页为使用法线贴图模拟网格中的复杂几何体提供了有用的指南。
  • 如果我们游戏中的网格不使用法线贴图,我们通常可以在网格的导入设置中禁用该网格的顶点切线。这减少了每个顶点发送到GPU的数据量。
  • 细节级别(也称为LOD)是一种优化技术,可以减少距离摄像机较远的网格的复杂性。这减少了GPU必须渲染的顶点数量,而不会影响游戏的视觉质量。《 Unity手册》的“ LOD组”页面包含有关如何在我们的游戏中设置LOD的更多信息。
  • 顶点着色器是告诉GPU如何绘制每个顶点的着色器代码块。如果我们的游戏受到顶点处理的限制,那么降低顶点着色器的复杂性可能会有所帮助。
  • 如果我们的游戏使用内置的着色器,则我们的目标是尽可能使用最简单,最优化的着色器来获得所需的视觉效果。例如,Unity随附的移动着色器经过了高度优化。我们应该尝试使用它们,看看是否可以在不影响游戏外观的情况下提高性能。
  • 如果我们的项目使用定制的着色器,则我们应力求尽可能地优化它们。优化着色器是一个复杂的主题,但是Unity手册的此页面以及Unity手册的此页面的Shader优化部分包含用于优化我们的着色器代码的有用起点。
结论

我们已经了解了渲染在Unity中的工作原理,渲染时会发生什么问题以及如何提高游戏的渲染性能。利用这些知识和配置工具,我们可以解决与渲染和结构化游戏有关的性能问题,从而使游戏拥有流畅而有效的渲染流程。

下面的链接提供了有关本文涵盖的主题的更多信息。

资源

Unity Learn: Optimizing Unity UI

Unity Knowledge Base: Why is my static batching breaking or otherwise not working as expected?

Fabian Giesen: A trip through the graphics pipeline

Simon Schreibt: Render hell

Gamasutra: How to choose between Forward or Deferred rendering paths in Unity

Gamasutra: Batching independently moving GameObjects into a single mesh to reduce draw calls

FlameBait Games: Optimizing SkinnedMeshRenderers for Unity 5

Pencil Square Games: Reducing draw calls (also named SetPass calls) in Unity 5

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值