Unity性能调优手册9Unity的Script:空生命周期函数,tags,组件,string,显式销毁的类(Texture2D、Sprite、Material),burst

2023-11-27 10:37:32 浏览数 (1)

翻译自https://github.com/CyberAgentGameEntertainment/UnityPerformanceTuningBible/

Unity的Script

随意使用Unity提供的功能可能会导致意想不到的陷阱。本章通过实际的例子介绍了与Unity内部实现相关的性能调优技术。

空Unity事件函数

当Unity提供的事件函数(如Awake, Start和Update)被定义时,它们会在运行时缓存在Unity内部列表中,并通过列表的迭代执行。 即使在函数中没有做任何事情,它也会被缓存,因为它被定义了。保留不需要的事件函数将使列表膨胀并增加迭代成本。 例如,如下面的示例代码所示,Start和Update是从Unity上新生成的脚本开始定义的。如果您不需要这些函数,请务必删除它们。

代码语言:javascript复制
public class NewBehaviourScript : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
    }
    // Update is called once per frame
    void Update()
    {
    }
}

译者真机部分 可以通过扫描代码,找出空的Start,Update函数 https://www.cnblogs.com/mrblue/p/5530370.html

使用tags与names

从UnityEngine继承的类。对象提供标记和名称属性。这些属性对于对象标识很有用,但实际上GC.Alloc。 我从UnityCsReference中引用了他们各自的实现。您可以看到,这两个调用进程都是用本机代码实现的。 Unity用c#实现脚本,但Unity本身是用c 实现的。由于c#内存空间和c 内存空间不能共享,所以分配内存是为了将字符串信息从c 端传递到c#端。这是在每次调用它时完成的,所以如果您想多次访问它,您应该缓存它 有关Unity如何在c#和c 之间工作和内存的更多信息,请参阅“Unity Runtime”。 取自UnityCsReference GameObject.bindings.cs

代码语言:javascript复制
public extern string tag
{
    [FreeFunction("GameObjectBindings::GetTag", HasExplicitThis = true)]
    get;
    [FreeFunction("GameObjectBindings::SetTag", HasExplicitThis = true)]
    set;
}

取自UnityEngineObject.bindings.cs

代码语言:javascript复制
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时,都会重新分配堆内存。所以如果频繁使用,在类成员中保存起来

获取组件

在下面的示例代码中,您将有每帧搜索刚体组件的成本。如果您经常访问该站点,则应该使用该站点的预缓存版本。

代码语言:javascript复制
void Update()
{
    Rigidbody rb = GetComponent<Rigidbody>();
    rb.AddForce(Vector3.up * 10f);
}

译者增加部分 在Lua中使用GetComponent 【腾讯文档】Lua缓存C#类型 https://docs.qq.com/doc/DWklHQWxRa2NlTGpI

使用Transform

Transform组件是经常访问的组件,例如位置、旋转、规模(扩展和收缩)以及父子关系更改。如下面的示例代码所示,您经常需要更新多个值。

代码语言:javascript复制
void SetTransform(Vector3 position, Quaternion rotation, Vector3 scale)
{
    transform.position = position;
    transform.rotation = rotation;
    transform.localScale = scale;
}

当transform被检索时,在Unity内部调用GetTransform()过程。它经过了优化,比上一节中的GetComponent()更快。但是,它比缓存的情况要慢,因此也应该缓存和访问它,如下面的示例代码所示。对于位置和旋转,你也可以使用SetPositionAndRotation()来减少函数调用的次数

代码语言:javascript复制
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函数生成它们,请确保显式地销毁它们。

代码语言:javascript复制
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中操作的属性。

代码语言:javascript复制
_animator.Play("Wait");
_material.SetFloat("_Prop", 100f);

在这些函数中,Animator.StringToHash()和Shader.PropertyToID()被执行以将字符串转换为唯一的标识值。由于在多次访问站点时每次都执行转换是浪费的,因此缓存标识值并重复使用它。如下面的示例所示,为了便于使用,建议定义一个列出缓存标识值的类。

代码语言:javascript复制
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转换期间将新建一个虚拟对象,因此您可能需要考虑到这个开销。

代码语言:javascript复制
[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渲染器使用,这将克隆共享材质并从现在开始使用它。 将获取的材料和网格保存在成员变量中,并在适当的时候销毁它们。当游戏对象被销毁时,销毁自动实例化的网格与材质。

代码语言:javascript复制
void Start()
{
    _material = GetComponent<Renderer>().material;
}
void OnDestroy()
{
    if (_material != null) 
        Destroy(_material);
}

译者增加部分 可以使用MaterialPropertyBlock修改材质 【腾讯文档】材质MaterialPropertyBlock https://docs.qq.com/doc/DWnhRZ09Za2xzTVBY

删除日志输出代码

Unity提供了Debug.Log()、Debug.LogWarning()和Debug.LogError()等日志输出函数。虽然这些函数很有用,但它们也存在一些问题。 •日志输出本身是一个繁重的过程。 •它也在发布版本中执行。 •字符串生成和连接会导致GC.Alloc。 如果你关闭Unity中的Logging设置,堆栈跟踪将停止,但是日志将被输出。如果UnityEngine.Debug.unityLogger.logEnabled设置为false。Unity,没有日志记录输出,但由于它只是函数内部的一个分支,函数调用成本和字符串生成和连接应该是不必要的。也可以选择使用#if指令,但是处理所有日志输出处理是不现实的。

代码语言:javascript复制
#if UNITY_EDITOR
Debug.LogError($"Error {e}");
#endif

在这种情况下可以使用条件属性。如果指定的符号未定义,具有条件属性的函数将被编译器删除调用部分。将条件属性添加到自制类端的每个函数中是一个好主意,作为通过自制日志输出类调用Unity端的日志函数的规则,这样可以在必要时删除整个函数调用。

代码语言:javascript复制
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数组中的示例

代码语言:javascript复制
[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的程序集中转换为如下

代码语言:javascript复制
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倍。

0 人点赞