Unity ECS(三)HelloWorld!ECS!(2)

完善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)

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值