DOTS实战技巧总结

【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()来获取。这两个函数的区别在于第三个参数,第一个传入的是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()获得管理器的实体,在代码第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)

posted @ 2023-08-16 16:03  UWATech  阅读(202)  评论(0编辑  收藏  举报