【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的区别。
- Run:运行于主线程下。
- Schedule:运行于工作线程下,同一个Job只能在同一个工作线程下执行。
- 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)
发表回复