Unity的Script
随意使用Unity提供的功能可能会导致意想不到的陷阱。本章通过实际的例子介绍了与Unity内部实现相关的性能调优技术。
空Unity事件函数
当Unity提供的事件函数(如Awake, Start和Update)被定义时,它们会在运行时缓存在Unity内部列表中,并通过列表的迭代执行。
即使在函数中没有做任何事情,它也会被缓存,因为它被定义了。保留不需要的事件函数将使列表膨胀并增加迭代成本。
例如,如下面的示例代码所示,Start和Update是从Unity上新生成的脚本开始定义的。如果您不需要这些函数,请务必删除它们。
public class NewBehaviourScript : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
译者真机部分
可以通过扫描代码,找出空的Start,Update函数
使用tags与names
从UnityEngine继承的类。对象提供标记和名称属性。这些属性对于对象标识很有用,但实际上GC.Alloc。
我从UnityCsReference中引用了他们各自的实现。您可以看到,这两个调用进程都是用本机代码实现的。
Unity用c#实现脚本,但Unity本身是用c++实现的。由于c#内存空间和c++内存空间不能共享,所以分配内存是为了将字符串信息从c++端传递到c#端。这是在每次调用它时完成的,所以如果您想多次访问它,您应该缓存它
有关Unity如何在c#和c++之间工作和内存的更多信息,请参阅“Unity Runtime”。
取自UnityCsReference GameObject.bindings.cs
public extern string tag
{
[FreeFunction("GameObjectBindings::GetTag", HasExplicitThis = true)]
get;
[FreeFunction("GameObjectBindings::SetTag", HasExplicitThis = true)]
set;
}
取自UnityEngineObject.bindings.cs
public string name
{
get { return GetName(this); }
set { SetName(this, value); }
}
[FreeFunction("UnityEngineObjectBindings::GetName")]
extern static string GetName([NotNull("NullExceptionObject")] Object obj);
译者增加部分
tag是场景中GameObject的标签,而GameObject的成员tag是一个属性,在获取该属性时,实质上是调用get_tag()函数,从native层返回一个字符串。字符串属于引用类型,这个字符串的返回,会造成堆内存的分配。然而,Unity引擎也没有通过缓存的方式对get_tag进行优化,在每次调用get_tag时,都会重新分配堆内存。所以如果频繁使用,在类成员中保存起来
获取组件
在下面的示例代码中,您将有每帧搜索刚体组件的成本。如果您经常访问该站点,则应该使用该站点的预缓存版本。
void Update()
{
Rigidbody rb = GetComponent<Rigidbody>();
rb.AddForce(Vector3.up * 10f);
}
译者增加部分
在Lua中使用GetComponent
使用Transform
Transform组件是经常访问的组件,例如位置、旋转、规模(扩展和收缩)以及父子关系更改。如下面的示例代码所示,您经常需要更新多个值。
void SetTransform(Vector3 position, Quaternion rotation, Vector3 scale)
{
transform.position = position;
transform.rotation = rotation;
transform.localScale = scale;
}
当transform被检索时,在Unity内部调用GetTransform()过程。它经过了优化,比上一节中的GetComponent()更快。但是,它比缓存的情况要慢,因此也应该缓存和访问它,如下面的示例代码所示。对于位置和旋转,你也可以使用SetPositionAndRotation()来减少函数调用的次数
void SetTransform(Vector3 position, Quaternion rotation, Vector3 scale)
{
var transformCache = transform;
transformCache.SetPositionAndRotation(position, rotation);
transformCache.localScale = scale;
}
需要显式丢弃的类
因为Unity是用c#开发的,所以不再被GC引用的对象会被释放。然而,Unity中的一些类需要被明确地销毁。典型的例子有Texture2D、Sprite、Material和PlayableGraph。如果使用new或专用的Create函数生成它们,请确保显式地销毁它们。
void Start()
{
_texture = new Texture2D(8, 8);
_sprite = Sprite.Create(_texture, new Rect(0, 0, 8, 8), Vector2.zero);
_material = new Material(shader);
_graph = PlayableGraph.Create();
}
void OnDestroy()
{
Destroy(_texture);
Destroy(_sprite);
Destroy(_material);
if (_graph.IsValid())
{
_graph.Destroy();
}
}
String规范
避免使用字符串指定要在Animator中播放的状态和要在Material中操作的属性。
_animator.Play("Wait");
_material.SetFloat("_Prop", 100f);
在这些函数中,Animator.StringToHash()和Shader.PropertyToID()被执行以将字符串转换为唯一的标识值。由于在多次访问站点时每次都执行转换是浪费的,因此缓存标识值并重复使用它。如下面的示例所示,为了便于使用,建议定义一个列出缓存标识值的类。
public static class ShaderProperty
{
public static readonly int Color = Shader.PropertyToID("_Color");
public static readonly int Alpha = Shader.PropertyToID("_Alpha");
public static readonly int ZWrite = Shader.PropertyToID("_ZWrite");
}
public static class AnimationState
{
public static readonly int Idle = Animator.StringToHash("idle");
public static readonly int Walk = Animator.StringToHash("walk");
public static readonly int Run = Animator.StringToHash("run");
}
JsonUtility的问题
Unity为JSON序列化/反序列化提供了一个类JsonUtility。官方文档(https://docs.unity3d.com/ja/current/Manual/JSONSerialization.html
)还指出,它比c#标准更快,并且经常用于性能敏感的实现
JsonUtility(尽管它的功能比.Net的JSON少)在基准测试中被证明比常用的要快得多。
然而,有一件与性能相关的事情需要注意。但是有一个与性能相关的问题需要注意null的处理
下面的示例代码显示了序列化过程及其结果。您可以看到,即使类A的成员b1被显式地设置为null,它也是用默认构造函数生成的类B和类C进行序列化的。序列化为null的对象,在JSON转换期间将新建一个虚拟对象,因此您可能需要考虑到这个开销。
[Serializable] public class A { public B b1; }
[Serializable] public class B { public C c1; public C c2; }
[Serializable] public class C { public int n; }
void Start()
{
Debug.Log(JsonUtility.ToJson(new A() { b1 = null, }));
// {"b1":{"c1":{"n":0}, "c2":{"n":0}}
}
Render 与 MeshFilter的问题
Renderer.material与MeshFilter.mesh会产生重复的实例,使用结束后必须显式销毁。官方文件也分别明确说明了以下几点。
如果材质被任何其他renderers渲染器使用,这将克隆共享材质并从现在开始使用它。
将获取的材料和网格保存在成员变量中,并在适当的时候销毁它们。当游戏对象被销毁时,销毁自动实例化的网格与材质。
void Start()
{
_material = GetComponent<Renderer>().material;
}
void OnDestroy()
{
if (_material != null)
Destroy(_material);
}
译者增加部分
可以使用MaterialPropertyBlock修改材质
删除日志输出代码
Unity提供了Debug.Log()、Debug.LogWarning()和Debug.LogError()等日志输出函数。虽然这些函数很有用,但它们也存在一些问题。
•日志输出本身是一个繁重的过程。
•它也在发布版本中执行。
•字符串生成和连接会导致GC.Alloc。
如果你关闭Unity中的Logging设置,堆栈跟踪将停止,但是日志将被输出。如果UnityEngine.Debug.unityLogger.logEnabled设置为false。Unity,没有日志记录输出,但由于它只是函数内部的一个分支,函数调用成本和字符串生成和连接应该是不必要的。也可以选择使用#if指令,但是处理所有日志输出处理是不现实的。
#if UNITY_EDITOR
Debug.LogError($"Error {e}");
#endif
在这种情况下可以使用条件属性。如果指定的符号未定义,具有条件属性的函数将被编译器删除调用部分。将条件属性添加到自制类端的每个函数中是一个好主意,作为通过自制日志输出类调用Unity端的日志函数的规则,这样可以在必要时删除整个函数调用。
public static class Debug
{
private const string MConditionalDefine = "DEBUG_LOG_ON";
[System.Diagnostics.Conditional(MConditionalDefine)]
public static void Log(object message)
=> UnityEngine.Debug.Log(message);
}
需要注意的一点是,指定的符号必须能够被函数调用者引用。在#define中定义的符号的作用域将被限制在写入它们的文件中。在每个调用带有条件属性的函数的文件中定义一个符号是不实际的。Unity有一个功能叫做ScriptingDefine Symbols,允许您为整个项目定义符号。这可以在“Project Settings -> Player -> Other Settings”下完成。
使用Burst加速代码
Burst 6是用于高性能c#脚本的官方Unity编译器。
Burst使用c#语言的一个子集来编写代码。Burst将c#代码转换为IR(Intermediate Representation中间表示),这是7的中间语法,一个称为LLVM的编译器基础结构,然后在将其转换为机器语言之前对IR进行优化。
此时,代码尽可能地向量化,并替换为SIMD,这是一个主动使用指令的过程。这有望产生更快的程序输出。
SIMD代表单指令/多数据,指的是将单个指令同时应用于多个数据的指令。换句话说,通过主动使用SIMD指令,可以在单个指令中一起处理数据,从而使操作速度比普通指令更快。
*6 https://docs.unity3d.com/Packages/com.unity.burst@1.6/manual/docs/QuickStart.html
*7 https://llvm.org/
使用Burst来加速代码
Burst使用c#的一个子集,称为高性能c# (HPC#) *8来编写代码。
HPC#的一个特性是c#的引用类型,比如类和数组,是不可用的。因此,通常使用结构来描述数据结构。
对于像数组这样的集合,请使用NativeArray之类的NativeContainer *9。有关hpc#的更多细节,请参考脚注中列出的文档。
Burst与c#作业系统一起使用。因此,它自己的处理在实现IJob的作业的Execute方法中描述。通过将bustcompile属性赋给所定义的作业,该作业将被Burst优化。
给出了一个将给定数组的每个元素平方并将其存储在Output数组中的示例
[BurstCompile]
private struct MyJob : IJob
{
[ReadOnly]
public NativeArray<float> Input;
[WriteOnly]
public NativeArray<float> Output;
public void Execute()
{
for (int i = 0; i < Input.Length; i++)
{
Output[i] = Input[i] * Input[i];
}
}
}
第14行中的每个元素都可以独立计算(计算中没有顺序依赖),并且由于输出数组的内存对齐是连续的,因此可以使用SIMD指令一起计算它们。
*8https://docs.unity3d.com/Packages/com.unity.burst@1.7/manual/docs/CSharpLanguageSupport_Types.html
*9 https://docs.unity3d.com/Manual/JobSystemNativeContainer.html
您使用BurstInspector 看到使用Burst将代码转换为汇编代码
代码第14行的进程将在ARMV8A_AARCH64的程序集中转换为如下
fmul v0.4s, v0.4s, v0.4s
fmul v1.4s, v1.4s, v1.4s
程序集的操作数以.4s为后缀,这一事实证实使用SIMD指令。
在实际设备上比较了用纯c#实现的代码和用Burst优化的代码的性能。
实际设备是Android Pixel 4a和IL2CPP,使用脚本后端进行比较。数组的大小是2^20 = 1,048,576。重复了同样的过程10次,取平均处理时间。
我们观察到,与纯c#实现相比,它的速度提高了5.8倍。