关于unity:DOTS实战技巧总结

65次阅读

共计 17859 个字符,预计需要花费 45 分钟才能阅读完成。

【USparkle 专栏】如果你深怀绝技,爱“搞点钻研”,乐于分享也博采众长,咱们期待你的退出,让智慧的火花碰撞交错,让常识的传递生生不息!

随着技术的倒退,客户端方向技术也日趋考究高性能,毕竟大世界高自由度玩法正在迫近,在这一趋势下,Unity 也是催生出了 DOTS 这一高性能技术计划,这一解决方案考究的是高并行和缓存敌对。以后的 DOTS 还处于正式版前夕的 1.0 版本,尽管有很多有余,然而其用法和开发思维曾经根本确定,将来的正式版本除了反对更多 Unity 个性,开发思维预计变动不会太大。

一、System 间的数据传递

依照 ECS 的代码框架,代码逻辑只能写到 System 外面。理论开发过程中,有很多情景下是须要 A 零碎去告诉 B 零碎做某件事的。

在以往的 Unity 程序开发中,代码的自由度十分高,要实现 A 零碎告诉 B 零碎做某事的方法很多。最简略的方法就是通过回调函数来实现,也能够通过观察者模式实现一个音讯零碎等这样的模式比拟优雅地去实现。

然而 DOTS 的限度比拟多。首先,是用不了回调函数,因为 Burst 编译不反对 Delegate。其次,观察者模式也不适用于 ECS 框架,因为 ECS 框架的逻辑都是面向数据,将数据切成组,一批一批调用解决的。换句话说就是观察者模式是触发式的调用,ECS 框架的逻辑是轮训式的调用。

有一种思路是在 ISystem 里定义一个 NativeList 成员,用来接管内部传递给本 ISystem 的音讯数据,而后在 OnUpdate 函数里将音讯数据一个一个取出来解决。但会遇到以下几个问题。

问题一,若这个 NativeList 定义为 ISystem 的成员,其余 ISystem 在本人的 OnUpdate 函数里是拜访不到本人这个 ISystem 的对象的,只能拜访到其对应的 SystemHandle。那么是不是能够把 NaitveList 定义为动态变量呢?这将会引出问题二和问题三。

当然,也能够调用 EntityManager.AddComponent(SystemHandle system,ComponentType componentType) 给零碎加组件,而后其余 ISystem 拜访这个零碎的组件来达到消息传递的目标,然而这种做法首先只能传递独自一个组件的数据,数据质变大就不实用了;其次这种做法不属于本文的技巧,这是 Unity Entities 1.0 官网的规范做法,有足够的文档去形容如何操作,这里不再赘述。

问题二,ISystem 的 OnUpdate 函数只能拜访 readonly 润饰的动态容器变量。如果不必 readonly 润饰 NativeList,并且在 OnUpdate 里还去拜访他的话,就会失去报错信息。

