Unity ECS(三)HelloWorld!ECS!(2)
view 2839
2019-11-13
E
EntherVarope
Electronic Arts-Leader Programmer
完善HelloWorldECS程序
在前文中,我们编写了一个由1万个方块实体组成的方块阵列,并在NoiseHeightSystem下呈噪波运动。也提到了,程序仍然存在问题,本章将解决这些问题,使之成为一个纯粹的ECS程序。
1.
利用Monobehaviour做数据输入源(CreateCubeEntity脚本中的字段 int row 与 int colum),并不符合ECS的理念,这是基于快速实现效果的妥协。
2.
由于快速实现效果的妥协,使用了固定数据和System结合在一块了,应当将它们剥离出去。
3.
它仍然是单线程运作的,并未利用到DOTS。
在原程序中整体的流程是这样的:
原先的流程
(一)将CreateCubeEntity的CreateCube方式转换成System工作机制
之前提到过,因为利用MonoBehaviour作为构建实体的数据输入源,这是不正确的,利用MonoBehaviour来命令EntityManager产生实体虽然简洁易懂,却造成了性能瓶颈,因为不管是Instantiate Entity(在ECS中我会避免使用实例化来描述构建一个Entity)或是SetComponentData(设置实体的数据)都是在MonoBehaviour中完成的,这是常规的OOP做法。
方块实体原型数据与大量方块实体的构建都是在MonoBehaviour中进行的
正确的做法是,将大量复制实体的流程独立成一个System。
那么改进后的流程应该是这样的:
注意这里将展示System流程中两个重要的部分OnCreate与OnUpdate
新建脚本SpawnCubeSystem,继承自ComponentSystem(同样别忘了命名空间!以后将不会特别强调),强制实现的OnUpdate()不做任何处理,作为空方法。
重写OnCreate()方法,完整的SpawnCubeSystem代码如下:
public class SpawnCubeSystem : ComponentSystem { protected override void OnCreate() { base.OnCreate(); var manager = World.Active.EntityManager; var cubeArchetype = EntityManager.CreateArchetype (ComponentType.ReadWrite<LocalToWorld>(), ComponentType.ReadWrite<Translation>(), ComponentType.ReadOnly<RenderMesh>()); var entity = manager.CreateEntity(cubeArchetype); var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); cube.GetComponent<MeshRenderer>().material.color = Color.black; cube.SetActive(false); cube.hideFlags=HideFlags.HideInHierarchy; manager.SetComponentData(entity, new Translation() { Value = new float3(0, 0, 0) }); manager.SetSharedComponentData(entity, new RenderMesh() { mesh = cube.GetComponent<MeshFilter>().sharedMesh, material = cube.GetComponent<MeshRenderer>().material, subMesh = 0, castShadows = UnityEngine.Rendering.ShadowCastingMode.Off, receiveShadows = false, }); using (NativeArray<Entity> entities = new NativeArray<Entity>(100* 100, Allocator.Temp, NativeArrayOptions.UninitializedMemory)) { manager.Instantiate(entity, entities); for (int i = 0; i < 100; i++) { for (int j = 0; j < 100; j++) { int index = i + j * 100; manager.SetComponentData(entities[index], new Translation() { Value = new float3(i, 0, j) }); } } } } protected override void OnUpdate() { } }
如果有认真思考,应该不难发现,我将CreateCubeEntity中的流程转移到此System脚本内了。
现在大胆地删掉CreateCubeEntity这个MonoBehaviour脚本(如果你不放心,你可以将场景内挂载了此脚本的物体删除或禁用此脚本)。工程内只有SpawnCubeSystem与NoiseHeightSystem这两个脚本
如果一切顺利,系统应该会自动进行设计的流程,产生和原先一样的效果。
我的Hierachy是空无一物的,系统但仍自动执行了程序。(别忘了再一次查看EntityDebugger窗口,看看实体现在有些什么变化)
OK,到现在为止已经解决了第一个问题,现在来着眼于第二个问题:
System中存在固有的数据,并没有Component来让System识别Entity里索引的数据,一切都是一开始就写死的。那么来为两个System完善Component。并为实体提供数据源:
基于快速实现的妥协,这里将使用Editor里的数据作为数据来源。注意,这种方式将GameObject转换成Entity,这个过程发生在System的OnCreate()之后,所以无法在一开始就让System执行产生大量方块实体,在OnUpdate里和普通MonoBehaviour的Update一样,为它设置限制条件,执行一次后就不再执行。当然这样的做法比较繁琐,但是在下文用上JobSystem之后就能解决这个问题。
新建SpawnCubeComponent脚本,只继承IComponentData接口,编写以下代码:
public struct SpawnCubeComponent : IComponentData { public int row; public int colum; public Entity prefab; }
和以上类似,编写NoiseHeightComponent脚本:
public struct NoiseHeightComponent : IComponentData { public float waveFactor; public float sampleFactor; }
编写SpawnCubeDataSource和NoiseHeightDataSource 两个脚本,都要继承特殊的接口和特性引用:
SpawnCubeDataSource:
[System.Serializable] [RequireComponent(typeof(ConvertToEntity))] [RequiresEntityConversion] public class SpawnCubeDataSource : MonoBehaviour,IConvertGameObjectToEntity,IDeclareReferencedPrefabs { [Header("SpawnCubeSample param")] public int row; public int colum; public GameObject prefab; public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem) { var spawnCubeData=new SpawnCubeComponent() { prefab = conversionSystem.GetPrimaryEntity(this.prefab), row=this.row, colum = this.colum }; dstManager.AddComponentData(entity,spawnCubeData); } public void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs) { referencedPrefabs.Add(prefab); } }
NoiseHeightDataSource:
[System.Serializable] [RequiresEntityConversion] public class NoiseHeightDataSource : MonoBehaviour,IConvertGameObjectToEntity { [Header("NoiseHeightSample param")] public float waveFactor; public float sampleFactor; public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem) { var noiseHeightData=new NoiseHeightComponent() { waveFactor = this.waveFactor, sampleFactor = this.sampleFactor }; dstManager.AddComponentData(entity,noiseHeightData); } }
最后修改对应的SpawnCubeSystem和NoiseHeightSystem,将System与Component关联起来:(再提一次,用GameObject转换Entity的方式,转换出来的Entity只能够在OnUpdate()里利用上)
SpawnCubeSystem:
public class SpawnCubeSystem : ComponentSystem { private EntityManager manager; private bool isSpawnCompleted; protected override void OnCreate() { base.OnCreate(); manager = World.Active.EntityManager; isSpawnCompleted = false; } protected override void OnUpdate() { if (!isSpawnCompleted) { Entities.ForEach((ref SpawnCubeComponent spawnCubeComponent) => { var row = spawnCubeComponent.row; var colum = spawnCubeComponent.colum; using (NativeArray<Entity> entities = new NativeArray<Entity>(row* colum, Allocator.Temp, NativeArrayOptions.UninitializedMemory)) { manager.Instantiate(spawnCubeComponent.prefab, entities); for (int i = 0; i < row; i++) { for (int j = 0; j < colum; j++) { int index = i + j * colum; manager.SetComponentData(entities[index], new Translation() { Value = new float3(i, 0, j) }); } } } isSpawnCompleted = true; }); }
NoiseHeightSystem:
public class NoiseHeightSystem : ComponentSystem { protected override void OnUpdate() { var time = Time.realtimeSinceStartup; Entities.ForEach((ref Translation translation,ref NoiseHeightComponent noiseHeightComponent) => { var waveFactor = noiseHeightComponent.waveFactor; var sampleFactor = noiseHeightComponent.sampleFactor; translation.Value.y = waveFactor * noise.snoise(new float2(time + sampleFactor * translation.Value.x, time + sampleFactor * translation.Value.z)); }); } }
请创建一个空物体EntityDataSource,并将SpawnCubeDataSource挂载到它身上作为生成方块实体阵列的数据源。请创建一个方块预制体,并将NoiseHeightDataSource挂载到它身上作为方块实体的材质数据(你也可以为方块创造独特的材质),网格数据,阴影接受数据,噪波运动的数据源,并将其设为EntityDataSource的目标预制体。
场景内EntityDataSource物体
方块预制体
(PS:你可以对方块材质启用GPU Instancing来进一步释放性能)
现在一切就绪,输入不同的值来查看ECS程序呈现的不同效果。
Good,现在我们解决了第二个问题,将Component与System关联起来,System不用关心数据,只进行运算,并且能让程序产生一些多样的变化,请注意一点,一种System最好只负责一类型的数据运算(用Component来限制过程范围),所以在设计数据结构的时候请特别小心。
现在只剩下最后一个问题了:
它仍然是单线程的!
善于观察的读者可能已经发现了,在将SpawnCubeData的row colum增大时,程序显著的变卡顿了,难道编了假的ECS程序?
请在Window->Analysis下打开Profiler调试窗口,运行程序并观察Profiler中的的Thread部分:
Profiler窗口同样重要,请理解其中的内容与功能
我的CPU是四核心的,可以看到CPU的每帧工作时间是33ms,只有主线程(Main Thread)在工作,内存的读取频率很低,FPS也只有30左右,剩下的三个核心Job-> Worker 0 Worker1 Worker2正在呼呼大睡(Idle),这可一点也不Cool,必须唤醒它们,让它们也参与进来!
(二)使用JobSystem改进程序为多线程,并分配至核心上
使用JobSystem来进行多线程
前往SpawnCubeSystem,将继承改为JobComponentSystem,并加入特性[UpdateInGroup(typeof(SimulationSystemGroup))]标志这段System将在预计算进行。顺带将利用实体命令缓冲系统(BeginInitializationEntityCommandBufferSystem)解决之前的方块生成问题(利用bool来限制一个System的运行实在不是一个高明科学的做法),编写以下代码:
[UpdateInGroup(typeof(SimulationSystemGroup))] public class SpawnCubeSystem : JobComponentSystem { private BeginInitializationEntityCommandBufferSystem m_EntityCommandBufferSystem; protected override void OnCreate() { m_EntityCommandBufferSystem = World.GetOrCreateSystem<BeginInitializationEntityCommandBufferSystem>(); } struct SpawnCubeJob:IJobForEachWithEntity<SpawnCubeComponent,LocalToWorld> { public EntityCommandBuffer.Concurrent CommandBuffer; public void Execute(Entity entity, int index, [ReadOnly]ref LocalToWorld location,[ReadOnly]ref SpawnCubeComponent spawnCubeComponent) { for (var i = 0; i < spawnCubeComponent.row; i++) { for (int j = 0; j < spawnCubeComponent.colum; j++) { var instance = CommandBuffer.Instantiate(index, spawnCubeComponent.prefab); CommandBuffer.SetComponent(index,instance,new Translation() { Value = new float3(i,0,j) }); } } CommandBuffer.DestroyEntity(index,entity); } } protected override JobHandle OnUpdate(JobHandle inputDeps) { var job=new SpawnCubeJob() { CommandBuffer = m_EntityCommandBufferSystem.CreateCommandBuffer().ToConcurrent() }.Schedule(this,inputDeps); m_EntityCommandBufferSystem.AddJobHandleForProducer(job); return job; } }
前往NoiseHeightSystem,将继承改为JobComponentSystem。编写以下代码:
public class NoiseHeightSystem : JobComponentSystem { struct TranslationNoise:IJobForEach<NoiseHeightComponent,Translation> { public float time; public void Execute([ReadOnly]ref NoiseHeightComponent noiseHeightComponent,ref Translation translation) { var waveFactor = noiseHeightComponent.waveFactor; var sampleFactor = noiseHeightComponent.sampleFactor; translation.Value.y = waveFactor * noise.snoise(new float2(time + sampleFactor * translation.Value.x, time + sampleFactor * translation.Value.z)); } } protected override JobHandle OnUpdate(JobHandle inputDeps) { var job=new TranslationNoise() { time=Time.realtimeSinceStartup }; return job.Schedule(this, inputDeps); } }
保存再次运行程序,并观察Profiler窗口:
使用了JobSystem后Profiler的状态
(不同的电脑配置应该会有不一样的效果,但是带来的性能提升应该是显著的,易于观察的)
如果一切顺利,你将会看到这个令人激动的效果,每一个核心有了一份工作(Job)在8ms内就能完成原本要33ms才能完成的工作,主线程再也不用面临“一核有难多核围观”的窘境,对内存的读取效率也有十分大的提升(可以对比前一张截图Memory的曲线抖动频率),FPS也达到了150左右,可以说是“人多力量大”。
(PS:如果在Editor中关闭垂直同步,就能显示出ECS原本的更高的FPS效率,但一般不考虑这种情况,垂直同步会将帧率稳定在一个较高的位置,但不会体现ECS真正的运算画面,可能会产生画面撕裂)
到此工作尚未完成,仅仅是利用到DOTS里的JobSystem实现了多线程任务,CPU的SIMD特性还没利用到,每一个核心的执行单元仍在走SISD的工作流程,还能再进一步让CPU启用SIMD特性,要利用SIMD则需要启动爆发式编译器Burst Complier
使用Burst Complier的方法非常简单,只需要引入命名空间Unity.Brust,并在你声明Job的地方加上[Brust Complier]特性即可。
在NoiseHeightSystem中,以下部位加入爆发式编译特性,系统将会识别这段代码,并进入爆发式编译流程。
[BurstCompile] struct TranslationNoise:IJobForEach<NoiseHeightComponent,Translation> { public float time; public void Execute([ReadOnly]ref NoiseHeightComponent noiseHeightComponent,ref Translation translation) { var waveFactor = noiseHeightComponent.waveFactor; var sampleFactor = noiseHeightComponent.sampleFactor; translation.Value.y = waveFactor * noise.snoise(new float2(time + sampleFactor * translation.Value.x, time + sampleFactor * translation.Value.z)); } }
在SpawnCubeSystem中,同样加入爆发式编译特性:
[BurstCompile] struct SpawnCubeJob:IJobForEachWithEntity<LocalToWorld,SpawnCubeComponent> { public EntityCommandBuffer.Concurrent CommandBuffer; public void Execute(Entity entity, int index, [ReadOnly]ref SpawnCubeComponent spawnCubeComponent,[ReadOnly]ref LocalToWorld location) { for (var i = 0; i < spawnCubeComponent.row; i++) { for (int j = 0; j < spawnCubeComponent.colum; j++) { var instance = CommandBuffer.Instantiate(index, spawnCubeComponent.prefab); CommandBuffer.SetComponent(index,instance,new Translation() { Value = new float3(i,0,j) }); } } CommandBuffer.DestroyEntity(index,entity); } }
保存并运行:
启用了Brust Complier的Profiler状态
Great!如同Burst Complier的名字一样,在爆发式编译器的协助下,每一个线程的任务完工时间都被极大压缩,FPS平均达到了500+!(这还是在我开着数个其他3D程序的状况下)同时由于SIMD多个执行单元同时在内存中进行读取,对内存的读取震幅也趋向于平和稳定!
在接下来的文章中,我会写一些摘要,对此HelloWorld中的一些流程,API做一些说明,如果空闲时间充足的话再做一个较为简易的海洋大群模拟。
最后以一张60帧率的10万实体方块阵列来结束此实例。(开了录制掉了10帧,实机上能达到60FPS)
Recommended reading
Unity ECS(五)了解System执行顺序
EntherVarope
2019-11-21
view 2197
yes icon
Unity ECS(四)ECS组成概念
EntherVarope
2019-11-19
view 2133
yes icon
Unity ECS(三)HelloWorld!ECS!(2)
EntherVarope
2019-11-13
view 2839
yes icon
Unity ECS(一)了解ECS与DOTS
EntherVarope
2019-11-7
view 3336
yes icon
Unity ECS 前言
EntherVarope
2019-11-4
view 1950
yes icon
Unity ECS(二)HelloWorld!ECS!(1)
EntherVarope
2019-11-12
view 2595
yes icon
Open In App