ECS(Entitas) For Unity #1

  1.7 C#, 3.2 游戏引擎技术
  • Entitas-RTS-Template:传送门
  • ECS博主:传送门 其主要博客:传送门
  • 另一篇博客:传送门
  • ECS教程视频:传送门,视频下方简介有工程文件
  • 最好不要在没有任何自己编写的文件时以及在其他任何非必要点击节点进行enny->Preference的核按钮点击。
  • CookBook:传送门
  • (不确定会不会有#2)

前言

  • ECS架构的学习曲线较为陡峭,加之并没有相对完全成熟的代码解决方案,因此请务必抱有决心来进行学习。
  • Entitas为支持ECS架构的一个插件,且于2020年停止更新。
  • Unity官方未来规划重点在于ECS架构的支持,但目前仍处于完善阶段,预计未来2年左右会得到较大完善。
  • 本篇以及本篇所用Entitas为一个现阶段的临时解决方案,他没有Unity底层的优化以及官方支持,但你仍可以依靠Entitas写出不错的ECS架构游戏以及通过此来窥见ECS的整体样貌。
  • 我认为ECS的架构很适合编写一款游戏:Baba is you,如果你没有玩过这款游戏,那么推荐你去玩一下或者云一下,该游戏可能会对于理解该架构有一定的帮助作用。

安装

环境基础

  • Unity 2021.3.1f1c1(LTS)
  • VS 2022

安装

  • 在Unity Asset Store里的Entitas插件相较于Github中落后一个版本,GitHub插件代码地址:传送门(1.13.0)
  • 点击下图所示zip文件进行下载
image 9 1024x657 - ECS(Entitas) For Unity #1
  • 解压zip到桌面或其他位置,会有如下两个文件夹,并按照下图所示层级建立文件夹把文件放置到Unity工程文件中
image 10 - ECS(Entitas) For Unity #1
image 11 - ECS(Entitas) For Unity #1
  • 以下是插件版本:添加Entitas插件并导入(千万不要随意挪动他的文件夹以及在其文件夹下创建/删除文件,让他安静的放置在哪里就好)
image 5 1024x720 - ECS(Entitas) For Unity #1
  • 不论你是从哪里进行的下载安装,在安装完成后你的顶部菜单栏中多了一个Tools选项,点击Tools->Jenny->Preference选项,显示出如下面板,然后点击Auto Import。
  • 请不要直接点击核按钮(这也是称之为核按钮的原因所在),当你的代码需要进行生成时(例如你编写了一个Component)在进行点击,以及不要进行反复点击或者在没有任何需要生成的操作时进行点击。
image 6 - ECS(Entitas) For Unity #1

00.ECS概念

  • 详细概念请参照博客:传送门
  • E:Entity,可以理解为一个标签Tag,ECS通过Enity去Add功能模块(System)
  • C:Component,只包含数据字段,不作任何逻辑处理
  • S:System,只处理功能,不作任何数据的定义声明
  • 工作逻辑:Unity中的GameObject链接需要的Entity,Entity链接加载System功能,System处理功能/逻辑,增删改查Component数据。
  • 特点:
    1、功能与数据分开,每个功能之间互相并不会收到干扰,可灵活增加编写功能,可灵活链接或者解绑功能。
    2、只要GameObject包含的Entity中有该功能,那么GameObject就有该功能
  • 举例:有一个System的功能实现了奔跑的逻辑,奔跑的数据存储在了相应Component中,只要任何GameObject挂载了一个Entity并且该Entity加载了奔跑的System,那么不论这个GameObject是啥,他都能进行奔跑的动作。

01.简要介绍

  • 本段落来自于:传送门的改编版本。融入了一些个人收集到的资料和看法。如有时间建议通读原本英文标注。

实体(Entity)

  • 一个实体是一个容器,在你的应用中,它负责保存代表某些对象的数据。你可以以实际形式添加,替换或删除IComponent中实体中的数据。同时Entitias 具有相应的事件,以通知您是否添加,替换或删除了组件。
//增加、获取、移除组件
entity.AddComponent(index, component);
entity.GetComponent(index);
entity.RemoveComponent(index);
//1、给实体赋值
//Position为一个组件,其结构为保存一个Vector2 pos;
//方法一(效率慢):使用使用Generate生成的Add代码
entity.AddPosition(3, 7);
//方法二:直接赋值(速度据说比方法一快三倍)
entity.Position.pos = new vector2(3,7);
//组件标签,当组件为空的时候,那么该组件相当于一个Tag或者Flag
entity.isMovable = false;

//2、获取实体的其中组件值
var hasPos = entity.hasPosition;
  • 实体总是上下文(Context)的一部分,因此我们必须使用context.CreateEntity()来创建实体,而不是直接实例化。
  • Entity的销毁并非真实销毁,而是存放到了上下文的对象池中,以此来避免GC。
  • 实体订阅事件:官方不推荐使用订阅事件,而是使用Group来代替。

上下文(Context)

  • 上下文是一个负责创造或者销毁实体的工厂,他是实体的父级。使用它来过滤你所感兴趣的实体。
  • 上下文是一种监视实体生命周期的管理数据结构
// Contexts.game由代码自动生成
var gameContext = Contexts.game;
var entity = gameContext.CreateEntity();
entity.isMovable = true;

// Returns all entities having MovableComponent and PositionComponent.
// Matchers也会自动生成
var entities = gameContext.GetEntities(Matcher<GameEntity>.AllOf(GameMatcher.Movable, GameMatcher.Position));
foreach (var e in entities) {
    // do something
}
  • 可以把Component定义为行,Entity定义为列,那么Context就是这一整张表格。如果Component或者Entity的数目过大,则会造成单个Context的臃肿,因此可能需要更多的Context。
    在64位系统上一个Component的索引指针为8Bytes。如果一个Context有100个Entity,50个Component,那么整个的Component大小为40KB。

组(Group)

  • 组可以对上下文当中的实体进行超级快速(super quick)的过滤。它们会一直的更新当实体发生变化,并且可以返瞬间返回你所需要的组。想想一下,你有成千上万个实体,但是你只想要那些拥有PositionComponent的组,只需要向上下文询问这个组,答案就已经在等着你了。
gameContext.GetGroup(GameMatcher.Position).GetEntities();
  • 组与获取实体都被缓存在内存当中,所以关于调用他们方法将会非常的快,尝试尽可能多的使用这个小技巧。
  • 同样gameContext.GetEntities(GameMatcher.Movable) 也是使用组的结构。
  • 组具有事件 OnEntityAddedOnEntityRemoved and OnEntityUpdated 可以直接对组的更改做出反应。
gameContext.GetGroup(GameMatcher.Position).OnEntityAdded += (group, entity, index, component) => {
    // Do something
};
  • Entitas不会真正地移除Component,而是采用了新的值以及激活对应事件,就好像真正地加入了新的值。以此方法来避免内存分配以及模拟不可变组件的效果。
//group事件
OnEntityAdded
OnEntityRemoved
OnEntityUpdated

收集器(Collector)

  • 收集器提供了一种简单的方法来响应组中的更改。 在下面我将演示如何去使用收集器,假设您想要收集和处理那些添加或替换PositionComponent的所有实体。
//获取一个group,所有使用了GameBoardElement.Removed()事件(即删除了该组件)的Entity
context.CreateCollector(GameMatcher.GameBoardElement.Removed());
//总共三个Added,Removed,AddedOrRemoved
//这里有个问题有待验证一下,如果不使用他生成的Add而是我手动直接更改的方式更改数据是否也会被检测到。我猜测应该是不可以
var group = gameContext.GetGroup(GameMatcher.Position);
var collector = group.CreateCollector(GroupEvent.Added);
foreach (var e in collector.collectedEntities) {
    // do something with all the entities
    // that have been collected to this point of time
}
collector.ClearCollectedEntities();
  • 一个Entity的组件被移除并被Collector捕捉到,这个Entity如果之后加入了新的同样的组件,那么他还是会被Collector捕捉到,并且再次被加入到Group中,因此reactive systems必须要实现Filter方法。
  • 可以激活和停用Collector,这样我们就可以停止并恢复对该组的观察。我们可以迭代收集的实体并清除它们。

匹配器(GameMatcher)

  • GameMatcher是Entitas的查询方法,我们可以查询到所有有关一个Context的所有Component。
//查询所有拥有某些组件的GameEntity。AllOf代表全满足,AnyOf至少满足一个,NoneOf没有该组件则满足条件。NoneOf不能单独使用,必须搭配前两者一起使用,因为NoneOf可能会使得产生的查询过长
//慎用AnyOf,他可能会返回预期之外的结果。如果发生这种情况,建议使用多个GameMatcher来进行合并查找,而非AnyOf
context.GetGroup(GameMatcher.AllOf(GameMatcher.Position,GameMatcher.Velocity).NoneOf(GameMatcher.NotMovable));

组件(Component)

  • ECS中的C
  • Entitas存放数据的代码文件。仅存放数据。
  • 除了普通的Component之外,你还可以给Component添加标签,这些标签应放在定义类的前一行。
[Context]: 你可以使用这个属性让它仅在特定的上下文中可以被获取; ,[MyContextName], [Enemies], [UI], etc. 提高内存占用率。不仅如此,他还可以创建组件。
[Unique]: 代码生成器将会提供一个额外的方法,来确保最大仅有一个实体在这个组件上存在。
[FlagPrefix]: 这个属性可以给你的组件标记一个自定义的前缀。
[PrimaryEntityIndex]: 可以将实体固定为一个唯一的组件值。
[EntityIndex]: 可用于通过组件值寻找相对应的组件。
[CustomComponentName]: 生成多个名字不同的组件继承自同一个类或者同一个接口。
[DontGenerate]: 代码生成器将会跳过这个属性的组件。
[Event]: 代码生成器将会为反应式的UI 生成相应的组件,系统。接口。
[Cleanup]: 代码生成器将会生成一处组件或者删除组件。

System

  • ECS中的S,在System中编写逻辑和具体代码。在System可以创建,更改,销毁状态。
  • Entitas提供了很多接口去标记Class和Systrem
    1、ISystem :base System,不需要自己实现,只需要进行标记即可
    2、IExecuteSystem:按周期执行的接口,其中只有一个void Execute()方法,在该方法中的内容会被每个tick执行。
    3、ICleanupSystem:清理接口。在所有IExecuteSystem执行后执行。一般用于清理。其中只有一个void Cleanup();方法。
    4、IInitializeSystem:初始化接口。实现void Initialize()方法,包含所有游戏的初始化逻辑。生成所有的Entity和其他需要初始化的内容。
  • 在所有自己编写的系统完成后,需要进行添加。Entitas提供了Feature类来扩展进行添加所有的System。将DebugSystems加入其中会消耗更多的视觉资源,所以在打包生成或者手机上请勿添加。
public class MatchOneSystems : Feature {

    public MatchOneSystems(Contexts contexts) {

        // Input
        Add(new InputSystems(contexts));

        // Update
        Add(new GameBoardSystems(contexts));
        Add(new GameStateSystems(contexts));

        // Render
        Add(new ViewSystems(contexts));

        // Destroy
        Add(new DestroySystem(contexts));
    }
}
  • 运行Systems的模板
using Entitas;
using UnityEngine;

public class GameController : MonoBehaviour {

    Systems _systems;

    void Start() {
        Random.InitState(42);

        var contexts = Contexts.sharedInstance;

        _systems = new MatchOneSystems(contexts);

        _systems.Initialize();
    }

    void Update() {
        _systems.Execute();//检查所有可执行System
        _systems.Cleanup();//执行完成之后进行清除
    }

    void OnDestroy() {
        _systems.TearDown();//释放
    }
}

Reactive System

  • Reactive System只有当有实体我们需要处理时才会调用,内部是由Collector进行实现。
  • 下方代码实现了寻找过滤所有具有Destroyed的组件,并把他们的实体干掉。
using System.Collections.Generic;
using Entitas;

public sealed class DestroySystem : ReactiveSystem<GameEntity> {

    public DestroySystem(Contexts contexts) : base(contexts.game) {
    }
//CreateCollector的默认检测事件为Add所以在示例中没有添加
    protected override ICollector<GameEntity> GetTrigger(IContext<GameEntity> context) {
        return context.CreateCollector(GameMatcher.Destroyed);
    }
//过滤返回所有拥有该组件的Entity
    protected override bool Filter(GameEntity entity) {
        return entity.isDestroyed;
    }
//DestroySystem在每个周期都会运行,但Execute只有在Collector收集到内容后才执行
    protected override void Execute(List<GameEntity> entities) {
        foreach (var e in entities) {
            e.Destroy();
        }
    }
}
  • Filter:当一个实体被Collector收集时,会保持该收集状态。因此,如果从实体中移除了Component,他仍会保持收集状态并传递给Execute执行。除非我们过滤掉他,Filter就是做这件事情的。

Hello World示例


02.用Entitas编写一个人物移动脚本

结论

  • 前置准备工作:因为使用了Entitas作为基础框架,所以可以在(你的Unity目录)\Editor\Data\Resources\ScriptTemplates中更改81-C# Script-NewBehaviourScript.cs
    这样就不必每次多去引用Entitas了。当然也可以自行添加新的二级脚本。
  • 一下是本人写的一些代码模板以供直接使用
  • 整体ECS结构图如下,以下的部分内容参考了开篇提到的ECS教程视频,再次感谢前人的探索与分享。
ECS人物移动 1024x427 - ECS(Entitas) For Unity #1
  • 虽然个人认为分享工程文件并不是推荐的学习方法,但出于人道主义关怀,对于第一个脚本还是公开相应的脚本以帮助降低理解难度。

过程

  • 请在每一个Component组件编写完成后点击一次“核按钮(Gnerate)”,他会为你自动生成一些辅助代码,例如add等。
  • 首先,对于ECS架构来说,我们需要根据整个功能和流程来进行拆分。
image 13 - ECS(Entitas) For Unity #1
  • 因此,对于主角移动来说,按照上述流程划分:要有1个实体(主角),7个系统(入口,系统添加,人物生成,键盘获取,更改速度,更改位置,清理数据),以及2个组件(速度,位置)。
    当然实际情况可能会进行相应的更改,譬如可以合并处理速度和位置的系统为一个,因为只要获取了速度就一定会更改相应的位置。
  • 关于代码结构,当你的游戏体量较小时,你可以采用如下简单的架构进行代码分类管理,把所有的System放到一个文件夹中,所有的Components放到一个文件夹中,并且把作管理类的代码提到与这两个文件夹同级的目录中。
image 14 - ECS(Entitas) For Unity #1
  • 而当你的游戏体量要比FlappyBird那种稍大一些,那么可以建议采用如下结构。为每一个大的功能模块(或者是按照一定原理划分出来的一组概念)创建一个单独的文件夹。
image 15 - ECS(Entitas) For Unity #1
  • 当你的游戏可能会较大时的结构:这种模式相较于上种来说,你可以把系统划分的尽量细致一些,但对于Component组件,因为例如主角,怪物,NPC可能都有速度,位置,HP,MP等相同的组件,因此在Component文件夹下可以不进行细分。
image 16 - ECS(Entitas) For Unity #1
  • 以上仅提供一些大致的关于ECS代码结构的思路,随着游戏体量的逐渐变大,在上述结构中进行细分和更改会对代码结构设计更加的便利。
  • 而对于第一个工程文件来说,我们可以直接采用第一种方式来进行结构规划。
    其中Hybrid是Entitas和MonoBehavior的混合代码,在这里他主要的工作是把Entitas中的Entity连接到Unity的GameObject上。
    EntityUtil管理物体在游戏窗口中的生成,Setting是一些系统设置。
image 20 - ECS(Entitas) For Unity #1
  • 我们根据流程图来创建编写我们的代码,首先是游戏的入口以及管理
using UnityEngine;
public class GameManager : MonoBehaviour
{
    private GameSystem m_gameSystem;
    /// <summary>
    /// 唤醒时创建游戏系统
    /// </summary>
    private void Awake()
    {
        m_gameSystem = new GameSystem(Contexts.sharedInstance);
    }

    /// <summary>
    /// 游戏系统实例化生成
    /// </summary>
    private void Start()
    {
        m_gameSystem.Initialize();
    }

    /// <summary>
    /// 用Unity原生Update检查那些System需要执行以及清理
    /// </summary>
    private void Update()
    {

        m_gameSystem.Execute();
        m_gameSystem.Cleanup();
    }

    /// <summary>
    /// ECS清理回收
    /// </summary>
    private void OnDestroy()
    {
        m_gameSystem.TearDown();
    }
}

  • 对于整个ECS部分来说,你只会在游戏场景中创建一个GameManager并在其上挂载管理代码。之后的一切都是再运行时进行生成。
image 19 - ECS(Entitas) For Unity #1
  • 第二步,添加我们的系统。该部分的顺序请不要弄错,否则容易出现问题。
public class GameSystem : Feature
{
    public GameSystem(Contexts contexts)
    {
        //玩家初始化
        Add(new PlayerSpawnSystem(contexts));

        //玩家输入
        Add(new InputSystem(contexts));
        Add(new PlayerInputProcessSystem(contexts));

        //移动
        Add(new MoveSystem(contexts));

        //加载实体到场景
        Add(new AddViewSystem(contexts));

        //清理
        Add(new InputCleanupSystem(contexts));
    }
}
  • 第三步,对于初始化人物部分。我们通过Contexts上下文创建一个游戏的Entity,然后为这个Entity添加Player标签、速度、位置这三个主要组件。
  • CreatObjectCmdComp组件会被生成系统检测到,并在游戏场景中实例化该对象之后移除该组件。他传入的值为Resources文件夹中的Prefab位置,为了方便演示,我们通过Resources.load等相关方式直接加载资源。
public static class EntityUtil
{

    /// <summary>
    /// 创建PlayerEntity
    /// </summary>
    /// <param name="contexts"></param>
    /// <param name="pos">生成位置</param>
    /// <param name="vel">速度</param>
    /// <returns></returns>
    public static GameEntity creatPlayerEntity(Contexts contexts, Vector2 pos,Vector2 vel)
    {
        var playerEntity = contexts.game.CreateEntity();
        playerEntity.isPlayerTag = true;
        playerEntity.AddPosComp(pos);
        playerEntity.AddVelComp(vel);
        playerEntity.AddCreatObjectCmdComp("Prefab/Player");
        return playerEntity;
    }
}
  • 我们的人物生成系统
public class PlayerSpawnSystem :IInitializeSystem
{

    private readonly Contexts m_context;
    public PlayerSpawnSystem(Contexts contexts)
    {
        m_context = contexts;
    }
    public void Initialize()
    {
        EntityUtil.creatPlayerEntity(m_context, Vector2.zero, Vector2.zero);
    }
}
  • 实例化系统会实时检测并生成到游戏场景中。
  • 代码末尾view.Link(m_contexts, gameEntity);的实现是在Hybrid文件夹中的代码,他主要的作用为:gameObject.Link(entity);把Gameobject上链接对应的Entity。
public class AddViewSystem : ReactiveSystem<GameEntity>
{
    private readonly Contexts m_contexts;
    public AddViewSystem(Contexts contexts) : base(contexts.game)
    {
        m_contexts = contexts;
    }

    /// <summary>
    /// 调用Entity生成和绘制模块
    /// </summary>
    /// <param name="entities">所有符合条件的Entity</param>
    protected override void Execute(List<GameEntity> entities)
    {
        foreach(var entity in entities)
        {
            var obj = SpawnObj(entity);
            entity.AddViewComp(obj);
            //生成之后就无须再次生成,所以直接移除生成Comp
            entity.RemoveCreatObjectCmdComp();
        }
    }

    protected override bool Filter(GameEntity entity)
    {
        return true;
    }

    protected override ICollector<GameEntity> GetTrigger(IContext<GameEntity> context)
    {
        return context.CreateCollector(GameMatcher.CreatObjectCmdComp);
    }

    /// <summary>
    /// 生成Enity所链接的Gameobject的方法
    /// </summary>
    /// <param name="gameEntity"></param>
    /// <returns></returns>
    private GameObject SpawnObj(GameEntity gameEntity)
    {
        var path = gameEntity.creatObjectCmdComp.path;
        var prefab = Resources.Load<GameObject>(path);
        var obj = GameObject.Instantiate(prefab,Vector3.zero,Quaternion.identity);
        var view = obj.GetComponent<View>();
        view.Link(m_contexts, gameEntity);
        return obj;
    }
}
  • 第四步,键盘获取读入
public class InputSystem : IExecuteSystem
{

    private readonly Contexts m_contexts;
    public InputSystem(Contexts contexts)
    {
        m_contexts = contexts;
    }


    public void Execute()
    {
        var playerInputEntity = m_contexts.input.CreateEntity();
        playerInputEntity.AddInputComp(new Vector2(
            Input.GetAxis("Horizontal"),
            Input.GetAxis("Vertical")
            )); 
    }
}
  • 获取读入的速度并赋值给速度组件。其中GetTrigger获取了所有拥有InputComp组件的实体。
public class PlayerInputProcessSystem : ReactiveSystem<InputEntity>
{
    private readonly Contexts m_contexts;
    private readonly IGroup<GameEntity> m_playerGroup;

    public PlayerInputProcessSystem(Contexts contexts) : base(contexts.input)
    { 
        m_contexts = contexts;
        m_playerGroup = m_contexts.game.GetGroup(GameMatcher.PlayerTag);
    }

    /// <summary>
    /// 获取速度读入并给Player赋值
    /// </summary>
    /// <param name="entities">PlayerEntity单例</param>
    protected override void Execute(List<InputEntity> entities)
    {
        var playerEntity = m_playerGroup.GetSingleEntity();
        foreach (var inputEntity in entities)
        {
            playerEntity.velComp.value = new Vector2(
                inputEntity.inputComp.Dir.x * PlayerSettings.PlayerSpeed, 
                inputEntity.inputComp.Dir.y * PlayerSettings.PlayerSpeed);
        }
    }

    protected override bool Filter(InputEntity entity)
    {
        return true;
    }

    protected override ICollector<InputEntity> GetTrigger(IContext<InputEntity> context)
    {
        return context.CreateCollector(InputMatcher.InputComp);
    }
}
  • 第五步,获取速度组件然后根据速度算位置,并根据位置组件的值更改游戏内具体坐标。
public class MoveSystem : IExecuteSystem
{
    private readonly IGroup<GameEntity> m_group;

    public MoveSystem(Contexts contexts)
    {
        m_group = contexts.game.GetGroup(GameMatcher.AllOf(
            GameMatcher.PosComp,
            GameMatcher.VelComp,
            GameMatcher.ViewComp
            ));
    }

    /// <summary>
    /// 获取移动速度,根据速度更改人物位置
    /// </summary>
    public void Execute()
    {
        //移动
        var dt = Time.deltaTime; 
        foreach(var entity in m_group.GetEntities())
        {
            var posComp = entity.posComp;
            var velComp = entity.velComp;
            float sidewaysRate = 1f;
            if (velComp.value.x != 0 && velComp.value.y != 0)
                sidewaysRate = 0.7f;

            entity.ReplacePosComp(new Vector2(
                posComp.value.x + dt * velComp.value.x * sidewaysRate,
                posComp.value.y + dt * velComp.value.y * sidewaysRate
                ));
            entity.viewComp.view.transform.position = entity.posComp.value;
        }
    }
}
  • 最后一步,清理System
public class InputCleanupSystem : ICleanupSystem
{
    private readonly Contexts m_context;

    public InputCleanupSystem(Contexts contexts)
    {
        m_context = contexts;
    }

    public void Cleanup()
    {
        m_context.input.DestroyAllEntities();
    }
}

  • 至此,恭喜你已经学会了如何用ECS系统写一个Hello World级别的代码了!

One Reply to “ECS(Entitas) For Unity #1”

LEAVE A COMMENT