Unity之ECS架构初识和实战应用

发布于:2025-05-01 ⋅ 阅读:(44) ⋅ 点赞:(0)

1.什么是ECS框架

引用自就一枚小白

ECS,即 Entity-Component-System(实体-组件-系统) 的缩写,其模式遵循组合优于继承原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。

实体与组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。

2.实战

引用B站僵尸吃脑子的例子。
在开始前先接受一个设定:unity面向对象的Scene叫做AuthoringScene。
ECS面向数据的Scene叫做SubScene。

1.自定义组件的创建。

在Entity1.0.0往下的旧版本声明方式如下:
在这里插入图片描述
显然,这样放置到Inspector的方法不再可用了。

2.从Authoring世界到ECS世界的转换者–bake

具体触发条件
场景加载:当场景被加载时,Unity 会检查场景中的游戏对象是否有需要烘焙的内容。如果有,就会调用相应的 Bake 方法。
数据变更:当 MonoBehaviour 脚本中的数据发生变化时,Unity 会标记相关的游戏对象需要重新烘焙。在下一次烘焙操作时,对应的 Bake 方法会被调用。

因为1.0.0后引入了一个Bake的概念,和灯光,Nev Mesh烘焙一样,都是一种过程操作。而ECS中的Bake指的就是把一个普通世界的gameObject信息转化成为ECS世界(subScene)中的组件信息。
因此我们创建一个更贴合Unity面向对象的代码格式。

在这里插入图片描述
但是我们查看runtime的Inspect发现并没有出现对应的Component组件。所以我们需要使用Entity1.0.0引入的Bake系统了。

在这里插入图片描述
用Authring类型烘焙成一个组件类型。是一个标准化的过程。

在这里插入图片描述
这里有人会问了,我前面不应该有一个Entity参数吗?是这样的,Bake会隐式的创建一个Entity即使你不显式的传入它。不过你也可以用下列方法显示的把挂载authoring脚本的物体转化成一个Entity。

 // 显式获取与当前 GraveyardMono 对应的实体
            Entity graveyardEntity = GetEntity(TransformUsageFlags.Dynamic);

            // 向该实体添加 GraveyardGraveyardProperties 组件
            AddComponent(graveyardEntity, new GraveyardGraveyardProperties
            {
                FieldDimensions = authoring.FieldDimensions,
                NumberTombstonesToSpawn = authoring.NumberTombstonesToSpawn,
                TombstonePrefab = GetEntity(authoring.TombstonePrefab)
            });

            // 向该实体添加 GraveyardRandom 组件
            AddComponent(graveyardEntity, new GraveyardRandom
            {
                value = Unity.Mathematics.Random.CreateFromIndex(authoring.randomSeed)
            });
        }
    }

同理新建一个GraveyardRandom组件用于生成随机数量的墓碑。
随机数组件:

using Unity.Mathematics;
using Unity.Entities;

namespace TMG.Zombies
{
    public struct GraveyardRandom : IComponentData
    {
        public Random value;
    }
}

同样的办法在bake中Add这个组件。
Unity.Mathematics.Random.CreateFromIndex 是 Unity 的数学库 Unity.Mathematics 中 Random 结构体的一个方法 ,为每个线程或循环索引提供独立且可预测的随机数生成器。每个线程传入不同的索引值,能让各线程基于不同随机种子生成随机数序列,避免随机数冲突,保证并行计算结果的正确性和可重复性。

AddComponent(new GraveyardRandom
{
    value = Unity.Mathematics.Random.CreateFromIndex(authoring.randomSeed)
});

—4月21号修改

3.引用一个实体的多个组件—Aspect

1.什么是Aspect?

可将其理解为是对单个实体(Entity)中部分组件(Component)的引用 “包装盒”包装盒可以嵌套使用 。它把实体中的一些组件引用整合在一起,本质是一个需继承自 IAspect 接口的只读部分结构体(readonly partial struct ) 。结构体里的成员涵盖多种类型,如 Entity 类型的引用;用 RefRW (可读写组件数据引用 )与 RefRO(只读组件数据引用 )修饰的组件数据引用 ;EnabledRefRW 与 EnabledRefRO 的 Enable Component 组件数据的引用;DynamicBuffer 类型数据 buffer;shared component 类型的组件的引用等 。
一个Aspect必须包含
在这里插入图片描述

using Unity.Entities;
using Unity.Transforms;

namespace TMG.Zombies
{
    public readonly partial struct GraveyardAspect : IAspect
    {
        public readonly Entity Entity;
        private readonly TransformAspect _transformAspect;
        private readonly RefRO<GraveyardGraveyardProperties> _graveyardProperties;
        private readonly RefRW<GraveyardRandom> _graveyardRandom;
    }

}

