完善HelloWorldECS程序
在前文中,我们编写了一个由1万个方块实体组成的方块阵列,并在NoiseHeightSystem下呈噪波运动。也提到了,程序仍然存在问题,本章将解决这些问题,使之成为一个纯粹的ECS程序。
-
利用Monobehaviour做数据输入源(CreateCubeEntity脚本中的字段 int row 与 int colum),并不符合ECS的理念,这是基于快速实现效果的妥协。
-
由于快速实现效果的妥协,使用了固定数据和System结合在一块了,应当将它们剥离出去。
-
它仍然是单线程运作的,并未利用到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)