翻译自https://github.com/CyberAgentGameEntertainment/UnityPerformanceTuningBible/
本章主要通过示例介绍c#代码的性能调优实践。这里不涉及基本的c#语法,而是你在开发需要性能的游戏时应该注意的设计和实现。
在本节中,让我们首先了解哪种特定的处理会导致GC.Alloc。
首先,这是一个非常简单的GC.Alloc发生。
private void Update()
{
const int listCapacity = 100;
// GC.Alloc in new of List<int>.
var list = new List<int>(listCapacity);
for (var index = 0; index < listCapacity; index++)
{
// Pack index into list, though it doesn't make any sense in particular
list.Add(index);
}
// Randomly take a value from the list
var random = UnityEngine.Random.Range(0, listCapacity);
var randomValue = list[random];
// ... Do something with the random value ...
}
这段代码的主要问题是,List在每一帧执行的Update方法中是新的。 要解决这个问题,可以避免GC。通过预生成List并使用它来分配每一帧。
private static readonly int listCapacity = 100;
// Generate a List in advance
private readonly List<int> _list = new List<int>(listCapacity);
private void Update()
{
_list.Clear();
for (var index = 0; index < listCapacity; index++)
{
// Pack indexes into the list, though it doesn't make sense to do so
_list.Add(index);
}
// Randomly take a value from the list
var random = UnityEngine.Random.Range(0, listCapacity);
var randomValue = _list[random];
// ... Do something with the random values ...
}
译者增加部分 【腾讯文档】C#List底层源码与优化 https://docs.qq.com/doc/DWnd6ZWpJd3dhbFNC
Lambda表达式也是一个有用的特性,但是它们在游戏中的使用受到限制,因为它们也可能导致GC。根据它们的使用方式进行分配。这里我们假设定义了以下代码。
// Member Variables
private int _memberCount = 0;
// static variables
private static int _staticCount = 0;
// member method
private void IncrementMemberCount()
{
_memberCount++;
}
// static method
private static void IncrementStaticCount()
{
_staticCount++;
}
// Member method that only invokes the received Action
private void InvokeActionMethod(System.Action action)
{
action.Invoke();
}
此时,如果一个变量在lambda表达式中被引用,GC.Alloc将发生。 清单10.5 GC.Alloc的情况。通过在lambda表达式中引用变量来
// When a member variable is referenced, Delegate Allocation occurs
InvokeActionMethod(() => { _memberCount++; });
// When a local variable is referenced, Closure Allocation occurs
int count = 0;
// The same Delegate Allocation as above also occurs
InvokeActionMethod(() => { count++; });
然而,可以通过引用静态变量来避免GC.Alloc,如下所示。
// When a static variable is referenced, GC Alloc does not occur and
InvokeActionMethod(() => { _staticCount++; });
对于lambda表达式中的方法引用,执行GC.Alloc的方式也不同,这取决于它们是如何编写的。 清单10.7 GC.Alloc的情况。在lambda表达式中引用方法时使用
// When a member method is referenced, Delegate Allocation occurs.
InvokeActionMethod(() => { IncrementMemberCount(); });
// If a member method is directly specified, Delegate Allocation occurs.
InvokeActionMethod(IncrementMemberCount);
// When a static method is directly specified, Delegate Allocation occurs
InvokeActionMethod(IncrementStaticCount);
为了避免这些情况,有必要以语句格式引用静态方法,如下所示。
// Non Alloc when a static method is referenced in a lambda expression
InvokeActionMethod(() => { IncrementStaticCount(); });
通过这种方式,Action仅在第一次是新的,但它在内部缓存以避免第二次GC.Alloc 然而,从代码安全性和可读性的角度来看,将所有变量和方法都设置为静态是不太容易接受的。在需要快速的代码中,对于每帧或不确定时间触发的事件,不使用lambda表达式的设计更安全,而不是使用大量静态来消除GC.Alloc。 译者增加部分 【腾讯文档】订阅者模式,监听函数转为Action去GC https://docs.qq.com/doc/DWkJnc2hMZ1NVSWxG
在以下使用泛型的情况下,什么可能导致装箱?
public readonly struct GenericStruct<T> : IEquatable<T>
{
private readonly T _value;
public GenericStruct(T value)
{
_value = value;
}
public bool Equals(T other)
{
var result = _value.Equals(other);
return result;
}
}
```csharp
在这种情况下,程序员实现了GenericStruct的IEquatable<T>接口,但忘记了对T施加限制。因此,可以为T指定不实现IEquatable<T>接口的类型,并且存在通过隐式强制转换到Object类型来使用下面的Equals的情况。
```csharp
public virtual bool Equals(object obj);
例如,如果struct没有实现IEquatable接口,却被指定为T,那么它将被强制转换为带有参数Equals的object,从而导致装箱。 为了防止这种情况提前发生,请修改以下内容
public readonly struct GenericOnlyStruct<T> : IEquatable<T>
where T : IEquatable<T>
{
private readonly T _value;
public GenericOnlyStruct(T value)
{
_value = value;
}
public bool Equals(T other)
{
var result = _value.Equals(other);
return result;
}
}
通过使用where子句(泛型类型约束)将T可以接受的类型限制为那些实现了IEquatable的类型,可以防止这种意外的装箱。 Tips 永远不要忘记最初的目的 在许多情况下,选择结构的目的是为了避免GC在游戏运行时分配。然而,为了减少GC.Alloc,不可能总是通过将所有东西都变成一个结构来加速这个过程。 最常见的错误之一是当使用结构体来避免GC.Alloc,与GC相关的成本如预期的那样减少,但是数据大小如此之大,以至于复制值类型变得昂贵,导致处理效率低下。 为了避免这种情况,还有一些方法通过对方法参数使用引用传递来减少复制成本。虽然这可能会导致加速,但在这种情况下,您应该考虑从一开始就选择一个类,并以预先生成和使用实例的方式实现它。请记住,最终目标不是根除GC.Alloc,但减少每帧的处理时间
循环的耗时取决于数据的数量。此外,循环乍一看似乎是相同的过程,但根据代码的编写方式,其效率可能会有所不同。 让我们看一下使用SharpLab *1,使用foreach/for List,逐个获取数组的内容。 *1 https://sharplab.io/
var list = new List<int>(128);
foreach (var val in list)
{
}
使用foreach遍历List示例的反编译结果
List<int>.Enumerator enumerator = new List<int>(128).GetEnumerator();
try
{
while (enumerator.MoveNext())
{
int current = enumerator.Current;
}
}
finally
{
((IDisposable)enumerator).Dispose();
}
在使用foreach的情况下,您可以看到实现是获取枚举数,继续使用MoveNext(),并使用currentt引用值。此外,查看list.cs *2中MoveNext()的实现,似乎增加了各种属性访问的数量,例如大小检查,并且处理比索引器直接访问更频繁。 *2 https://referencesource.microsoft.com/#mscorlib/system/collections/generic/list.cs 接下来,转为for
var list = new List<int>(128);
for (var i = 0; i < list.Count; i++)
{
var val = list[i];
}
用for遍历List时的反编译结果
List<int> list = new List<int>(128);
int num = 0;
while (num < list.Count)
{
int num2 = list[num];
num++;
}
在c#中,for语句是while语句和索引器(public T this[int index])的语法糖,由索引器通过引用获得(另外,如果你仔细看这个while语句,你会发现条件表达式包含list.Count。这意味着每次重复循环时都要执行对Count属性的访问。数的越多对属性的访问次数进行计数,对属性的访问次数按比例增加越多,根据访问次数的不同,负载变得不可忽略。如果Count在循环中没有改变,那么可以通过在循环之前缓存属性访问来减少属性访问的负载。
var count = list.Count;
for (var i = 0; i < count; i++)
{
var val = list[i];
}
for中的列表示例:改进版本的反编译结果
List<int> list = new List<int>(128);
int count = list.Count;
int num = 0;
while (num < count)
{
int num2 = list[num];
num++;
}
缓存计数减少了属性访问的数量并使其更快。这个循环中的两个比较都不是由GC.Alloc,差异是由于实现的不同。 对于数组,foreach也进行了优化,与for中描述的相比几乎没有变化。
var array = new int[128];
foreach (var val in array)
{
}
反编译结果
int[] array = new int[128];
int num = 0;
while (num < array.Length)
{
int num2 = array[num];
num++;
}
为了验证,数据的数量为1000000,并预先分配随机数。List计算数据的和。 验证环境为Pixel 3a和Unity 2021.3.1f1。
在List的情况下,与一组更精细的条件进行比较可以发现,使用Count优化的for和for甚至比foreach更快。List的前一段可以通过Count优化重写为for,以减少方法的开销 MoveNext()和Current属性在foreach的处理中,从而使其更快。 此外,当比较List和数组各自的最快速度时,数组的速度大约是List的2.3倍。即使foreach和for写入具有相同的IL结果,foreach是更快的结果,并且数组的foreach得到了充分的优化。 根据以上结果,对于数据量大且处理速度必须快的情况,应该考虑使用数组而不是List 但是,如果重写不够充分,例如在没有本地缓存的情况下引用字段中定义的List时,可能无法加快该过程。
正如我们在许多地方所提到的,在游戏开发中预先生成对象并使用它们而不是动态生成它们是非常重要的。例如,将在游戏阶段使用的对象可以在加载阶段集中在一起,并且只在使用池中的对象时分配和引用它们时进行处理,从而避免GC.Alloc在游戏阶段分配。 除了减少分配之外,对象池还可以用于各种其他情况,例如启用屏幕转换,而不必每次都重新创建组成屏幕的对象,减少加载时间,并通过保留计算成本非常高的进程的结果来避免多次繁重的计算。它被用于各种场合。 虽然这里使用的术语“对象”是广义的,但它不仅适用于最小的数据单元,还适用于协程和操作。例如,考虑提前生成超过预期执行次数的协例程,并在必要时使用它来耗尽它。例如,如果一个需要2分钟才能完成的游戏将被执行最多20次,你可以通过提前生成IEnumerator来减少生成的成本,只在需要使用它的时候才使用StartCoroutine。 译者增加部分 【腾讯文档】协程等待时间WaitForSeconds字典保存 https://docs.qq.com/doc/DWnV6amJjY0N6Sk1T
字符串对象是System。表示字符串的Char对象。字符串GC。Alloc很容易在一次使用中出现。例如,使用字符连接操作符+连接两个字符串将导致创建一个新的字符串对象。的值在被创建后不能被改变(不可变),所以一个看起来改变值的操作创建并返回一个新的字符串对象。 当使用字符串连接来创建string时
private string CreatePath()
{
var path = "root";
path += "/";
path += "Hoge";
path += "/";
path += "Fuga";
return path;
}
在上面的例子中,每个字符串连接创建一个字符串,结果是总共164Byte的分配。 当字符串经常被更改时,使用StringBuilder(其值可以更改)可以防止大量生成字符串对象。通过在StringBuilder对象中执行诸如字符连接和删除之类的操作,并最终提取值并将其ToString()添加到字符串对象中,可以将内存分配限制为仅获取时间。另外,在使用stringbuilder时,一定要设置Capacity。当未指定时,默认值为16,当缓冲区扩展为更多字符时,例如Append,内存分配和值复制将发生。
private readonly StringBuilder _stringBuilder = new StringBuilder(16);
private string CreatePathFromStringBuilder()
{
_stringBuilder.Clear();
_stringBuilder.Append("root");
_stringBuilder.Append("/");
_stringBuilder.Append("Hoge");
_stringBuilder.Append("/");
_stringBuilder.Append("Fuga");
return _stringBuilder.ToString();
}
在使用StringBuilder的例子中,如果StringBuilder是提前生成的 (在上面的示例中,在生成时分配了112Byte),然后从现在开始,只需要分配50Byte,这是在检索生成的字符串时在ToString()中进行的 但是,当您希望避免GC.Alloc时,也不建议使用StringBuilder。因为分配在值操作期间发生的可能性较小,并且如上所述,字符串对象将在执行ToString()时生成。另外,由于$""语法被转换为字符串。格式和string的内部实现。Format使用StringBuilder, ToString()的开销最终是不可避免的。上一节中对对象的使用也应该应用在这里,并且可能提前使用的字符串应该是预先生成的字符串对象并使用 然而,在游戏过程中,有时必须执行字符串操作和创建字符串对象。在这种情况下,有必要为字符串预先生成一个缓冲区,并对其进行扩展,以便可以按原样使用。考虑实现您自己的代码,如不安全或引入带有扩展的库Unity像ZString *3(例如NonAlloc适用于TextMeshPro)。 *3 https://github.com/Cysharp/ZString
介绍减少GC的方法。利用LINQ进行分配和延迟求值的要点。 减轻GC.Alloc通过使用LINQ
var oneToTen = Enumerable.Range(1, 11).ToArray();
var query = oneToTen.Where(i => i % 2 == 0).Select(i => i * i);
GC.Alloc发生在LINQ的内部实现中。此外,一些LINQ方法针对调用者的类型进行了优化,因此GC的大小。根据调用者的类型分配更改。 每种类型的执行速度验证
private int[] array;
private List<int> list;
private IEnumerable<int> ienumerable;
public void GlobalSetup()
{
array = Enumerable.Range(0, 1000).ToArray();
list = Enumerable.Range(0, 1000).ToList();
ienumerable = Enumerable.Range(0, 1000);
}
public void RunAsArray()
{
var query = array.Where(i => i % 2 == 0);
foreach (var i in query){}
}
public void RunAsList()
{
var query = list.Where(i => i % 2 == 0);
foreach (var i in query){}
}
public void RunAsIEnumerable()
{
var query = ienumerable.Where(i => i % 2 == 0);
foreach (var i in query){}
}
我们测量了每种方法的基准。结果表明,堆分配的大小按照T[] → List →IEnumerable的顺序增加 因此,当使用LINQ时,GC的大小。可以通过了解运行时类型来减少Alloc。
Tips LINQ造成GC.Alloc的原因 部分原因的GC.Alloc与LINQ的使用是LINQ的内部实现。许多LINQ方法接受IEnumerable并返回IEnumerable,这个API设计允许使用方法链进行直观的描述。方法返回的实体IEnumerable是每个函数的类实例。LINQ内部实例化一个实现Enumerable< t>的类,此外GetEnumerator()实现循环处理等造成了GC.Alloc LINQ延迟求值 LINQ方法(如Where和Select)是延迟计算,直到实际需要结果时才进行计算。另一方面,ToArray等方法是为立即求值而定义的。
private static void LazyExpression()
{
var array = Enumerable.Range(0, 5).ToArray();
var sw = Stopwatch.StartNew();
var query = array.Where(i => i % 2 == 0).Select(HeavyProcess).ToArray();
Console.WriteLine($"Query: {sw.ElapsedMilliseconds}");
foreach (var i in query)
{
Console.WriteLine($"diff: {sw.ElapsedMilliseconds}");
}
}
private static int HeavyProcess(int x)
{
Thread.Sleep(1000);
return x;
}
以上执行结果是。通过在末尾添加ToArray,这是执行方法的即时求值结果 在对query进行赋值时返回选择并求值。因此,由于还调用了HeavyProcess,因此可以看到处理时间是在生成查询时占用的。 Query: 3013 diff: 3032 diff: 3032 diff: 3032
正如您所看到的,无意中调用LINQ的即时求值方法可能会在这些点上导致瓶颈。需要一次查看整个序列的ToArray方法(如OrderBy、Count和)是立即求值的,因此在调用它们时要注意成本。 “避免使用LINQ”的选择 本节解释GC的原因。使用LINQ时分配,如何减少分配,以及延迟评估的关键点。在本节中,我们将解释使用LINQ的标准。前提是LINQ是一个有用的语言特性,但它的使用将会与不使用脚本相比,脚本(c#)使堆分配和执行速度变差。事实上,微软的Unity性能建议在*4中明确指出“避免使用LINQ"。下面是在使用和不使用LINQ的相同逻辑实现的基准比较。 *4https://docs.microsoft.com/en-us/windows/mixed-reality/develop/unity/performancerecommendations-for-unity#avoid-expensive-operations
private int[] array;
public void GlobalSetup()
{
array = Enumerable.Range(0, 100_000_000).ToArray();
}
public void Pure()
{
foreach (var i in array)
{
if (i % 2 == 0)
{
var _ = i * i;
}
}
}
public void UseLinq()
{
var query = array.Where(i => i % 2 == 0).Select(i => i * i);
foreach (var i in query)
{
}
}
执行时间的比较表明,使用LINQ的进程比不使用LINQ的进程花费的时间长19倍。
虽然上面的结果清楚地表明使用LINQ会降低性能,但在某些情况下,使用LINQ更容易传达编码意图。在理解了这些行为之后,在项目中可能有讨论是否使用LINQ的空间,如果是,使用LINQ的规则。
Async/await是c# 5.0中添加的一项语言特性,它允许异步处理被编写为单个同步进程而不需要回调避免在不需要异步的地方使用异步 避免在不需要的地方使用async 定义为async的方法将具有由编译器生成的代码,以实现异步处理。如果存在async关键字,编译器将始终执行代码生成。因此,即使是可以同步完成的方法,如List 10.27,实际上也是由编译器生成的代码 List10.27可以同步完成的异步处理
using System;
using System.Threading.Tasks;
namespace A {
public class B {
public async Task HogeAsync(int i) {
if (i == 0) {
Console.WriteLine("i is 0");
return;
}
await Task.Delay(TimeSpan.FromSeconds(1));
}
public void Main() {
int i = int.Parse(Console.ReadLine());
Task.Run(() => HogeAsync(i));
}
}
}
在List10.27这样的情况下,为的生成状态机结构的成本IAsyncStateMachine的实现,在同步完成的情况下是不必要的,可以通过拆分HogeAsync来省略,HogeAsync可以同步完成,并将其实现为List10.28。
using System;
using System.Threading.Tasks;
namespace A {
public class B {
public async Task HogeAsync(int i) {
await Task.Delay(TimeSpan.FromSeconds(1));
}
public void Main() {
int i = int.Parse(Console.ReadLine());
if (i == 0) {
Console.WriteLine("i is 0");
}
else {
Task.Run(() => HogeAsync(i));
}
}
}
}
Tips async/await如何工作 async/await语法是在编译时使用编译器代码生成实现的。带有async关键字的方法添加一个进程来生成在编译时实现IAsyncStateMachine的结构,并且async/await函数是通过管理一个状态机来实现的,该状态机在等待的进程完成时推进状态。同样,这个IAsyncStateMachine是一个定义在System.Runtime.CompilerServices命名空间中的接口,并且只对编译器可用。 避免捕获同步上下文 从保存到另一个线程的异步处理返回到调用线程的机制是同步上下文和await,前面的上下文可以通过使用捕获。由于每次执行await时都会捕获这个同步上下文,因此每个await都有开销。出于这个原因,UniTask*5,在Unity开发中广泛使用,没有使用ExecutionContext和SynchronizationContext来实现,以避免同步上下文的开销。就Unity而言,实现这样的库可能会提高性能。 *5 https://tech.cygames.co.jp/archives/3417/
将数组分配为局部变量会导致GC.Alloc每次都发生,这可能导致峰值。此外,对堆区域进行读写的效率略低于对堆栈区域进行读写的效率。 因此,在c#中,仅用于在堆栈上分配数组的unsafe代码语法。 下面的例子不使用new关键字,可以使用stackalloc关键字在堆栈上分配数组。
// stackalloc is limited to unsafe
unsafe
{
// Allocating an array of ints on the stack
byte* buffer = stackalloc byte[BufferSize];
}
从c# 7.2开始,可以使用Span结构体在堆栈上分配int型数组,如表10.30所示。该结构体现在可以在不使用unsafestack的情况下使用。
Span<byte> buffer = stackalloc byte[BufferSize];
对于Unity,这是2021.2的标准。对于早期版本,Span不存在,因此必须安装System.Memory.dll。 用stackalloc分配的数组是栈专用的,不能保存在类或结构字段中。它们必须用作局部变量 即使数组是在栈上分配的,分配具有大量元素的数组也需要一定的处理时间。如果您希望在应该避免堆分配的地方(例如在更新循环中)使用具有大量元素的数组,那么最好在初始化期间提前分配数组,或者准备一个像对象池这样的数据结构,并以一种可以在使用时出租的方式实现它。
unsafe void Hoge()
{
for (int i = 0; i < 10000; i++)
{
// Arrays are accumulated for the number of loops
byte* buffer = stackalloc byte[10000];
}
}
当在Unity中使用IL2CPP作为运行环境时,方法调用使用c++类虚表机制执行,以实现类的虚拟方法调用* 6 *6 https://blog.unity.com/technology/il2cpp-internals-method-calls 译者增加部分 【腾讯文档】Mono,IL,Unity跨平台,托管,IL2CPP https://docs.qq.com/doc/DWnZGZHRmTGV4cmdC
具体来说,对于类的每个方法调用定义,将自动生成清单10.32所示的代码。
struct VirtActionInvoker0
{
typedef void (*Action)(void*, const RuntimeMethod*);
static inline void Invoke (
Il2CppMethodSlot slot, RuntimeObject* obj)
{
const VirtualInvokeData& invokeData =
il2cpp_codegen_get_virtual_invoke_data(slot, obj);
((Action)invokeData.methodPtr)(obj, invokeData.method);
}
};
它不仅为虚拟方法生成类似的c++代码,也为非虚拟方法生成类似的c++代码 这种自动生成的行为导致代码大小膨胀,增加了方法调用的处理时间。 这个问题可以通过向类定义中添加sealed修饰符来避免* 7 *7 https://blog.unity.com/technology/il2cpp-optimizations-devirtualization 如果您定义一个类似于List 10.34的类并调用一个方法,那么由IL2CPP生成的c++代码将生成如下的方法调用:
public abstract class Animal
{
public abstract string Speak();
}
public class Cow : Animal
{
public override string Speak() {
return "Moo";
}
}
var cow = new Cow();
// Calling the Speak method
Debug.LogFormat("The cow says '{0}'", cow.Speak());
c++代码中对应的方法调用
// var cow = new Cow();
Cow_t1312235562 * L_14 =
(Cow_t1312235562 *)il2cpp_codegen_object_new(
Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /* hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;
// cow.Speak()
String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(
4 /* String AssemblyCSharp.Cow::Speak() */, L_16);
显示了VirtFuncInvoker0< String_t* >::Invoke被调用,即使它不是一个虚拟方法调用,并且像虚拟方法一样进行了方法调用。 类定义和使用SEALED方法调用
public sealed class Cow : Animal
{
public override string Speak() {
return "Moo";
}
}
var cow = new Cow();
// Calling the Speak method
Debug.LogFormat("The cow says '{0}'", cow.Speak());
c++代码中对应的方法调用
// var cow = new Cow();
Cow_t1312235562 * L_14 =
(Cow_t1312235562 *)il2cpp_codegen_object_new(
Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /* hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;
// cow.Speak()
String_t* L_17 = Cow_Speak_m1607867742(L_16, /* hidden argument*/NULL);
因此,我们可以看到方法调用调用Cow_Speak_m1607867742,后者直接调用该方法。 然而,在最近的Unity中,Unity官方澄清说这种优化是部分自动的*8。 换句话说,即使您没有显式地指定sealed,这种优化也有可能自动完成。 然而,“[il2cpp]在Unity 2018.3中’密封’不再工作了吗?” *8正如论坛中提到的,截至2019年4月,该实施尚未完成。 由于目前的情况,最好检查由IL2CPP生成的代码,并决定每个项目的密封修饰符的设置。 对于更可靠的直接方法调用,以及对未来IL2CPP优化的预期,将密封修饰符设置为可优化标记可能是一个好主意。 *8 https://forum.unity.com/threads/il2cpp-is-sealed-not-worked-as-said-anymore-inunity-2018-3.659017/#post-4412785
方法调用有一些成本。因此,作为一种通用的优化,不仅针对c#,也针对其他语言,相对较小的方法调用由编译器通过内联进行优化。
int F(int a, int b, int c)
{
var d = Add(a, b);
var e = Add(b, c);
var f = Add(d, e);
return f;
}
int Add(int a, int b) => a + b;
使用内联代码后
int F(int a, int b, int c)
{
var d = a + b;
var e = b + c;
var f = d + e;
return f;
}
内联是通过复制和展开方法中的内容来完成的, 在IL2CPP中,在代码生成期间不执行特别的内联优化。 然而,从Unity 2020.2开始,通过为方法和MethodOptions指定MethodImpl属性。对于其参数,生成的c++代码中的相应函数将被赋予内联说明符。换句话说,c++代码级的内联现在是可能的。 内联的优点是,它不仅降低了方法调用的成本,而且还节省了在方法调用时指定的参数的复制。 例如,算术方法采用多个相对较大的结构作为参数,例如Vector3和Matrix。如果将结构体作为实参传递,则将它们全部复制并按值传递给方法。如果参数的数量和传递的结构体的大小很大,则方法调用和参数复制的处理成本可能相当大。此外,方法调用可能成为不能忽视的处理负担,因为它们经常用于周期性处理,例如在物理操作和动画的实现中 在这种情况下,通过内联进行优化是有效的。事实上,Unity的新数学库mathematics指定了MethodOptions.AggressiveInlining 调用无处不在*9。 *9 https://github.com/Unity-Technologies/Unity.Mathematics/blob/f476dc88954697f71e5615b5f57462495bc973a7/src/Unity.Mathematics/math.cs#L1894
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int dot(int x, int y) { return x * y; }
另一方面,内联的缺点是代码大小会随着方法内进程的扩展而增加。 因此,建议考虑内联,特别是对于经常在单个帧中调用并且是热传递的方法。还应该注意的是,指定属性并不总是导致内联。 内联仅限于内容较小的方法,因此您希望内联的方法必须保持较小。 此外,在Unity 2020.2和更早的版本中,内联说明符不附加到属性规范,并且即使指定了c++内联说明符,也不能保证内联将可靠地执行 因此,如果您想要确保内联,您可能需要考虑对热路径方法进行手动内联,尽管这会降低可读性