写好这个Aspect后会发现他自动的添加到了含有所有包装盒组件的物体上。
在这里插入图片描述

3.实现第一个System–SpawnTombSystem

1.生成墓碑

system可以是class也可以是struct,取决于他是否需要继承等类的特点。
无论是struct还是strcut都需要实现3个接口的抽象函数(和Mono生命周期很像)。
如上文定义所说,System是用来处理逻辑的,里面不含任何的数据,与之相对应的组件是用来存放数据的,里面不包含任何的逻辑。
完整实现:
在graveyardAspect中暴露两个组件的字段
在这里插入图片描述

在这里插入图片描述
可以看到在EntitiesHierarchy中生成了多个墓碑预制件。
在这里插入图片描述

2.个性化墓碑UniformScaleTransform

在这里插入图片描述
可以发现LocalToWorldTransform是3维度可以描述且简单易懂的,但是LocalToWorld好像是矩阵变换,博主不会。
在这里插入图片描述

public UniformScaleTransform GetRandomTombStoneTransform()
{
    return new UniformScaleTransform
    {
        Position = GetRandomPosition(),
        Rotation = quaternion.identity,
        Scale = 1f
    };
}
private float3 GetRandomPosition()
{
    float3 randomPosition;
    //随机数获取
    randomPosition = _graveyardRandom.ValueRW.value.NextFloat3(MinCorner, MaxCorner);
    return randomPosition;
}
//定义范围
private float3 MinCorner => _transformAspect.Position - HalfDimensions;
private float3 MaxCorner => _transformAspect.Position + HalfDimensions;
private float3 HalfDimensions => new()
{
    x = _graveyardProperties.ValueRO.FieldDimensions.x * 0.5f,
    y = 0f,
    z = _graveyardProperties.ValueRO.FieldDimensions.y * 0.5f
};

发现墓碑能够正确生成后,再完善一下生成范围,生成的个性化墓碑。

public UniformScaleTransform GetRandomTombStoneTransform()
{
    return new UniformScaleTransform
    {
        Position = GetRandomPosition(),
        Rotation = GetRandomRotation(),
        Scale = GetRandomScale(0.5f)
    };
}
//随机旋转y
private quaternion GetRandomRotation() => quaternion.RotateY(_graveyardRandom.ValueRW.value.NextFloat(-0.25f, 0.25f));
//随机大小min - 1f
private float GetRandomScale(float min) => _graveyardRandom.ValueRW.value.NextFloat(min, 1f);
private float3 GetRandomPosition()
{
    float3 randomPosition;
    //随机数获取
    do
    {
        //确保两者距离的平方不在安全范围之内 否则重新生成位置
        randomPosition = _graveyardRandom.ValueRW.value.NextFloat3(MinCorner, MaxCorner);
    }
    while (math.distancesq(_transformAspect.Position, randomPosition) <= BRAIN_SAFETY_RADIUS_SQ);
    
    return randomPosition;
}

4.不知道第几个组件–zombieSpawnPoints组件 初见NativeArray

NativeArray是ECS特化的多线程并行执行
可以精确控制可读可写的权限
同时是值类型所以空间连续,cache命中率高,无拆装箱。

public struct ZombieSpawnPoints : IComponentData
{
    public NativeArray<float3> Value; 
}

同步在Aspect这个包装盒新增这个组件的引用,对外使用属性定义。
并且在bake中添加这个组件到实体中(挂载mono脚本的)。

private readonly RefRW<GraveyardRandom> _graveyardRandom;
public NativeArray<float3> zombieSpawnPoints
{
    get => _zombieSpawnPoints.ValueRO.Value;
    set => _zombieSpawnPoints.ValueRW.Value = value;
}
 AddComponent<ZombieSpawnPoints>();

—4月22号修改

5.僵尸生成

1.初识Execute函数和Job

foreach类型job:用于遍历访问含有参数组件的实体

public struct MoveEntitiesJob : IJobForEach<Translation, MovementSpeed>
{
    public float DeltaTime;

    public void Execute(ref Translation translation, ref MovementSpeed speed)
    {
        // 根据移动速度更新实体的位置
        translation.Value += math.forward() * speed.Value * DeltaTime;
    }
}

