ECS系统入门手记——其二

前言

还是因为期末考试导致ECS系统的学习推迟了很久,再加上ECS的内容比较抽象,这里只是简单讲讲进阶内容

原型和chunk

在笔记一中我们介绍了chunk,我们知道IJobEntity是它的语法糖,现在,让我们正式和它见一面吧。
简单来说,chunk就是连续内存块,每一个的大小一般是16kb。它的里面装满原型相同的实体,这也是为什么ECS的速度非常快的原因之一。
上面这段话很难理解,所以我们逐一为您解释

原型是什么

原型就是所有的组件相同,共享组件(SharedComponent)的值相同的实体,我们称其为一个原型,Unity把对应的原型分成多个chunk,便于查找。
了解了原型,我们还要知道它在代码中出现的情况。通常情况下,只要你给实体添加组件,就会有对应的原型,唯一用到的地方是你要创建大量的新实体,并且实体上要添加较多组件,请不要给每一个实体AddComponent一遍,而是创建出对应的原型,再统一创建

EntityArchetype _bulletArchetype;

public void OnCreate(ref SystemState state)
{
    _bulletArchetype = state.EntityManager.CreateArchetype(
        typeof(BulletComponent),
        typeof(LocalTransform),
        typeof(LocalToWorld),
    );
    EntityManager.CreateEntity(_bulletArchetype, count, Allocator.Temp);
}

改变原型

既然原型由实体挂载的组件决定,那它的原型会不会改变呢?答案是肯定的,这种情况,专业术语称为结构性变化。它会导致chunk中的分布发生变化,每一次结构性变化后Unity都会帮你把新原型放到对应的chunk中。这样会很耗性能,下面几种情况会导致结构性变化:

  1. 删除,增加实体

  2. 为实体删除,增加组件

  3. sharedComponent中的值发生变化

低频少量的改变对性能的影响极小,可以放心使用,如果是高频,大量的改变,可以考虑以下的方案

  1. 使用ecb(EntityCommandBuffer),请注意,这个是在系统的主线程中使用,它并不会让增删的性能变化,只是打上标记,在当前帧的帧末调用playback方法统一增删。只是提供安全性,并不能提供性能。playback是否手动调用取决于ecb是通过CommandBufferSystem的CreateCommandBuffer方法还是你new出来的。
  2. 使用ecb的AsParallelWriter()方法,它可以为你提供一支多线程安全的笔,让你在JobEntity或JobChunk中想用ecb相同的安全性
  3. 真正能够提供性能的,是IEnableComponent,它类似IComponentData,只是多了一个打开关闭的属性,类似Mono中的enable属性,我们可以在查找时选择性忽略某个组件被关闭的实体,而且,它的打开与关闭并不影响实体

SharedComponent组件

请不要感觉神秘,它也类似IComponentData,只不过主要用来充当不常改变的,原型相同的公共值,取值起来比一般组件更快更省内存,你可以把它当成普通组件,只要牢记一改变它的值就会发生结构性变化即可

ECS中的事件系统

回想c#中的事件系统,为我们带来了很多便利,然而,ECS世界中并没有这种便利,为了实现这样的效果,我们需要自己创建类似的机制

原理

使用类似监听与命令的方式,将事件作为一种组件,每当事件发生,就挂载到对应的实体上,再由监听系统统一处理逻辑。
我知道上面这段话很难理解,所以接下来为您举一个例子

例子

首先定义一个事件参数的组件

public struct HitEventData:IComponentData
{
	public int Damage;	//伤害事件
}

接下来的逻辑是满足特定情况时就给实体添加上面的事件,常见的比如碰撞
,这里为了演示方便,直接让有Player标签的一直受到Hit

public particla struct OnHitSystem:System
{
	public void OnUpdate(ref SystemState state)
	{
		var player=SystemAPI.GetSingletonEntity<Player>();
		AddComponent(player,new HitEventData()
		{
			Damage=100;
		});//这里本来是要用ecb,我是为了演示方便
	}
}

最后,只需要一个监听系统,看看谁身上有HitEventData组件,并执行相关逻辑就好了。可以是减少Health组件的值等等,这里就交由读者自行完成。
最后提一嘴,大量添加组件会导致结构性变化…………