Burst error BC1042: The managed class type Unity.Collections.NativeList1<XXX>* is not supported. Loading from a non-readonly static field XXXSystem.xxx` is not supported

问题三,如果用 readonly 润饰动态的 NativeList,就不得不在定义变量时初始化变量,那么你将会失去如下报错信息。

(0,0): Burst error BC1091: External and internal calls are not allowed inside static constructors: Unity.Collections.LowLevel.Unsafe.AtomicSafetyHandle.Create_Injected(ref Unity.Collections.LowLevel.Unsafe.AtomicSafetyHandle ret)

因而,应用 NativeList 的这种思路是行不通的。上面介绍一些我的项目中摸索进去的可行技巧。

1.1 将数据包装成实体传递
当初换种思路,先创立一个 Entity,并将要传递的信息组织成 IComponentData 绑到 Entity 上,造成一个个音讯实体,其余 ISystem 通过去遍历这些音讯实体实现数据在实体间传递的性能。

这种做法不仅实用于 ISystem 和 ISystem 之间的数据传递,甚至实用于 MonoBehaviour 和 ISystem、以及 ISystem 和 SystemBase 之间的数据传递。

上面是一个具体例子,定义了一个 MonoBehaviour 用来绑定到 UGUI 的按钮上,点击按钮就会调用 OnClick 函数。

public struct ClickComponent : IComponentData
{public int id;}

public class UITest : MonoBehaviour
{public void OnClick()
    {
        World world = World.DefaultGameObjectInjectionWorld;
        EntityManager dstManager = world.EntityManager;

        // 每次点击按钮都创立一个 Entity
        Entity e = dstManager.CreateEntity();
        dstManager.AddComponentData(e, new ClickComponent()
        {id = 1});
    }
}

代码第 14 到第 18 行,就是把要传递的音讯(id = 1)包装为一个 Entity 给到默认世界。

上面是在另外的 ISystem 里接管按钮点击音讯的代码。

public partial struct TestJob : IJobEntity
{
    public Entity managerEntity;
    public EntityCommandBuffer ecb;

    [BurstCompile]
    void Execute(Entity entity, in ClickComponent c)
    {
        // TODO...
        UnityEngine.Debug.Log("接管到按钮点击的音讯");

        ecb.DestroyEntity(entity);
    }
}

[BurstCompile]
public partial struct OtherSystem : ISystem
{void OnUpdate(ref SystemState state)
    {var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
        EntityCommandBuffer ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
        Entity managerEntity = SystemAPI.GetSingletonEntity<CharacterManager>();

        TestJob job = new TestJob()
        {
            managerEntity = managerEntity,
            ecb = ecb
        };
        job.Schedule();}
}

代码第 7 行,在 IJobEntity 的 Execute 函数外部,拜访了所有的点击按钮音讯。通过代码第 30 行对 Job 的调用,实现了音讯从 MonoBehaviour 到 ISystem 的传递。

代码第 12 行,通过调用 ecb.DestroyEntity 将曾经解决的音讯删除。

以下是运行后果。

到这里就实现了所有性能,然而如果你是一个须要时刻盯着 Entities Hierarchy 窗口调试代码的开发者,这种做法会让你的窗口闪动不停,根本无法在引擎界面上察看 Entity 的属性。那么有没有别的方法呢?

1.2 应用 DynamicBuffer 接收数据
这种做法和应用 NativeList 的思路相似,不过这里应用 DynamicBuffer 来代替 NativeList。

接着 1.1 大节的例子持续写代码。这里咱们要实现的是点击 UGUI 的按钮,创立出一个角色,就是创立一个角色零碎来接管创立音讯的数据。

public struct CreateCharacterRequest : IBufferElementData
{public int objID;}

public struct CharacterManager : IComponentData { }

[BurstCompile]
public partial struct CharacterSystem : ISystem
{[BurstCompile]
    void OnCreate(ref SystemState state)
    {
        // 创立一个管理器的 Entity 来治理所有申请
        Entity managerEntity = state.EntityManager.CreateEntity();
        // 创立一个 TagComponent 来获取管理器的 Entity
        state.EntityManager.AddComponentData(managerEntity, new CharacterManager());
        // 创立一个 DynamicBuffer 来接管创立申请
        state.EntityManager.AddBuffer<CreateCharacterRequest>(managerEntity);

        state.EntityManager.SetName(managerEntity, "CharacterManager");
    }

    [BurstCompile]
    void OnUpdate(ref SystemState state)
    {DynamicBuffer<CreateCharacterRequest> buffer = SystemAPI.GetSingletonBuffer<CreateCharacterRequest>();

        for (int i = 0; i < buffer.Length; ++i)
        {CreateCharacterRequest request = buffer[i];

            // TODO...
            Debug.Log("创立一个角色...");
        }
        buffer.Clear();}

    /// <summary>
    /// 申请创立角色(工作线程 / 主线程)/// </summary>
    /// <param name="request"> 申请数据 </param>
    /// <param name="manager"> 通过 SystemAPI.GetSingletonEntity<EntityManager>()获取 </param>
    /// <param name="ecb">ECB</param>
    public static void RequestCreateCharacter(in CreateCharacterRequest request, Entity manager, EntityCommandBuffer ecb)
    {ecb.AppendToBuffer(manager, request);
    }

    /// <summary>
    /// 申请创立角色(并行工作线程)/// </summary>
    /// <param name="request"> 申请数据 </param>
    /// <param name="manager"> 通过 SystemAPI.GetSingletonEntity<EntityManager>()获取 </param>
    /// <param name="ecb">ECB</param>
    public void RequestCreateCharacter(in CreateCharacterRequest request, Entity manager, EntityCommandBuffer.ParallelWriter ecb)
    {ecb.AppendToBuffer(0, manager, request);
    }
}

代码第 12 行的 OnCreate 函数创立了一个管理器实体,这个实体上有一个 DynamicBuffer 用来保留其余零碎的申请数据。

代码第 29 行通过一个 for 循环去遍历这个 DynamicBuffer 的所有数据,并在代码第 34 行解决这些数据,当初是简略的打印一句话。

代码第 36 行通过调用 buffer.Clear()将曾经解决过的数据从 DynamicBuffer 里删除。

代码第 45 行和代码第 56 行,定义了两个名为 RequestCreateCharacter 的函数供其余 ISystem 调用,其中第二个参数 Entity manager 比拟非凡,须要其余 ISystem 在主线程的 OnUpdate 函数中调用 SystemAPI.GetSingletonEntity<EntityManager>()来获取。这两个函数的区别在于第三个参数,第一个传入的是 EntityCommandBuffer,第二个传入的是 EntityCommandBuffer.ParallelWriter,也就是说第一个函数用于主线程和通过调用 Schedule 函数执行的 Job,第二个函数用于通过调用 ScheduleParallel 函数执行的 Job。

温习一下 Run、Schedule 和 ScheduleParallel 的区别。

  1. Run:运行于主线程下。
  2. Schedule:运行于工作线程下,同一个 Job 只能在同一个工作线程下执行。
  3. ScheduleParallel:运行于工作线程下,同一个 Job,不同 Chunk 的数据会调配到不同工作线程下执行,然而有诸多限度,比方不能写入主线程里 Allocate 的 Container 等。

上面看一下如何给 CharacterSystem 发消息,申请创立角色。上面回到 1.1 大节写的代码里看。

public partial struct TestJob : IJobEntity
{
    public Entity managerEntity;
    public EntityCommandBuffer ecb;

    [BurstCompile]
    void Execute(Entity entity, in ClickComponent c)
    {CharacterSystem.RequestCreateCharacter(new CreateCharacterRequest()
        {objID = c.id}, managerEntity, ecb);

        ecb.DestroyEntity(entity);
    }
}

[BurstCompile]
public partial struct OtherSystem : ISystem
{void OnUpdate(ref SystemState state)
    {var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
        EntityCommandBuffer ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
        Entity managerEntity = SystemAPI.GetSingletonEntity<CharacterManager>();

        TestJob job = new TestJob()
        {
            managerEntity = managerEntity,
            ecb = ecb
        };
        job.Schedule();}
}

代码第 25 行通过调用 SystemAPI.GetSingletonEntity<CharacterManager>()取得管理器的实体,在代码第 12 行传递给 RequestCreateCharacter 函数。

代码第 9 行通过调用 RequestCreateCharacter 函数,将 CreateCharacterRequest 类型的数据传递给了 CharacterSystem。

运行后果如下。

这样就用了两种方法实现了 System 之间,甚至和 MonoBehaviour 之间的数据传递。

二、模仿多态

面向数据的设计相比于面向对象的设计会难不少,毕竟面向对象的初衷就是升高开发的思维难度,将世间万物形象为对象来设计的。而面向数据的初衷可不是为了升高思维难度,而是为了执行效率更加高效而产生的。

那么,能不能联合一下面向数据的高效和面向对象的“傻瓜”呢?

这里最大的妨碍来自于 DOTS 外面所有的数据都应用的是 struct,struct 自身不反对继承和多态,在框架设计的很多时候,这个个性会极大地解放住设计者。

当然,你会感觉也能够应用 interface,这样治理类就能够通过 interface 治理不同品种的 struct 数据,然而 DOTS 是不认 interface 的,因为 DOTS 须要值类型,而 interface 是无奈表明本身到底是值类型还是援用类型的。实操一下就晓得这个思路不可行了。

比方要做一个状态机零碎,它的组成单位是一个一个的状态,依照 OOD 的思维来说,须要有一个状态基类,而后每个具体的状态类须要继承自这个基类去写实现。这么简略的设计,在 DOD 外面会很艰难。

上面介绍一个在我的项目过程中摸索进去的技巧。

如果相熟 C ++ 会晓得有 union 的存在,当然 C# 也有相似的存在,也就是 StructLayout 和 FieldOffset。通过应用这种精准布局也好,联合体也好的操作,就能在尽可能小的耗费的状况下,变相实现类的继承。

上面是状态基类的定义。

using System.Runtime.InteropServices;
using Unity.Entities;

// 状态枚举
public enum StateType
{
    None,
    Idle,
    Chase,
    CastSkill,
    Dead,
}

/// <summary>
/// 所有状态实现类都须要继承自 IBaseState
/// </summary>
public interface IBaseState
{void OnEnter(Entity entity, ref StateComponent self, ref StateHelper helper);

    void OnUpdate(Entity entity, ref StateComponent self, ref StateHelper helper);

    void OnExit(Entity entity, ref StateComponent self, ref StateHelper helper);
}

/// <summary>
/// 寄存状态子类用的组件
/// </summary>
[Serializable]
[StructLayout(LayoutKind.Explicit)]
public struct StateComponent : IBufferElementData
{[FieldOffset(0)]
    public StateType stateType;

    [FieldOffset(4)]
    public int id;

    [FieldOffset(8)]
    public NoneState noneState;
    [FieldOffset(8)]
    public IdleState idleState;
    [FieldOffset(8)]
    public ChaseState chaseState;

    public void OnEnter(Entity entity, ref StateComponent self, ref StateHelper helper)
    {switch (stateType)
        {
            case StateType.None:
                noneState.OnEnter(entity, ref self, ref helper);
                break;
            case StateType.Idle:
                idleState.OnEnter(entity, ref self, ref helper);
                break;
            case StateType.Chase:
                chaseState.OnEnter(entity, ref self, ref helper);
                break;
        }
    }

    public void OnUpdate(Entity entity, ref StateComponent self, ref StateHelper helper)
    {switch (stateType)
        {
            case StateType.None:
                noneState.OnUpdate(entity, ref self, ref helper);
                break;
            case StateType.Idle:
                idleState.OnUpdate(entity, ref self, ref helper);
                break;
            case StateType.Chase:
                chaseState.OnUpdate(entity, ref self, ref helper);
                break;
        }
    }

    public void OnExit(Entity entity, ref StateComponent self, ref StateHelper helper)
    {switch (stateType)
        {
            case StateType.None:
                noneState.OnExit(entity, ref self, ref helper);
                break;
            case StateType.Idle:
                idleState.OnExit(entity, ref self, ref helper);
                break;
            case StateType.Chase:
                chaseState.OnExit(entity, ref self, ref helper);
                break;
        }
    }
}

代码第 29 行到第 31 行定义了一个名为 StateComponent 的构造体,这个构造体用到了上文提到的两个标签 StructLayout 和 FieldOffset,用来管制内存的布局。

代码第 34 行定义了 StateType 类型的成员 stateType,表明这个 StateComponent 构造体里寄存的实现类的对象是哪一个。比方,如果 stateType 的值为 StateType.Chase,那么代码第 44 行的 chaseState 对象是被初始化填充过值的,而代码第 40 行的 noneState 对象,以及代码第 42 行的 idleState 对象都是未经初始化的。

从代码第 46 行的 OnEnter 函数、代码第 62 行的 OnUpdate 函数以及代码第 78 行的 OnExit 函数的实现能够晓得,stateType 的值决定了 StateComponent 这个构造真正失效的对象,也就是说内部调用 StateComponent 的 OnEnter 函数、OnUpdate 函数和 OnExit 函数,就会触发调用到对应 IBaseState 的子类的 OnEnter 函数、OnUpdate 函数和 OnExit 函数。

角色身上治理状态应用一个 DynamicBuffer 即可,如下所示。

Entity characterEntity = GetCharacter();
state.EntityManager.AddBuffer<StateComponent>(characterEntity);

通过这种方法就模拟出了面向对象的多态。

上面再具体看一下 ChaseState 构造体的实现,以便有更加残缺的认知。

using Unity.Entities;
using Unity.Mathematics;

public struct ChaseState : IBaseState
{
    public Entity target;
    public float duration;

    private float endTime;

    public void OnEnter(Entity entity, ref StateComponent self, ref StateHelper helper)
    {endTime = helper.elapsedTime + duration;}

    public void OnExit(Entity entity, ref StateComponent self, ref StateHelper helper)
    { }

    public void OnUpdate(Entity entity, ref StateComponent self, ref StateHelper helper)
    {if (helper.elapsedTime >= endTime)
        {
            // 跳转到下一个状态
            return;
        }
        if (!helper.localTransforms.TryGetComponent(target, out var targetTrans))
        {return;}
        if (!helper.localTransforms.TryGetComponent(entity, out var selfTrans))
        {return;}
        float3 dir = math.normalizesafe(targetTrans.Position - selfTrans.Position);
        selfTrans.Position = selfTrans.Position + dir * helper.deltaTime;
        helper.localTransforms[entity] = selfTrans;
    }
}

从代码可见这是一个追击指标的状态,逻辑很简略。OnEnter 函数里确定了一个追击工夫,OnUpdate 查看追击工夫,如果超过了追击状态的持续时间,就跳转到下一个状态。其中 StateHelper 外面保留了一些主线程 OnUpdate 函数外面收集到的数据,包含各种 ComponentLookup。

有了这种模仿的多态,很多面向对象的设计模式都能够利用到面向数据的设计外面。然而这种做法有两大缺点须要留神:

  • 因为应用了相似联合体的构造去从新布局内存,那么这个组件(也就是上文说的 StateComponent)的内存占用空间,将会等于这些实现类外面最大的那个类的占用空间。
  • 扩大新的实现类要写的代码量减少,这里举荐能够写一个生成代码的编辑器来辅助扩大实现。

三、性能调试

本大节属于比拟根底的局部,次要介绍一些区别于以往调试的一些小技巧。因为以往的开发多是在主线程下面写逻辑,DOTS 应用到了 Job,所以很多次要逻辑会扩散到各个工作线程(Work Thread)下面。

3.1 Profiler 的应用办法
Profiler 如何调试编辑器以及如何调试真机,办法和以往的开发差不多,不再赘述,不一样的是进行多线程的剖析,上面简略介绍一下。

上面写两个简略的 Job,察看一下他们的性能状况。

public struct Test1Component : IBufferElementData
{
    public int id;
    public int count;
}

[UpdateAfter(typeof(Test2System))]
public partial class Test1System : SystemBase
{protected override void OnCreate()
    {Entity managerEntity = EntityManager.CreateEntity();
        EntityManager.AddBuffer<Test1Component>(managerEntity);
    }

    protected override void OnUpdate()
    {
        // 创立容器,从工作线程外面取数据
        NativeList<Test1Component> list = new NativeList<Test1Component>(Allocator.TempJob);

        // 遍历 DynamicBuffer 里的所有元素,并传递给主线程的 list,用来在主线程里打印日志
        Dependency = Entities
            .WithName("Test1Job")
            .ForEach((in DynamicBuffer<Test1Component> buffer) =>
            {for (int i = 0; i < buffer.Length; ++i)
                {list.Add(buffer[i]);
                }
            }).Schedule(Dependency);

        // 期待工作线程完结
        CompleteDependency();

        // 主线程里打印所有元素
        for (int i = 0; i < list.Length; ++i)
        {Test1Component c = list[i];
            Debug.Log("element:" + c.id);
        }
        // 开释容器
        list.Dispose();}
}

代码第 7 到第 8 行定义了一个名为 Test1System 的零碎,执行程序是紧跟在名为 Test2System 的零碎前面。

代码第 19 行创立了一个 NativeList,传递到工作线程,收集工作线程外面的 DynamicBuffer 里的所有元素。

温习一下:这里须要留神,容器的构造函数里传入的调配符必须是 Allocator.TempJob,因为这个容器须要在 Job 外面拜访,帧末记得期待 Job 运行完结,并把这个容器 Dispose 掉。(当然也能够在 Lambda 表达式外面用 WithDisposeOnCompletion 在 Lambda 执行完就立刻开释,然而因为主线程还要应用,所以延后开释。)

代码第 22 行到第 30 行是一个简略的 Job,将 DynamicBuffer 里的所有元素传递给 NativeList。

为了在 Profiler 里能更好分辨出 Job,最好应用 WithName 函数赋予该 Job 一个名字。

代码第 33 行调用 CompleteDependency 函数,期待本帧内工作线程的 Job 执行完结,才会持续往下执行 OnUpdate 的残余的代码。

代码第 36 行之后就是在主线程外面的运行,与以往的开发一样不再详述。

public partial class Test2System : SystemBase
{
    public static int index;

    protected override void OnUpdate()
    {
        int id = ++index;

        Entities
            .WithName("Test2Job")
            .ForEach((ref DynamicBuffer<Test1Component> buffer) =>
            {
                // 往 DynamicBuffer 外面加元素
                buffer.Add(new Test1Component()
                {id = id});

                // 上面的代码单纯为了减少性能耗费
                for (int i = 0; i < buffer.Length; ++i)
                {var c = buffer[i];
                    c.count = buffer.Length;
                    for (int j = 0; j < 10000; ++j)
                    {c.count = buffer.Length + j;}
                }
            }).Schedule();}
}

代码第 9 行到代码第 29 行,定义了一个名为 Test2System 的零碎,这个零碎里实现了一个名为“Test2Job”的 Job,这个 Job 没有别的性能,次要就是写了减少性能耗费的。

上面执行了,看一下这些 Job 的耗费。

依照以往的调试教训间接看主线程性能耗费,会发现 Test1System 竟然占了 91.5%,从代码逻辑看显然是不合理的。这个时候须要关上 Timeline 看一下。

从截图能够看到以下几点信息:

  • Test2Job 是优先于 Test1Job 执行的,这是因为 Test1System 加了 [UpdateAfter(typeof(Test2System))] 这个标签。
  • Test1Job 的次要耗费是 JobHandle.Complete,也就是在期待本帧的工作线程完结。
  • 工作线程“Worker 0”的 Timeline 上体现出了真正的耗费,这个耗费次要就是上文代码写的 Test2Job 里上万次的循环。

点击 Timeline 下面的 Test2Job 块,就会弹出提示框,点击提示框上“Show > Hierachy”就可以看这个 Job 的具体耗费状况了。

留神,如果要应用断点或者 Profiler.BeginSample / Profiler.EndSample 函数,该 Job 须要调用 WithoutBurst 函数,防止应用 Burst 编译。

因而,在 DOTS 外面应用 Profiler 剖析,不能仅仅看主线程的耗费,要综合看工作线程的耗费以及主线程的期待状况,用于参考剖析的因素会更多。

3.2 Lookup 的耗费
大节的开始局部,先解释一下大节题目里的 Lookup 指的是什么?在 ECS 框架里,如果须要通过 Entity 去拜访 Component 或者 DynamicBuffer,官网给出了一组构造,他们别离是 ComponentLookup 和 BufferLookup(老版本 DOTS 外面叫做 ComponentDataFromEntity 和 BufferFromEntity)。在本文中,这些用来随机拜访组件的构造统称 Lookup。

以往的 Unity 开发中,要拜访某个 GameObject 上的某个 MonoBehaviour 是一件很简略的事件,通过调用 gameObject.GetComponent 就能够拜访到,然而在 ECS 框架外面就比拟艰难。

假如咱们要写一个性能:创立 30 个小球和 200 个方块,小球会找到离本人最近的两个方块,在这两个方块间来回弹。上面先写一段代码看看 Profiler 的状况。

protected override void OnUpdate()
{
    float deltaTime = SystemAPI.Time.DeltaTime;
    ComponentLookup<LocalTransform> transforms = SystemAPI.GetComponentLookup<LocalTransform>();

    // 查问所有方块的实体
    NativeArray<Entity> entities = entitiesQuery.ToEntityArray(Allocator.TempJob);

    Entities
        .WithoutBurst()
        .WithName("Test2Job")
        .ForEach((/* 小球的实体 */Entity entity, int entityInQueryIndex, /* 小球的挪动组件 */ref BulletMove move) =>
        {
            float minDist = float.MaxValue;
            LocalTransform targetTransform = default(LocalTransform);

            // 遍历所有方块,查找离小球最近的方块并凑近它
            for (int i = 0; i < entities.Length; ++i)
            {Entity targetEntity = entities[i];
                // 上次凑近过的方块先排除掉
                if (move.lastEntity == targetEntity)
                {continue;}
                // 通过 Lookup 获取小球的地位
                if (!transforms.TryGetComponent(entity, out var selfT))
                {continue;}
                // 通过 Lookup 获取方块的地位
                if (!transforms.TryGetComponent(targetEntity, out var targetT))
                {continue;}
                float distance = math.distance(targetT.Position, selfT.Position);
                // 找到离小球最近的方块
                if (minDist > distance)
                {
                    minDist = distance;
                    move.targetEntity = targetEntity;
                    targetTransform = targetT;
                }
            }

            if (!transforms.TryGetComponent(entity, out var t))
            {return;}

            // 朝着离小球最近的方块凑近
            float3 dir = targetTransform.Position - t.Position;
            float3 n = math.normalizesafe(dir);
            t.Position = t.Position + n * deltaTime;

            // 达到离小球最近的方块左近,记录该方块,下一帧开始将不再朝着这个方块凑近
            if (math.length(dir) <= 0.5f)
            {move.lastEntity = move.targetEntity;}

            transforms[entity] = t;
        }).Schedule();}

以上代码足够简略不再过多解说其逻辑,须要留神的是,代码第 27 行和第 28 行别离调用了两次 ComponentLookup 的 TryGetComponent 函数,这个例子里有 200 个方块,也就是说每个小球将会调用 400 次 TryGetComponent 函数。耗费如下。

从上图能够看到,仅仅每个小球 400 次调用就破费了 2.89ms,这是一个很大的开销。那么咱们尝试优化一下,将每个小球 400 次 TryGetComponent 函数调用改为每个小球 200 次看看。

protected override void OnUpdate()
{
    float deltaTime = SystemAPI.Time.DeltaTime;
    ComponentLookup<LocalTransform> transforms = SystemAPI.GetComponentLookup<LocalTransform>();

    // 查问所有方块的实体
    NativeArray<Entity> entities = entitiesQuery.ToEntityArray(Allocator.TempJob);

    Entities
        .WithoutBurst()
        .WithName("Test2Job")
        .ForEach((/* 小球的实体 */Entity entity, int entityInQueryIndex, /* 小球的挪动组件 */ref BulletMove move) =>
        {
            // 通过 Lookup 获取小球的地位
            if (!transforms.TryGetComponent(entity, out var t))
            {return;}

            float minDist = float.MaxValue;
            LocalTransform targetTransform = default(LocalTransform);

            // 遍历所有方块,查找离小球最近的方块并凑近它
            for (int i = 0; i < entities.Length; ++i)
            {Entity targetEntity = entities[i];
                // 上次凑近过的方块先排除掉
                if (move.lastEntity == targetEntity)
                {continue;}
                // 通过 Lookup 获取方块的地位
                if (!transforms.TryGetComponent(targetEntity, out var targetT))
                {continue;}
                float distance = math.distance(targetT.Position, t.Position);
                // 找到离小球最近的方块
                if (minDist > distance)
                {
                    minDist = distance;
                    move.targetEntity = targetEntity;
                    targetTransform = targetT;
                }
            }

            // 朝着离小球最近的方块凑近
            float3 dir = targetTransform.Position - t.Position;
            float3 n = math.normalizesafe(dir);
            t.Position = t.Position + n * deltaTime;

            // 达到离小球最近的方块左近,记录该方块,下一帧开始将不再朝着这个方块凑近
            if (math.length(dir) <= 0.5f)
            {move.lastEntity = move.targetEntity;}

            transforms[entity] = t;
        }).Schedule();}

代码第 15 行在整个 Job 逻辑开始的中央,首先通过调用 TryGetComponent 函数获取了小球的地位,防止每次 for 循环都获取一次小球的地位。代码如此优化后,TryGetComponent 函数的调用次数升高了一半,耗费如下。

从上图可见,Job 的耗费从 2.89ms 升高到了 1.31ms,这是性能上一个很显著的晋升!

基于以上优化,再进一步思考,如果在调用 Job 之前曾经记录过以后方块的地位,并保留到一个名为 Target 的 IComponentData 构造里,那么岂不是整个 Job 外面都不须要调用 TryGetComponent 函数了?上面是基于此想法实现的代码。

protected override void OnUpdate()
{
    float deltaTime = SystemAPI.Time.DeltaTime;

    // 查问所有方块的 Target 组件
    NativeArray<Target> entities = entitiesQuery.ToComponentDataArray<Target>(Allocator.TempJob);

    Entities
        .WithoutBurst()
        .WithName("Test2Job")
        .ForEach((/* 小球的实体 */Entity entity, int entityInQueryIndex, /* 小球的挪动组件 */ref BulletMove move, /* 小球的地位组件 */ref LocalTransform t) =>
        {
            float minDist = float.MaxValue;
            int targetID = 0;
            float3 targetPos = float3.zero;

            // 遍历所有方块,查找离小球最近的方块并凑近它
            for (int i = 0; i < entities.Length; ++i)
            {Target target = entities[i];
                // 上次凑近过的方块先排除掉
                if (move.lastID == target.id)
                {continue;}
                float distance = math.distance(target.position, t.Position);
                // 找到离小球最近的方块
                if (minDist > distance)
                {
                    minDist = distance;
                    targetID = target.id;
                    targetPos = target.position;
                }
            }

            // 朝着离小球最近的方块凑近
            float3 dir = targetPos - t.Position;
            float3 n = math.normalizesafe(dir);
            t.Position = t.Position + n * deltaTime;

            // 达到离小球最近的方块左近,记录该方块,下一帧开始将不再朝着这个方块凑近
            if (math.length(dir) <= 0.5f)
            {move.lastID = targetID;}
        }).Schedule();}

从代码可见齐全勾销了 TryGetComponent 函数的调用,上面看一下这样实现代码的耗费。

不出意外代码效率再次进步!

如果你比拟仔细会发现,这三次代码的耗费在 Timeline 上都显示的是蓝色,这是为了调试不便,敞开了 Burst 编译。那么咱们将最初这次改变的代码关上 Burst 编译将会是什么状况呢?

可见关上 Burst 编译,Timeline 上的 Job 耗费变为了绿色,耗费再次升高,从 0.718ms 升高到 0.045ms。

以上试验证实:

  • Lookup 在大量实体计算的状况下效率并不高,这也是 DOTS 不举荐大量应用的起因。从 Entities 的源码上看,TryGetComponent 函数就是简略的指针偏移,没有很简单的逻辑,竟然对性能有如此大的影响。这毕竟是随机拜访,毁坏了缓存敌对性,所以不举荐应用。取而代之的优化技巧是在其余 Job 里组织好数据,再传递给以后 Job 应用。
  • 这个试验附带证实了 Burst 的弱小,能开启 Burst 编译的肯定要开启。

然而凡事并不相对,在并非性能热点的中央还是能够应用 Lookup 的 TryGetComponent 函数来简化逻辑的,毕竟要组织专门的数据也是有内存耗费和人力老本的。

四、总结

目前总结的 DOTS 的技巧就是这些,通过这些小技巧,能够帮忙老手解决一些不好下手的问题,至多能提供一种思路,不过毕竟摸索阶段,如有误区欢送斧正、交换。


这是侑虎科技第 1445 篇文章,感激作者 zd304 供稿。欢送转发分享,未经作者受权请勿转载。如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ 群:465082844)

作者主页:https://www.zhihu.com/people/zhang-dong-13-77

再次感激 zd304 的分享,如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ 群:465082844)

正文完
 0