Entity类型job:功能和foreach类似,但是不需要在声明结构体的时候指定泛型类型,只需要在Execute指定就行了。
也就是说job类型包含了查询query的功能。
僵尸生成的主要代码
在这里插入图片描述
每帧都创建一个新的Job可以保证僵尸在对的时间被生成,虽然会有性能的开销。
ECB在作业结束后就被销毁。
用几乎和墓碑位置生成一样的脚本,定义僵尸的Transform

 public UniformScaleTransform GetZombieSpawnPoint()
 {
     float3 position = GetRandomZombieSpawnPoint();
     return new UniformScaleTransform
     {
         Position = position,
         Rotation = quaternion.RotateY(MathHelpers.GetHeading(position, _transformAspect.Position)),
         Scale = 1f
     };

 }
 public float3 GetRandomZombieSpawnPoint()
 {
     return zombieSpawnPoints[_graveyardRandom.ValueRW.value.NextInt(zombieSpawnPoints.Length)];
 }
public partial struct SpawnZombieJob : IJobEntity
{
    //处理僵尸的生成
    public float deltaTime;
    public EntityCommandBuffer ecb;
    [BurstCompile]
    private void Execute(GraveyardAspect graveyardAspect)
    {
        graveyardAspect.ZombieSapwnTimer -= deltaTime;
        if (!graveyardAspect.TimeToSpawnZombie) return;
        if (graveyardAspect.zombieSpawnPoints.Length == 0) return;
        graveyardAspect.ZombieSapwnTimer = graveyardAspect.ZombieSpawnRate;
        Entity zombie =  ecb.Instantiate(graveyardAspect.ZombiePrefab);
        UniformScaleTransform uniformScaleTransform = graveyardAspect.GetZombieSpawnPoint();
        ecb.SetComponent(zombie, new LocalToWorldTransform { Value = uniformScaleTransform });
    }
}
2.僵尸升起系统

不同于生成僵尸,上升僵尸位置是一个过程,在同一时间段内会有不同的僵尸位置上升,所以要使用到多线程处理job。
在这里插入图片描述

1.编译时间语句
//这行代码要求该系统在SpwanZombieSystem执行后执行
[UpdateAfter(typeof(SpawnZombieSystem))]

和Unity一样,脚本的执行顺序是会影响到效果的。我们必须确保上升脚本在僵尸生成脚本之后调用。
在这里插入图片描述

2.僵尸停止上升

原视频似乎漏掉了 SetAtGroundLevel 和 IsAboveGround的定义,经过查阅github原码发现是有这部分代码的。

 public void SetAtGroundLevel()
 {
     float3 position = _transformAspect.Position;
     position.y = 0;
     _transformAspect.Position = position;
 }

1. BeginInitializationEntityCommandBufferSystem
此系统会在初始化阶段的开头执行。在 Unity ECS 的执行流程中,初始化阶段是系统执行的起始部分,主要用于处理一些需要在游戏开始时或者每帧开始时完成的初始化工作。
它在系统更新的早期就会处理命令缓冲中的命令,这意味着在这个阶段进行的操作会优先于其他大部分系统的更新操作。
2. EndSimulationEntityCommandBufferSystem
该系统在模拟阶段的末尾执行。模拟阶段是处理游戏逻辑的主要阶段,涵盖了实体的移动、碰撞检测、状态更新等众多操作。
EndSimulationEntityCommandBufferSystem 会在模拟阶段的所有系统更新完成之后,才处理命令缓冲中的命令。所以,在这个阶段进行的操作通常是对当前帧模拟结果的最终处理。

public partial struct ZombieRiseJob : IJobEntity
{
    public float deltaTime; 
    public EntityCommandBuffer ecb;
    [BurstCompile]
    private void Execute(ZombieRiseAspect zombieRiseAspect)
    {
        zombieRiseAspect.Rise(deltaTime);
        if (!zombieRiseAspect.IsAboveGround) return;
        ecb.RemoveComponent<ZombieRiseRate>(zombieRiseAspect.Entity);
    }
}

对于一个多线程的任务,使用普通的ECB是不够的,不过这里有个简单的方法来避免错误。

在这里插入图片描述
不过细心小伙伴发现了,下面又多了一个报错,原因是如果你在多线程不给它传入一个哈希key它并不知道是哪个任务会出现多线程的问题,所以还需传入一个sortkey。

3.sortkey的作用

sortkey旨在关注“有顺序”的动作以及多线程环境下的增删动作。
比如我们希望在多线程下按顺序控制僵尸动作要soetkey。
我们要多线程中删除某个僵尸的组件,也需要得知顺序,并非是因为删除需要sortkey,而是因为需要知道sortkey才不会引发线程问题。
在这里插入图片描述
sortKey由ECS系统自行分配和使用,我们只要传入就好了。

4.再详细说说bake