ECS中的碰撞系统

首先,想要碰撞,请自行导入Unity.Physics包,之后可以为SubScene中的对象添加刚体和碰撞体等Mono组件,ECS在渲染时会自行为您生成对应的组件。

ICollisionEventJob与ITriggerEventJob

我们真正需要关心的是碰撞事件的产生,好在Unity为我们提供了两个接口,用来替代Mono中的OnTriggerEnter等事件。
首先,我们需要对应的Job事件,继承自标题的两种之一

[BurstCompile]
partial struct CollisionEventJob : ICollisionEventsJob
{
    public ComponentLookup<Health> HealthLookup;   //  Lookup类似Dictionary,以实体为键
    [ReadOnly]
     public ComponentLookup<Enemy> EnemyLookup;

    public void Execute(CollisionEvent collisionEvent)
    {
        Entity a = collisionEvent.EntityA;//发生碰撞的实体A
        Entity b = collisionEvent.EntityB;//发生碰撞的实体B

        // 根据组件判断谁是敌人,谁是子弹
        Entity enemy   = EnemyLookup.HasComponent(a) ? a : b;
        Entity bullet  = enemy == a ? b : a;

        if (EnemyLookup.HasComponent(enemy) && HealthLookup.HasComponent(enemy))
        {
            Health hp = HealthLookup[enemy];//获取敌人的生命组件
            hp.Value -= 10;          // 扣血
            HealthLookup[enemy] = hp;

            // 子弹回收( 最好用ecb,这里演示直接删)
            // EntityManager.DestroyEntity(bullet);
        }
    }
}

有了Job还不够,还需要一个系统

[UpdateInGroup(typeof(PhysicsSimulationGroup))]   // 必须在这个组里
[UpdateAfter(typeof(PhysicsSimulationGroup))]     // 等物理跑完再处理
public partial struct CollisionEventSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<SimulationSingleton>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var job = new CollisionEventJob
        {
            HealthLookup = SystemAPI.GetComponentLookup<Health>(false),
            EnemyLookup  = SystemAPI.GetComponentLookup<Enemy>(true)
        };
        state.Dependency = job.Schedule(SystemAPI.GetSingleton<SimulationSingleton>(),
                                        state.Dependency);
    }
}

[UpdateInGroup(typeof(PhysicsSimulationGroup))]这个我忘了有没有在第一节提过了,这里提一嘴,UpdateInGroup可以决定系统的运行时机,这里是在物理组中,也可以在初始化组和一般组,还有FixedUpdate组等等,UpdateAfter/Before可以决定系统的执行顺序,这里也包含你自己的系统,一搬用到的情况就是读数据在写数据之后。

ECS中的对象池系统

对象池的核心,在于为池中的对象添加Disabled组件(Unity已经内置),并在需要时直接找寻对应实体并移除Disabled组件,这里简单给一个例子

为对象池初始化的,可以设置预制体和初始大小

 public class BulletPoolAuthoring : MonoBehaviour
    {
        public GameObject prefab;
        public int size;
        private class Baker : Baker<BulletPoolAuthoring>
        {
            public override void Bake(BulletPoolAuthoring authoring)
            {
                Entity entity=GetEntity(TransformUsageFlags.None);
                AddComponent(entity,new BulletPool()
                {
                    Prefab = GetEntity(authoring.prefab,TransformUsageFlags.Dynamic),
                    PoolSize =  authoring.size,
                });
            }
        }
    }

