美文网首页
[Unity][翻译/翻新]从一个打飞机游戏入门ECS

[Unity][翻译/翻新]从一个打飞机游戏入门ECS

作者: pamisu | 来源:发表于2022-05-10 18:10 被阅读0次

    目前(2022年5月)ECS相关包已经来到了0.50+版本,很多旧教程变得不适用,而这篇坦克打飞机的教程比较有意思,所以我尝试将它升级到最新版,顺便记录一些学习过程中踩到的坑,如有错误欢迎指出。
    文中的✨部分是个人关于翻新的一些注释,并简单补充了以下方面的内容:

    • Schedule、ScheduleParallel和Run的区别及使用场景。
    • EntityCommandBuffer及其使用。
    • 使用EntityQuery解决ForEach嵌套问题、它在Schedule、ScheduleParallel下的写法。

    正片

    原文:https://www.raywenderlich.com/7630142-entity-component-system-for-unity-getting-started
    作者:Wilmer Lin

    本篇教学中,你将使用ECS改造一个射爆游戏,你将从中学到:

    • 如何创建Entity(实体)。
    • 如何使用混合ECS轻松实现这一新的范式转变。
    • 如何正确使用Component(组件)高效存储数据。
    • 如何使用System(系统)控制数据的行为与逻辑。
    • 综合使用以上部分来达到ECS的完全体。

    开始

    点击网页上的Download Materials按钮下载项目文件,解压并打开IntroToECSStarter项目。
    在Window ► PackageManager,安装下列包:

    ECS核心包:

    • Entities 包含ECS功能。
    • Hybrid Renderer 渲染ECS创建的实体。

    其他包:

    • Cinemachine 相机控制
    • Universal Render Pipeline
    • TextMeshPro 文字显示

    Demo的资产放在Assets/RW下:

    Demo压力测试

    先来看看传统方式的实现效果,打开Scenes文件夹下的SwarmDemoNonECS ,勾选Maximize on Play并运行。

    WASD移动,鼠标移动瞄准,鼠标左键发射子弹。

    在Stats面板中查看CPU时间和FPS:

    随着游玩时间的增加,每帧渲染时间逐渐增加,FPS开始下降,过了一分钟左右,游戏变得十分卡顿。
    你需要ECS来拯救它!

    ECS: 为性能而生

    在传统的Unity开发中,GameObject和MonoBehaviour允许我们将数据与行为混在一起,例如float和string可以在Start与Update等方法中共存。在内存中,单个对象里的各种数据类型像是一盘大杂烩:

    举个例子,你的GameObject可能引用了多种数据类型,例如Transform、Renderer和Collider,Unity将各种数据分散在非连续内存中,当GameObject变多时,它们流向更缓慢的内存中(指图上的slower方向)。
    相比之下,ECS尝试将相似的数据分组,并放到一个个组块(chunk)中,它以更少的间隙分配内存,从而更紧密地打包数据。这样做可以尽可能多地保留非常快的CPU内存缓存层(L1、L2、L3)。

    DOTS(多线程式数据导向型技术堆栈)用面向数据编程替代了面向对象设计,这种架构专注于如何让数据保持紧凑,不幸的是,这意味着我们需要替换掉早已习惯的MonoBehaviour。
    取而代之的则是Entity(实体)、 Component(组件)与 System(系统)。

    Entity填充了整个程序,但它们并不是传统意义上的对象。一个Entity是一个指向其他数据位的整型ID。
    Component是实际的数据容器,它们是持有数据的结构体,不带任何逻辑,毫无疑问我们会用到大量的Component。ECS以一种巧妙的方式围绕着这些Component的存储而运作。
    System负责行为和逻辑,我们用它们来操作、变换数据。System一次操作一整个Entity数组,所以它们很有效率。
    这三部分便组成了ECS
    遵循这种体系结构模式,数据将倾向于聚集到非常快的缓存中,与面向对象的等效实现相比,结果便是显著的加速。

    避免将ECSComponent与传统的Component混淆,它们只是名称相同而已。

    ✨升级版本

    使用Unity 2020.3.30或更高版本打开IntroToECSStarter项目(2021版本可能会有些问题),在PacakgeManager中将Entities、Hybrid Renderer升级到0.50:

    如果升级完报Burst相关错误,可尝试重启编辑器,或者升级Burst版本后重启。
    如果报其他错误,可尝试在Project Settings ► Package Manager ► Advanced Settings 中勾选Show Dependences,然后回到Package Manager中检查依赖包的版本是否正确。

    移除非ECS代码

    打开Scenes下的SwarmDemoECS场景,这里面去除了生成飞机的代码。
    暂时禁用DemoManagersPlayerTank

    现在运行,可以看到只剩下地板,这很适合用来生成一些Entity!

    创建Entity

    现在来生成敌机,在EnemySpawner.cs中,引入相关命名空间:

    using Unity.Entities;
    

    添加以下字段:

    private EntityManager entityManager;
    

    EntityManager类用于处理Entity与它们的数据。
    然后编写Start方法:

    private void Start()
    {
        // 1
        entityManager = World.DefaultGameObjectInjectionWorld.EntityManager; 
        // 2
        Entity entity = entityManager.CreateEntity();
    }
    
    1. 所有的Entity、Component、System都存在于World中,每个World拥有一个EntityManager,这一行获取对其的引用。
    2. 通过entityManager.CreateEntity()生成我们的第一个Entity。

    保存脚本并在编辑器中进入运行模式。
    在Hierarchy面板中,我们可以看到...无事发生!真的是这样吗?
    由于Entity并不是GameObject,所以它们并不会出现在Hierarchy中。
    要查看所有的Entity,我们需要用到Entity Debugger面板,你可以在Window ► Analysis ► Entity Debugger中找到它。

    在运行模式中,Entity Debugger窗口左侧面板显示运行在Default World的各种System。

    选中All Entities (Default World),你可以看到有两个Entity出现在中间的面板,选中它们可以在Inspector中查看详情。

    ✨Entity Debugger已被弃用,在未来将被移除,使用Window ► DOTS下的Archetypes、Hierarchy、Components、Systems来对不同部分进行查看调试:

    在DOTS Hierarchy中选中默认生成的WorldTime,在Inspector面板中可以看到游戏时钟。

    选中一个Entity,可以查看详情(当然现在运行的话Entity下什么都没有)。

    添加Component

    一种方式是使用代码给Entity添加Component。
    Entity与MonoBehaviour不同,因此它们需要专属的库,像是变换、数学运算、渲染等等。
    回到EnemySpawner.csusing Unity.Mathematics;已经有了,再引入两个命名空间:

    using Unity.Transforms;
    using Unity.Rendering;
    

    然后添加敌机的网格与材质字段:

    [SerializeField] private Mesh enemyMesh;
    [SerializeField] private Material enemyMaterial;
    

    修改Start方法:

     private void Start()
     {
        entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        // 1
        var archetype = entityManager.CreateArchetype(
            typeof(Translation),
            typeof(Rotation),
            typeof(RenderMesh),
            typeof(RenderBounds),
            typeof(LocalToWorld)
        );
        // 2
        var entity = entityManager.CreateEntity(archetype);
        // 3
        entityManager.AddComponentData(entity, new Translation { Value = new float3(-3f, 0.5f, 5f) });
        // ❗
        entityManager.AddComponentData(entity, new Rotation { Value = quaternion.EulerXYZ(new float3(0f, 45f, 0f)) });
        
        entityManager.AddSharedComponentData(entity, new RenderMesh 
        {
            mesh = enemyMesh,
            material = enemyMaterial,
            layerMask = 1 // ✨layerMask为0不显示
        });
        // ✨设置RenderBounds
        entityManager.AddComponentData(entity, new RenderBounds { Value = enemyMesh.bounds.ToAABB() });
     }
    

    这段代码做了什么:

    1. 定义了一个EntityArchetype,它将指定的数据类型关联在一起。在这里Translation, Rotation, RenderMesh, RenderBounds,LocalToWorld组成了Archetype。
    2. 将这个Archetype传入 entityManager.CreateEntity,实例化Entity。
    3. 然后使用 AddComponentDataAddSharedComponent 来添加数据并指定值。这里设置了敌机的位置、旋转,以及网格与材质。

    ECS中各概念定义

    ❗一般情况下用欧拉角构造四元数应使用quaternion.EulerZXYquaternion.Euler,传入弧度而不是角度。 Method EulerZXY

    选中Hierarchy中的EnemySpawner,给网格与材质赋值:

    进入运行模式,可以看到一只敌机出现在视野里。

    Entity Debugger中选中它,查看它的数据。

    Entity Debugger右侧显示的是对应的Archetype。Unity将相同的Archetype分组放到Chunk中,提升读写效率。

    ConvertToEntity

    当然像上面那样代码未免太多了,在代码中徒手创建Entity是纯ECS的做法。尽管这很有效,但每次都要这样做实在是太累了。
    Unity使用混合ECS的方式简化了这一过程,首先你在GameObject中定义一些数据,在运行时一个具有相同数据的Entity将会替代这个GameObject。
    首先把上面写的大部分逻辑都注释掉,只留下这一行:

    private void Start()
    {
        entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
    }
    

    打开RW/Prefabs,打开EnemyDrone预制体,添加一个Convert to Entity组件:

    Conversion Mode中选择Convert And Destroy,这将在运行时移除原有的GameObject,替换成Entity。
    保存预制体。然后将EnemyDroneRW/Prefabs文件夹中拖入Hierarchy,设置你喜欢的位置与旋转角度。
    进入运行模式,敌机的GameObject将在Hierarchy中消失,但敌机依然可见,在Entity Debugger中,你可以看到名为EnemyDrone的Entity,各项参数与GameObject对应。

    退出运行模式后,GameObject会重新出现在Hierarchy中。
    在混合ECS中,GameObject类似一个占位符,用于设置基础变换与渲染数据。在运行时,Unity将其转换为Entity。

    未来ConvertToEntityGameObjectConversionUtility 会被移除,官方推荐使用新的转换工作流

    MoveForward Component Data

    接下来我们需要让敌机向前飞行,如果敌机不能飞,作为一只坦克就没有乐趣了。我们可以创建一些数据并用一个Component来表示敌机的前进速度。
    首先在Scripts/ECS/ComponentData下创建一个新的C#脚本MoveForwardComponent.cs

    using Unity.Entities;
    
    public struct MoveForward : IComponentData
    {
        public float speed;
    }
    

    继承IComponentData接口而不是MonobehaviourIComponentData接口用于实现通用的Component,但需要注意任何实现必须是结构体
    这里只需要一个简单的public变量,Unity建议将几乎总是同时访问的数据字段放在一起,使用大量小的分离的Component会更有效率,而不是一些大而臃肿的Component。
    结构体的名称MoveForward不需要与文件名相同,ECS脚本比MonoBehaviour更灵活,在一个文件中可以存放多个结构体或类。

    Authoring

    ConvertToEntityEnemyDrone的变换与渲染信息转换为了等价的Entity,但它无法自动转换自定义的MoveForward,为此我们需要添加一个Authoring Component。
    首先打开EnemyDrone预制体,尝试将MoveForwardComponent拖拽到预制体中,Unity会提示一条错误消息。

    现在给MoveForward结构体添加一个[GenerateAuthoringComponent] 特性:

    [GenerateAuthoringComponent]
    

    再次拖拽,这时Unity会添加一个MoveForwardAuthoring组件,MoveForward中的任何public字段将会显示在Inspector中。
    speed设置为5并保存预制体。

    现在进入运行模式,Entity Debugger中可以看到Authoring Component给Component的数据设置了默认值,敌机Entity将拥有一个MoveForward数据类型,其中speed值为5

    Movement System

    现在Entity有Component数据了,但数据本身做不了什么,我们需要再创建一个System。
    Scripts/ECS/Systems文件夹下创建一个新C#脚本MovementSystem.cs

    using Unity.Entities;  
    using Unity.Mathematics;  
    using Unity.Transforms;  
      
    // 1 ✨ComponentSystem → SystemBase  
    public partial class MovementSystem : SystemBase 
    {  
        // 2
        protected override void OnUpdate()  
        {  
            var dt = Time.DeltaTime;
            // 3
            Entities.WithAll<MoveForward>().ForEach((ref Translation trans, in Rotation rot, in MoveForward moveForward) =>  
            {  
                // 4
                trans.Value += moveForward.speed * math.forward(rot.Value) * dt;  
            }).ScheduleParallel();  // 5 ✨
        }  
    }
    

    这表示了ECS中的一个基础System:

    1. MovementSystem 继承 SystemBase,一个实现System需要继承的抽象类,实现类需要标记为partial
    2. 实现 protected override void OnUpdate(),它将每帧被调用。
    3. 使用 Entities 的静态方法ForEach 对World中的每个Entity执行逻辑。
      WithAll是一个过滤器,它限制仅对含有MoveForward数据的Entity进行遍历,虽然现在只有一个Entity,但之后这会有意义。
      ForEach 的参数是一个Lambda表达式,对于需要读写的参数,使用ref关键字修饰,对于只读的参数,使用in。例如这里我们用到了Translation(位置)、Rotation(旋转)和MoveForward。
    4. Lambda表达式内,为每个Entity计算每帧的速度,然后与前进方向相乘,并应用到Entity的Translation上。
    5. ✨使用Schedule()ScheduleParallel()Run()来执行。
      保存这个脚本,System便可用了,不需要将其添加到Hierarchy里的什么东西上,不管你愿不愿意,它都会运行!
      现在进入运行模式,敌机沿直线飞行了!

    ✨Schedule、ScheduleParallel和Run

    Run() 在主线程上立即执行。
    Schedule() 生成一个Job并在任意非主线程上执行。
    ScheduleParallel() 以Chunk为基础分割为多个Job,并在多个线程上并行执行。
    当ForEach的Lambda中需要用到一些Job不支持的变量,例如非blittable类型、非值类型this、引用类型等,此时只能在主线程中执行,必须使用Run()。
    尝试修改上面Lambda中代码,不使用dt变量而是改为this.Time.DeltaTime

    trans.Value += moveForward.speed * math.forward(rot.Value) * this.Time.DeltaTime;
    

    编辑器将会报错并提示使用.WithoutBurst().Run()

    当处理大量同种结构数据时,ScheduleParallel很有效率,数据较少或其他情况则使用Schedule。

    ECS: Run vs. Schedule vs. ScheduleParallel
    Unity中文课堂 DOTS课程系列:C# Job System精要(需要登录)

    创建Entity预制体

    到目前为止,我们创建了一个单独的敌机Entity,但这远远不够,我们可以将其创建为一个可重用的预制体,以在运行时创建大量的敌机。
    首先在EnemySpawner.cs中添加如下字段:

     [SerializeField] private GameObject enemyPrefab;
     
     private Entity enemyEntityPrefab;
    

    然后在Start方法底部添加如下代码:

    var settings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, null);  
    enemyEntityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(enemyPrefab, settings);
    

    这两行真长!虽然书写上有些冗长,但这里只是做了一些默认的转换设置,将敌机预制体转换为了Entity预制体。
    来试试,删除Hierarchy中的EnemyDrone(记得应用修改到预制体),选中 EnemySpawner,在Inspector中,将EnemyDrone预制体拖入到EnemyPrefab
    如果现在运行的话无事发生,我们需要在代码中实例化Entity预制体:

    entityManager.Instantiate(enemyEntityPrefab);
    

    再次运行,敌机出现了:

    现在可以创建大量敌机了,注释掉刚才那一行,改为调用SpawnWave

    // entityManager.Instantiate(enemyEntityPrefab);
    SpawnWave();
    

    接下来写SpawnWave逻辑。

    使用NativeArray生成大波敌机

    EnemySpawner.cs顶部引入命名空间:

    using Unity.Collections;
    

    这让我们可以使用一种叫做NativeArray的特殊集合类型,NativeArray可以以更少的内存开销遍历Entity。
    然后编写SpawnWave,在一个圆中随机生成敌机:

    private void SpawnWave()  
    {  
        // 1
        var enemyArray = new NativeArray<Entity>(spawnCount, Allocator.Temp);  
        // 2
        for (int i = 0; i < enemyArray.Length; i++)  
        {  
            enemyArray[i] = entityManager.Instantiate(enemyEntityPrefab);  
            // 3
            entityManager.SetComponentData(enemyArray[i], new Translation { Value = RandomPointOnCircle(spawnRadius) });  
            // 4
            entityManager.SetComponentData(enemyArray[i], new MoveForward { speed = Random.Range(minSpeed, maxSpeed) });  
        }
        // 5  
        enemyArray.Dispose();  
        // 6
        spawnCount += difficultyBonus;  
    }
    

    SpawnWave里都做了些什么:

    1. 定义了一个名为enemyArrayNativeArray,大小为spawnCountAllocator.Temp表示NativeArray是临时的。
    2. 循环一遍,每次实例化一个敌机Entity,将其保存在enemyArray中。
    3. 使用 RandomPointOnCircle生成随机位置,使用SetComponentData将其设置到Entity的Translation中。
    4. 依葫芦画瓢设置MoveForward的速度。
    5. 循环完成后,使用 NativeArray.Dispose 释放临时分配的内存。
    6. 最后,增加spawnCount让游戏逐渐变难(SpawnWave在Update中也有调用),让你的玩家保持紧张感!

    激活玩家

    在Hierarchy中再次启用PlayerTankDemoManagers
    在运行模式中,玩家可以移动,但子弹静止在了原地。
    Bullet预制体上已经有ConvertToEntity组件了,Unity会在运行时将其转换为Entity,现在只需要让它们移动。
    打开Bullet预制体,添加MoveForward组件,设置speed50并保存。
    现在子弹可以正常飞行了,虽然它们会直接穿过敌机:

    FacePlayer System

    现在敌机完全无视了我们,该让它们直面玩家了。
    Scripts/ECS/Systems下创建一个名为FacePlayerSystem.cs的System:

    // ✨ComponentSystem → SystemBase  
    public partial class FacePlayerSystem : SystemBase  
    {  
        // 1
        protected override void OnUpdate()  
        {  
            // 2
            if (GameManager.IsGameOver())  
                return;  
            // 3
            var playerPos = (float3)GameManager.GetPlayerPosition();  
            // 4
            Entities.ForEach((Entity entity, ref Translation trans, ref Rotation rot) =>  
                {  
                    // 5
                    var direction = playerPos - trans.Value;  
                    direction.y = 0f;  
                    // 6
                    rot.Value = quaternion.LookRotation(direction, math.up());  
                }).ScheduleParallel();  // ✨
        }  
    }
    
    1. OnUpdate 每帧执行。
    2. 游戏结束时返回。
    3. 使用GameManager.GetPlayerPosition获取玩家位置,并将其转换为float3
    4. Entities.ForEach 遍历所有Entity,Lambda参数使用了Entity自身与它的Translation、Rotation。
    5. 计算指向玩家的向量,忽略y值。
    6. 使用 quaternion.LookRotation 计算朝向。

    很好!现在敌机会面向玩家了,虽然它们还不会爆炸,但它们会不断朝玩家飞来。
    不幸的是,玩家武器出现了问题,我们发射的子弹也立即飞向自己,这不是期望的效果。

    创建ComponentTag

    玩家子弹与敌机使用了相同的MoveForward,Unity不会在它们之间做出区分。
    我们可以使用Component来给Entity打上标签,以此进行区分。
    首先,在Scripts/ECS/ComponentTags下创建一个EnemyTag.cs

    using Unity.Entities;
    
    [GenerateAuthoringComponent]
    public struct EnemyTag : IComponentData
    {
    }
    

    然后再创建一个BulletTag.cs

    using Unity.Entities;
    
    [GenerateAuthoringComponent]
    public struct BulletTag : IComponentData
    {
    }
    

    这样就可以了,里面什么都不用写。
    EnemyTag添加到EnemyDrone预制体,BulletTag添加到Bullet预制体。
    回到FacePlayerSystem.cs,在ForEach前添加一个WithAll 查询,传入EnemyTag

    Entities.WithAll<EnemyTag>().ForEach((Entity entity, ref Translation trans, ref Rotation rot) =>
    ...
    

    这种链式结构让我们仅遍历包含EnemyTag的Entity,还可以使用 WithNoneWithAny等约束。

    现在子弹正常了,FacePlayerSystem不会影响到它们。

    Destruction System

    敌机在接触子弹后需要爆炸并销毁,类似的,玩家坦克被敌机撞击到时也应该爆炸。在这个Demo中,我们使用一个简单的距离检测。
    ✨在Scripts/ECS/Systems中创建DestructionSystem.cs

    using Unity.Collections;  
    using Unity.Entities;  
    using Unity.Mathematics;  
    using Unity.Transforms;  
      
    // ComponentSystem → SystemBase  
    public partial class DestructionSystem : SystemBase  
    {  
        private float thresholdDistance = 2f;  
        // 1
        private EndSimulationEntityCommandBufferSystem ecbSystem;
        // 2
        private EntityQuery bulletQuery;  
    
        // 3
        protected override void OnCreate()  
        {  
            ecbSystem = World.GetExistingSystem<EndSimulationEntityCommandBufferSystem>();  
            bulletQuery = GetEntityQuery(ComponentType.ReadOnly<BulletTag>(), ComponentType.ReadOnly<Translation>());  
        }  
      
        protected override void OnUpdate()  
        {  
            if (GameManager.IsGameOver())  
                return;  
            // 4
            var ecb = ecbSystem.CreateCommandBuffer();  
      
            var playerPosition = (float3)GameManager.GetPlayerPosition();  
            
            // 5
            var bulletEntities = bulletQuery.ToEntityArray(Allocator.Temp);  
            var bulletPositions = bulletQuery.ToComponentDataArray<Translation>(Allocator.Temp);  
            Entities
                .WithDisposeOnCompletion(bulletEntities)  // 6
                .WithDisposeOnCompletion(bulletPositions)  
                .WithAll<EnemyTag>()  
                .ForEach((Entity enemy, int entityInQueryIndex, ref Translation enemyPos) =>  
                {  
                    playerPosition.y = enemyPos.Value.y;  
                    // 7
                    if (math.distance(enemyPos.Value, playerPosition) <= thresholdDistance)  
                    {  
                        FXManager.Instance.CreateExplosion(enemyPos.Value);  
                        FXManager.Instance.CreateExplosion(playerPosition);  
                        GameManager.EndGame();  
                        // 8
                        ecb.DestroyEntity(enemy);  
                    }  
      
                    var enemyPosition = enemyPos.Value;  
                    // 9
                    for (int i = 0; i < bulletPositions.Length; i++)  
                    {  
                        if (math.distance(enemyPosition, bulletPositions[i].Value) <= thresholdDistance)  
                        {  
                            ecb.DestroyEntity(enemy);  
                            ecb.DestroyEntity(bulletEntities[i]);  
          
                            FXManager.Instance.CreateExplosion(enemyPosition);  
                            GameManager.AddScore(1);  
                        }  
                    }  
                })  
                .WithoutBurst()  
                .Run();  
            // 10
            ecbSystem.AddJobHandleForProducer(Dependency);  
        }  
    }
    

    ✨这部分变化较大,所以直接重写了。整体逻辑很简单,遍历所有敌机,判断它与玩家的距离,若小于阈值则爆破敌机与玩家坦克,游戏结束;对于每一个敌机,遍历所有子弹,判断它与子弹的距离,若小于阈值则爆破敌机、销毁子弹、增加得分。
    由于Lambda中使用了引用类型,这里不得不用.WithoutBurst().Run()让其在主线程中立即执行,但还是按照兼容Job的方式来写,所以会复杂一些,但日后修改为Job兼容时会更方便。

    1. 创建一个EndSimulationEntityCommandBufferSystem,用于销毁Entity,也可以使用EntityManager.DestroyEntity方法。
    2. 创建一个EntityQuery用于查询所有的子弹,目前ForEach不支持嵌套,所以需要提前将所有子弹Entity查询出来。
    3. 初始化ecbSystembulletQuery
    4. 创建ecbSystem对应的CommandBuffer实例。
    5. 获取子弹的查询结果,这里获取到了所有子弹实体与它们的位置,分别放在两个NativeArray中。
    6. 使用WithDisposeOnCompletion让NativeArray在执行完毕后销毁。
    7. 判断敌机与玩家坦克的距离,小于阈值则创建爆炸特效,结束游戏。
    8. 销毁敌机,也可以用EntityManager.DestroyEntity(enemy)
    9. 遍历所有子弹位置,判断其是否与当前敌机接触。
    10. 使用Run()执行时,这一行可以省略。

    进入运行模式,开始测试:

    现在可以社保敌人了,不小心撞到敌机时游戏就会结束。

    ✨EntityCommandBuffer是干嘛的

    上面用到了一个名字老长老长的类,EndSimulationEntityCommandBufferSystem,并且它可以换成EntityManager,那么这二者的区别是什么,直接用EntityManager不是少些代码吗?

    有些操作,像是创建Entity、销毁Entity、添加Component、移除Component、修改Shared Component的值,它们会导致Archetype的改变、Entity在Chunk中的顺序变化,这些操作称为“结构变化(Structural change)”。显然它们不是线程安全的,所以只能在主线程中执行。

    为了执行这些操作,Unity提出了同步点(Sync Point)的概念,即等待当前所有Job完成,再执行Job中无法执行的操作,就像电视剧中间穿插的广告一样,同步点自然是越少越好。

    当调用EntityManager.DestroyEntity或类似方法时,就会创建一个同步点,如果需要销毁大量Entity,则会创建大量同步点,拖慢游戏的运行。

    EntityCommandBuffer先将结构变化操作放入队列,当程序执行到特定同步点时,再统一执行这些操作,有效地减少了同步点数量。这里的EndSimulationEntityCommandBufferSystem便可以创建其中一种EntityCommandBuffer,它们的类型与执行顺序与System Group有关,在Entity Debugger中可以查看System Group详情:

    即使在主线程中,EntityCommandBuffer也比EntityManager效率更高,所以应该尽可能地使用EntityCommandBuffer。

    Sync Points
    Entity Command Buffers
    System Update Order

    ✨使用ScheduleParallel的写法

    上面的代码中,ForEach中的引用类型(FXManager、GameManager)阻止了我们使用Schedule()ScheduleParallel()等方法,假设我们已经将这两个东西搞定了(注释了),这里要如何改写?

    protected override void OnUpdate()  
    {  
        if (GameManager.IsGameOver())  
            return;  
        // 1
        var ecb = ecbSystem.CreateCommandBuffer().AsParallelWriter();  
        var playerPosition = (float3)GameManager.GetPlayerPosition();  
        // 2
        var bulletEntities = bulletQuery.ToEntityArrayAsync(Allocator.TempJob, out JobHandle handle);  
        Dependency = JobHandle.CombineDependencies(Dependency, handle);  
        var bulletPositions = bulletQuery.ToComponentDataArrayAsync<Translation>(Allocator.TempJob, out handle);  
        Dependency = JobHandle.CombineDependencies(Dependency, handle);  
      
        var thresholdDistanceCopy = thresholdDistance;  
        Dependency = Entities  
            .WithReadOnly(bulletEntities)  // 3
            .WithReadOnly(bulletPositions)  
            .WithDisposeOnCompletion(bulletEntities)  
            .WithDisposeOnCompletion(bulletPositions)  
            .WithAll<EnemyTag>()  
            .ForEach((Entity enemy, int entityInQueryIndex, ref Translation enemyPos) =>  
            {  
                playerPosition.y = enemyPos.Value.y;  
                if (math.distance(enemyPos.Value, playerPosition) <= thresholdDistanceCopy)  
                {  
                    // FXManager.Instance.CreateExplosion(enemyPos.Value);  
                    // FXManager.Instance.CreateExplosion(playerPosition);                
                    // GameManager.EndGame();  
                    // 4
                    ecb.DestroyEntity(entityInQueryIndex, enemy);  
                    // EntityManager.DestroyEntity(enemy);  
                }  
      
                var enemyPosition = enemyPos.Value;  
                for (int i = 0; i < bulletPositions.Length; i++)  
                {  
                    if (math.distance(enemyPosition, bulletPositions[i].Value) <= thresholdDistanceCopy)  
                    {  
                        ecb.DestroyEntity(entityInQueryIndex, enemy);  
                        ecb.DestroyEntity(i, bulletEntities[i]);  
                        // FXManager.Instance.CreateExplosion(enemyPosition);  
                        // GameManager.AddScore(1);                
                    }  
                }  
            })  
            .ScheduleParallel(Dependency);  // 5
        ecbSystem.AddJobHandleForProducer(Dependency);  
    }
    
    1. 使用.AsParallelWriter()让EntityCommandBuffer支持并行。
    2. 将子弹的查询操作改为异步执行,并且需要先查询到结果,再执行ForEach中的Lambda,所以使用JobHandle.CombineDependencies将查询操作的JobHandle合并到当前System的Dependency中。
    3. 以只读方式使用子弹Entity与位置的NativeArray。
    4. 销毁Entity,此时需要额外传入Entity在查询结果中的下标,即entityInQueryIndex
    5. 使用.ScheduleParallel(Dependency)并将返回值赋给Dependency,这样ForEach中的Lambda将在查询到子弹Entity后执行。

    Job dependencies

    更多System与整理

    现在Demo几乎要完成了,最后一步是将冲出屏幕外的子弹移除,不然它们会吃掉你的内存。
    导入IntroToECSExtras.unitypackage,添加一些脚本:

    • Lifetime Component,定义子弹的持续时间。
    • TimeoutSystem,用于将持续时间到期的子弹移除。
    • ClearOnRestartSystem,用于在游戏重开前销毁剩余的敌机。

    阅读这些脚本,看看它们是如何工作的,或者自己编写,你现在肯定是个专家了!
    编辑Bullet预制体,为其添加Lifetime组件并设值为3。
    现在就算有成百上千的Entity存在,游戏依然会以一个非常平稳的帧数运行。

    相关文章

      网友评论

          本文标题:[Unity][翻译/翻新]从一个打飞机游戏入门ECS

          本文链接:https://www.haomeiwen.com/subject/syolurtx.html