bake可以把Authoring的物体属性转化成ECS世界的。
每个带有这个Authoring的物体都会被转化成对应的实体。
在这里插入图片描述
在这里插入图片描述

即每个僵尸实体都会带有这些转化后的组件并且被视为实体。

6.僵尸行为

1.Tag组件

没有内容,只作为查询的索引组件。

public struct NewZombieTag : IComponentData { }

创建Aspect,包含对走路逻辑和走路范围的实现。

public readonly partial struct ZombieWalkAspect : IAspect
{
    public readonly Entity Entity;

    private readonly TransformAspect _transform;
    private readonly RefRW<ZombieTimer> _walkTimer;
    private readonly RefRO<ZombieWalkProperties> _walkProperties;
    private readonly RefRO<ZombieHeading> _heading;

    private float WalkSpeed => _walkProperties.ValueRO.WalkSpeed;
    private float WalkAmplitude => _walkProperties.ValueRO.WalkAmplitude;
    private float WalkFrequency => _walkProperties.ValueRO.WalkFrequency;
    private float Heading => _heading.ValueRO.Value;

    private float WalkTimer
    {
        get => _walkTimer.ValueRO.Value;
        set => _walkTimer.ValueRW.Value = value;
    }

    public void Walk(float deltaTime)
    {
        WalkTimer += deltaTime;
        _transform.Position += _transform.Forward * WalkSpeed * deltaTime;

        var swayAngle = WalkAmplitude * math.sin(WalkFrequency * WalkTimer);
        _transform.Rotation = quaternion.Euler(0, Heading, swayAngle);
    }

    public bool IsInStoppingRange(float3 brainPosition, float brainRadiusSq)
    {
        return math.distancesq(brainPosition, _transform.Position) <= brainRadiusSq;
    }
}

ZombieWalk / Eat Properties详细
继承IEnableableComponent组件的组件可以设定自身是否启用。

public struct ZombieWalkProperties : IComponentData, IEnableableComponent
{
    public float WalkSpeed;
    public float WalkAmplitude;
    public float WalkFrequency;
}

public struct ZombieEatProperties : IComponentData, IEnableableComponent
{
    public float EatDamagePerSecond;
    public float EatAmplitude;
    public float EatFrequency;
}

public struct ZombieTimer : IComponentData
{
    public float Value;
}

public struct ZombieHeading : IComponentData
{
    public float Value;
}

2.初始化僵尸行为

生成时,walk 和 eat应该为false

public void OnUpdate(ref SystemState state)
{
    var ecb = new EntityCommandBuffer(Allocator.Temp);
    //让含有哨兵的僵尸的初始移动能力和吃能力为false 然后删除这个哨兵
    foreach (var zombie in SystemAPI.Query<ZombieWalkAspect>().WithAll<NewZombieTag>())
    {
        ecb.RemoveComponent<NewZombieTag>(zombie.Entity);
        ecb.SetComponentEnabled<ZombieWalkProperties>(zombie.Entity, false);
        ecb.SetComponentEnabled<ZombieEatProperties>(zombie.Entity, false);
    }

    ecb.Playback(state.EntityManager);
    ecb.Dispose();
}

那么什么时候应该走呢?没错就是当僵尸完全出土的时候,在ZombieRiseSystem修改它walkpropertiesEnable = true;

public partial struct ZombieRiseJob : IJobEntity
{
    public float deltaTime;
    public EntityCommandBuffer.ParallelWriter ecb;
    [BurstCompile]
    private void Execute(ZombieRiseAspect zombieRiseAspect, [EntityInQueryIndex] int sortKey)
    {
        zombieRiseAspect.Rise(deltaTime);
        if (!zombieRiseAspect.IsAboveGround) return;
        ecb.RemoveComponent<ZombieRiseRate>(sortKey, zombieRiseAspect.Entity);
        ecb.SetComponentEnabled<ZombieWalkProperties>(sortKey, zombieRiseAspect.Entity, true);
    }
}

初始化朝向在僵尸刚生成的时候初始化。

 UniformScaleTransform uniformScaleTransform = graveyardAspect.GetZombieSpawnPoint();
 ecb.SetComponent(zombie, new LocalToWorldTransform { Value = uniformScaleTransform });
 var zombieHeading = MathHelpers.GetHeading(uniformScaleTransform.Position, graveyardAspect.Position);
 ecb.SetComponent(zombie, new ZombieHeading { Value = zombieHeading });
3.僵尸停止在脑前

要执行的job
在这里插入图片描述
update
在这里插入图片描述
—4月23号修改

4.僵尸吃脑子