初始化对象池系统,填充对象池,只执行一次

 [WorldSystemFilter(WorldSystemFilterFlags.Default)]
    partial struct InitBulletPoolSystem : ISystem
    {
        private EntityQuery _poolQuery;
        [BurstCompile]
        public void OnCreate(ref SystemState state)
        {
            _poolQuery = state.GetEntityQuery(ComponentType.ReadOnly<BulletPool>());
            state.RequireForUpdate<BulletPool>();
        }

        [BurstCompile]
        public void OnUpdate(ref SystemState state)
        {
            state.Enabled = false;
            var ecb = new EntityCommandBuffer(Allocator.Temp);
            var pools=_poolQuery.ToComponentDataArray<BulletPool>(Allocator.Temp);
            foreach (var pool in pools)
            {
                for (int i = 0; i < pool.PoolSize; i++)
                {
                    var bullet = ecb.Instantiate(pool.Prefab);
                    ecb.AddComponent<Disabled>(bullet);
                }
            }
            ecb.Playback(state.EntityManager);
            ecb.Dispose();

        }

后续想要子弹只要查找有BulletComponent和Disabled组件的实体,并删除Disabled组件即可,想丢弃子弹就加上Disabled就好了

配置表Blob

想要实现Unity中的SO,最简单又实用的方法就是用一个IComponentData作为配置表,当然,这里有一个更麻烦的方法,但是既然它有,就必然有其存在价值,这里简单略过
首先搞一个SO资产

 [CreateAssetMenu(fileName = "PlayerConfig", menuName = "SO/Config/PlayerConfig")]
    public class PlayerConfig : ScriptableObject
    {
        [Header("基础属性")]
        public float baseHealth;
        public float baseMaxHealth;
        public float baseSpeed;
        public int baseLevel;
        public int baseNeedExperience;
        [Header("视角输入")]
        public float viewSensitivityX = 200f;
        public float viewSensitivityY = 200f;
        [Header("开火设置")]
        public float fireRate;
        public float bulletSpeed;
        public float bulletDamage;
    }

再用一个结构体接受SO资产

 public struct PlayerConfigBlob
    {
        public float BaseHealth;
        public float BaseMaxHealth;
        public float BaseSpeed;
        public int BaseLevel;
        public int BaseNeedExperience;
        public float ViewSensitivityX ;
        public float ViewSensitivityY;
        public float FireRate;
        public float BulletSpeed;
        public float BulletDamage;
    }

还有一个组件数据来接收

 public struct PlayerConfigComponent : IComponentData
    {
        public BlobAssetReference<PlayerConfigBlob> ConfigBlob;//一个引用
    }

再来一个辅助方法实现从SO到Blob的转化

 public static BlobAssetReference<PlayerConfigBlob> ConvertPlayerConfigBlob(PlayerConfig so)
        {
            if (so == null)
            {
                Debug.LogError("没有玩家配置表");
                return default;
            }

            using var builder = new BlobBuilder(Allocator.Temp);
            ref var blobData = ref builder.ConstructRoot<PlayerConfigBlob>(); //要求获得一片内存区域的引用

            blobData.BaseHealth = so.baseHealth;
            blobData.BaseLevel = so.baseLevel;
            blobData.BaseMaxHealth = so.baseMaxHealth;
            blobData.BaseNeedExperience = so.baseNeedExperience;
            blobData.BaseSpeed = so.baseSpeed;
            blobData.BulletDamage = so.bulletDamage;
            blobData.BulletSpeed = so.bulletSpeed;
            blobData.FireRate = so.fireRate;
            blobData.ViewSensitivityX = so.viewSensitivityX;
            blobData.ViewSensitivityY= so.viewSensitivityY;
            //冻结成只读资产并返回一个只读引用
            var blobRef = builder.CreateBlobAssetReference<PlayerConfigBlob>(Allocator.Persistent);

            return blobRef;
        }

最后烘培即可

 [SerializeField]
        private PlayerConfig config;
        public PlayerConfig Config => config;

        private class Baker : Baker<PlayerAuthoring>
        {
            public override void Bake(PlayerAuthoring authoring)
            {
                if (authoring.Config == null)
                {
                    Debug.LogError($"{nameof(authoring.Config)} is null");
                    return;
                }
                Entity entity=GetEntity(TransformUsageFlags.Dynamic);
                var blob=ConfigBlobConverter.ConvertPlayerConfigBlob(authoring.Config);
                if (!blob.IsCreated)
                {
                    Debug.LogError($"{nameof(authoring.Config)} is not created");
                    return;
                }
                //用引用计数,防止假死
                AddBlobAsset(ref blob, out var hash);
                //清除警告
                _ = hash;
                //这个组件留着当备胎,以后需要了才用
                AddComponent(entity,new PlayerConfigComponent()
                {
                    ConfigBlob=blob,
                });
                }
 }

绕了这么大一圈,其实真不如一个IComponentData来的快吧

结语

ECS后面应该就是学一些场景方面的内容了,看看寒假之前会不会有第三节吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值