首先新建一个标签组件,标识某个物体是脑子,然后用bake转化一下mono脚本。

namespace TMG.Zombies
{
    public class BrainMono : MonoBehaviour
    {
        
    }

    public class BrainBaker : Baker<BrainMono>
    {
        public override void Bake(BrainMono authoring)
        {
            AddComponent<BrainTag>();
        }
    }
}

namespace TMG.Zombies
{
    public partial struct BrainTag : IComponentData { }

}

7.脑子模块

脑子血量模块

namespace TMG.Zombies
{
    public partial struct BrainHealth : IComponentData
    {
        public float Max;
        public float Value;
    }
}

脑子烘焙模块

 public class BrainMono : MonoBehaviour
 {`在这里插入代码片`
     public float brainHealth;
 }

 public class BrainBaker : Baker<BrainMono>
 {
     public override void Bake(BrainMono authoring)
     {
         AddComponent<BrainTag>();
         AddComponent(new BrainHealth
         {
             Max = authoring.brainHealth,
             Value = authoring.brainHealth
         });
         AddBuffer<BrainDamageElementBuffer>();
     }
 }

僵尸造成的伤害存入动态缓存区统一处理。
动态缓存区类似一个数组,并且使用ECB添加数组内容在里面。

public void Eat(float deltaTime, EntityCommandBuffer.ParallelWriter ecb, int sortKey, Entity brainEntity)
{
    ZombieTimer += deltaTime;
    var eatAngle = EatAmplitude * math.sin(EatFrequency * ZombieTimer);
    _transform.Rotation = quaternion.Euler(eatAngle, Heading, 0);

    var eatDamage = EatDamagePerSecond * deltaTime;
    var curBrainDamage = new BrainDamageElementBuffer { value = eatDamage };
    ecb.AppendToBuffer(sortKey, brainEntity, curBrainDamage);
}

完成大脑数值改变包装盒

public void DamageBrain()
{
    foreach(BrainDamageElementBuffer damage in _brainDamages)
    {
        _brainHealth.ValueRW.Value -= damage.value;
    }
    _brainDamages.Clear();
    var ltw = _transform.LocalToWorld;
    ltw.Scale = _brainHealth.ValueRO.Value / _brainHealth.ValueRO.Max;
    _transform.LocalToWorld = ltw;
}

写入攻击大脑系统
这里的state.Dependency.Complete();要求等待所有依赖完成后才执行,可能会造成阻塞,但是可以避免线程问题。

public void OnUpdate(ref SystemState state)
{
    state.Dependency.Complete();
    foreach (var brain in SystemAPI.Query<BrainAspect>())
    {
        brain.DamageBrain();
    }
}

8.相机

using System;
using Unity.Entities;
using Unity.Transforms;
using UnityEngine;

namespace TMG.Zombies
{
    public partial class CameraControllerSystem : SystemBase
    {
        protected override void OnUpdate()
        {
            var brainEntity = SystemAPI.GetSingletonEntity<BrainTag>();
            var brainScale = SystemAPI.GetComponent<LocalToWorldTransform>(brainEntity).Value.Scale;

            var cameraSingleton = CameraSingleton.Instance;
            if (cameraSingleton == null) return;
            var positionFactor = (float)SystemAPI.Time.ElapsedTime * cameraSingleton.Speed;
            var height = cameraSingleton.HeightAtScale(brainScale);
            var radius = cameraSingleton.RadiusAtScale(brainScale);

            cameraSingleton.transform.position = new Vector3
            {
                x = Mathf.Cos(positionFactor) * radius,
                y = height,
                z = Mathf.Sin(positionFactor) * radius
            };
            cameraSingleton.transform.LookAt(Vector3.zero, Vector3.up);
        }
    }
}
using UnityEngine;

namespace TMG.Zombies
{
    public class CameraSingleton : MonoBehaviour
    {
        public static CameraSingleton Instance;

        [SerializeField] private float _startRadius;
        [SerializeField] private float _endRadius;
        [SerializeField] private float _startHeight;
        [SerializeField] private float _endHeight;
        [SerializeField] private float _speed;

        public float RadiusAtScale(float scale) => Mathf.Lerp(_startRadius, _endRadius, 1 - scale);
        public float HeightAtScale(float scale) => Mathf.Lerp(_startHeight, _endHeight, 1 - scale);
        public float Speed => _speed;

        private void Awake()
        {
            if (Instance != null)
            {
                Destroy(gameObject);
                return;
            }

            Instance = this;
        }
    }
}

4月24修改


网站公告

今日签到

点亮在社区的每一天
去签到