主页
文章
分类
系列
标签
ECS
发布于: 2021-5-19   更新于: 2021-8-10   收录于: gameplay
文章字数: 6591   阅读时间: 14 分钟  

ECS

ECS 框架

ECS 架构看起来就是这样子的。先有个 World,它是系统(System)和实体 (Entity) 的集合。而实体就是一个 ID,这个 ID 对应了组件 (Component) 的集合。
组件用来存储游戏状态并且没有任何的行为,System 有行为但是没有状态。组件没有函数而系统没有任何字段。

不同的系统关注不同的组件,所关注的这些组件组合成为一个组件元组,系统遍历元组集合并在其状态上执行一些操作(系统只有行为没有状态,组件只有状态没有行为),每个系统在运行时,不知道也不关心这些实体是什么,它们只是在实体相关组件的子集上执行操作而已。

World 存储了一个所有 System 的集合,和一个所有实体的哈希表。表键是实体的 ID,ID 是个 32 位无符号整形数,用来在实体管理器(Entity Array)上唯一标识这个实体。另一方面,每个实体也都存了这个实体 ID 和资源句柄(resource handle),后者是个可选字段,指向了实体对应的 Asset 资源,资源定义了实体。

Entity

实体对应应用中的具体对象(比如游戏世界中的 Actor,PlayerController,Inventory 等),它是一个组合了不同 Component 的容器,通过增加、替换或删除 Component 来改变实体中的数据,同时 Entitias 具有相应的事件,可以知道是否添加、替换或删除了组件。

EntityID

ID 组成:ContextID(4 bits) –>|<– MainType(4 bits) –>|<– SubType(8 bits) –>|<– sequence(16 bits)

  • Context ID 将 Context ID 编入 Entity ID 是因为在 Entitas 框架中,Entity 是从属于 Context 的,我们对 Entity 生命周期的管理需要先获取到其所属的 Context。
  • MainType 目前包含下面几个大类
    1. Replicated
    2. ServerOnly
    3. ClientOnly
    4. Temp
    5. Ghost
  • SubType(EntityType or Archetype) 标识 Entity 的类型
  • sequence 同一个 SortID 下的唯一编号

EntityManager

负责管理 Entity 的生命期。一个 world 中对应一个 EntityManager。

EntityFactory

负责管理 EntityProxy,维护了 Entity 和 EntityProxy 的对应关系。

EntityArchetypeProcessor

封装 Entity 的初始化/销毁的整个过程。每一种 Entity 类型对应一个 EntityArchetypeProcessor。

创建/销毁

一个 Entity 必须由 context 负责其生命期,不能直接使用 new 实例化一个 Entity 实例,需要通过 context.CreateEntity() 来创建。销毁一个 Entity 实例的正确姿势是调用 entity.Destroy(),通过事件机制最终会调用到 context.onDestroyEntity() 来启动 Entity 的销毁流程。Destroy 一个 Entity 并不会真的销毁,而是放入到了一个对象池中,这是性能优化和避免 GC 的一个设计。

事件

Entity 中可被订阅的事件包括:

  • OnComponentAdded: 当增加一个 Component 时触发调用。
  • OnComponentRemoved:当移除一个 Component 时触发调用。
  • OnComponentReplaced:当一个 Component 被替换(同类型替换)时触发调用。
  • OnEntityReleased:执行 Release 操作后,引用计数变为 0 时触发调用。
  • OnDestroyEntity:执行 entity.Destroy() 时触发调用。

当 Entity 实例被 context 销毁时,该 Entity 所有的 event handlers 都会被移除。

属性/成员变量

  • totalComponents:entity 可以包含 Component 的最大数目,由 context 在初始化 entity 时传入。该值决定了 IComponent[] _components 数组的大小。
  • creationIndex:entity 的唯一索引,也是 EntityEqualityComparer.GetHashCode 的返回值,context 创建 Entity 时设置。context 中也会以该索引值作为 key 维护一个字典(Dictionary<int, Entity> _entitiesLookup),方便根据索引值快速查询 Entity 实例。注意只能保证同一个 context 下 Entity 的该值是唯一的,为了保证整个应用该值的全局唯一,我们可以给每个 context 分配不同的索引值区间段。
  • isEnabled:entity 的状态,已经 destroyed 的 entity 该值为 false。
  • Stack[] componentPools:用于已回收 component 的重用,由 context 在创建 entity 时设置。也是基于性能和避免 GC 的一个设计。
  • contextInfo:包含 context 的一些基本信息(名称、component 名称等),context 创建 entity 时设置,主要用于提供更明确的错误日志。
  • _components_components 数组长度固定为 _totalComponents,数组下标为 component 类型的 Index 值
  • _componentsCache_componentsCache 是 entity 实际包含的 component 列表
  • _componentIndicesCache_componentIndicesCache 是 entity 实际包含的 component 类型 index 列表

后面这两个 cache 变量都是通过前面的 _components 变量重建出来的。

Component

组件负责存储数据

Component 相关操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 增加一个 component。entity 如果处于非 Enable 状态(即 destroyed)将抛出异常。同一个 index(每一种 component 类型对应一个唯一的 index)下只能有一个 component,否则也会抛出异常。增加 component 成功后会触发 event OnComponentAdded 事件调用。
AddComponent(int index, IComponent component)

// 移除指定 index 的 component。内部实现上是调用了 replaceComponent。entity 处于非 enable 状态或者指定 index 的 component 不存在则会抛出异常。移除 component 成功后触发 OnComponentRemoved 调用,最后会重置该 component 并放入回收重用对象池中。
RemoveComponent(int index)

// 将指定 index 的 component 替换掉,如果指定 index 原来没有 component,则等同于 AddComponent。执行成功后将触发 OnComponentReplaced 事件调用。
ReplaceComponent(int index, IComponent component)

// 查询:GetComponent,GetComponents,GetComponentIndices,HasComponent,HasComponents,HasAnyComponent。这些查询类函数均不会触发异常。
// 创建:优先使用回收复用对象池中的对象,如果没有可复用的才会新创建。注意,这里仅仅是创建 component,并不会加到 entity 的 component 集合中,这是因为新创建的 component 往往还需要做额外的初始化工作,初始化完成后再调用 AddComponent 增加到 entity 中。

Component 的几个设计原则

  1. 对一个 Component 的修改尽量收敛到一个 System 中。如果一个 Component 会被多个 System 修改,这往往意味着 Component 划分得不合理,可以考虑拆分。
  2. 同一类数据尽量放到同一个 Component 中。如果 ComponantA 和 ComponentB 的修改只在同一个 System 中修改,而且其数据也存在相关性,可以考虑合并成一个 Component。

Matcher

Matcher 用于描述哪些 entities 是我们关心的,为 Group 提供查询 entities 的能力。

  • AllOf:包含了所有指定 components 的 entities。
  • AnyOf:包含了指定 components 中任何一个即可。
  • NoneOf:不能包含指定 components 中的任何一个。

这些表达式可以组合使用,特别需要注意的是 NoneOf 不要单独使用,因为这可能返回一个巨大的 entities 列表。
例如:var _botEntityMatcher = Matcher.AllOf(ComponentIndex<Game, BotTagComponent>.value);

Group

在 ECS 框架中,System 会关心那些拥有指定 components 的 entities,由于 context 管理了所有的 entities,通过遍历是可以获取到 system 关心的 entities 的,但是这样效率非常低。为了解决这个问题,引入了 Group 的概念。
Group 是一个包含了满足一定条件的 entities 容器,Matcher 决定哪些 entities 可以加到 group 中,group 中的 entities 列表会实时更新。
context 内部管理了一个可重用的 group 列表,相同的 matcher 将返回相同的 group,因此频繁访问含有相同 matcher 的 group 不会有额外的开销。

事件

  • OnEntityAdded:当 entity 添加到 group 中时触发调用。
  • OnEntityRemoved:当 entity 从 group 中移除时触发调用。
  • OnEntityUpdated:当 group 中的 entity 被更新时(即 entity 的一个 component 被替换时)触发调用。

属性/成员变量/成员函数

  • HashSet _entities: group 中保存的 entities。
  • Entity[] _entitiesCache: 以数组形式返回 group 中的 entities,根据上面的 _entities 变量动态生成。
  • Entity _singleEntityCache:
  • HandleEntitySilently: 静默处理 entity,不会触发事件回调,不会触发异常。如果满足 matcher 条件,则将 entity 添加到 group 列表中(内部调用 addEntitySilently,如果已经存在则什么也不做),如果不满足,则从 group 列表中移除(内部调用 removeEntitySilently,如果不存在则什么也不做)。
  • HandleEntity: 与 HandleEntitySilently 对应,内部调用 addEntity 和 removeEntity。在成功执行后会触发事件调用 (OnEntityAdded,OnEntityRemoved)。
  • UpdateEntity:在该函数中会依次触发 OnEntityRemoved,OnEntityAdded,OnEntityUpdated 事件调用。 内部实现并不会真的移除后再增加 component,而是先模拟触发 remove component 事件(此时 component 是旧值),然后再设置 component 新值,最后再触发 add component 事件。
  • GetSingleEntity:如果 group 为空,返回 null,如果 entity 个数大于 1 则抛出异常,只有当 entity 个数为 1 时才能正确返回这个唯一的 entity 实例。

Collector

根据指定的 GroupEvent(Add,Remove,AddOrRemove)监测 groups(这些 groups 必须在同一个 context 下)中发生变化的 entities,处理完后要调用 ClearCollectedEntities 清理掉收集到的 entities。
需要注意的是,当我们监测 group.Remove,一个 entity 被收集到了 collector,稍后如果这个 entity 又满足 group 的 matcher 条件重新加回到 group 中,此时这个 entity 依然会保留在 collector 中。
collector 可以被激活和去激活。内部实现其实就是根据传入的 group.event 去订阅 group 的 OnEntityAdded、OnEntityRemoved 事件。

  • HashSet collectedEntities:存储收集到的 entities,处理完后需要手工清理该集合的数据。
  • GroupChanged _addEntityCache

System

在 Entitas 中定义了几种类型的 System

接口继承关系

1
ISystem <--- ICleanupSystem ISystem <--- IExecuteSystem <--- IReactiveSystem ISystem <--- IInitializeSystem ISystem <--- ITearDownSystem
  • InitializeSystem 仅执行一次的系统,用于实现整个应用的初始化逻辑(Initialize 函数)。
  • CleanupSystem 定期执行的系统,是在所有的 ExecuteSystem 执行完毕后执行(Cleanup 函数)。
  • TearDownSystem 仅执行一次的系统,用于实现整个应用的结束逻辑(TearDown 函数)。
  • ExecuteSystem 定期执行的系统,每次 tick 时调用(Execute 函数)。内部是存储了一个 matcher 对象,根据 matcher 从 context 中获取满足条件的 entities,然后遍历每个 entity 执行操作(纯虚函数,子类负责实现具体逻辑)。
  • ReactiveSystem 变化时触发执行的系统。内部存储了一个 monitor 对象列表,Activate() 函数激活所有 monitor,开始监控 entity 的变化。Deactivate() 函数去激活所有 monitor,停止监控 entity 的变化(此时 monitor 关联的 collector 收集的 entities 数量一直为 0)。Execute() 函数会在 tick 时定期执行,如果 monitor 关联的 collecor 收集到了变化的 entities,则遍历这些 entities 执行 monitor 指定的操作(_processor 委托)。
  • Composing systems 将前面介绍的 system 类型(InitializeSystem, CleanupSystem, TearDownSystem, ExecuteSystem(含ReactiveSystem))组合在一起使用。 内部是记录了四个 system 集合,按照加入集合的顺序执行。注意,集合中的 system 可以内嵌其他 composing systems 的,从 ActivateReactiveSystem() 可以看出来。
  • System的使用 System 在 Entitas 中有两种处理数据变化的方式,一种是响应式的,一种是轮询式的。响应式处理需要使用继承自 ReactiveSystem 的 system,虽然在底层它还是会去轮询,但是在使用层面上它只是处理发生了变化的 component。而轮询式的则要求 system 继承自 IExecuteSystem,它会在每帧去调用一次 Execute 方法。

对于不同功能的 component 应该合理的去选择它对应的 system,绝大多数 component 都应该是对应 ReactiveSystem。因为响应式会比较高效,只有当系统中 entity 上的 component 发生了 Add/Remove 操作,或者 component 中的数据发生了变化后才会执行相关的操作。

Context

一个单例,管理应用中所有的context。context 负责管理 entities 和 groups 的生命期。一个应用中可以有多个 context。

事件

  • OnEntityCreated
  • OnEntityWillBeDestroyed
  • OnEntityDestroyed
  • OnGroupCreated

属性/变量

  • Stack[] componentPools 数组下标是 component-index,用于 component 对象的重用,同时避免了自动 GC。
  • **HashSet _entities **:entity 实例集合。
  • **Stack _reusableEntities **:可被重用的 entities 集合。
  • HashSet _retainedEntities:返回当前还被其他对象(比如 Group,Collector,ReactiveSystem 等)retained 的 entities 集合。
  • Dictionary<string, IEntityIndex> _entityIndices
  • Dictionary<IMatcher, IGroup> _groups:以 matcher 作为 key 缓存所有创建过的 group。
  • List[] _groupsForIndex:数组下标为 component-index,用于缓存与指定 component-index 相关的 group 列表。
  • IGroup[] _groupForSingle:数组下标为 component-index,用于缓存 unique-component 对应的 group。
  • Dictionary<int, Entity> _entitiesLookup:以 entity 的 creation-index 作为 key 缓存的 entity 实例集合。
  • int _creationIndex:每创建一个 entity 该值加 1.

函数

  • CreateEntity 创建一个新的 entity。
    1. 优先复用 reusableEntities 集合中的实例,并调用 entity.Reactivate() 来重新激活 entity 和重新赋值 creationIndex。
    2. 放入 _entities 集合
    3. 订阅 entity 事件
    4. 触发事件 OnEntityCreated
  • DestroyEntity 销毁指定 entity,移除 entity 的所有 component 实例并放入重用池中。注意不要直接调用该函数,而是应该使用 entity.Destroy() 来销毁 entity。
    1. 从 _entities 集合中移除
    2. 触发调用 OnEntityWillBeDestroyed
    3. entity.InternalDestroy:移除所有 component,置 _isEnabled 为 false,重置除 OnEntityReleased 以外的其他事件订阅。 _
    4. 触发调用 OnEntityDestroyed
    5. 如果还有其他对象引用了该 entity,则只是简单减少引用计数,并放入 retainedEntities 集合
    6. 如果没有其他对象引用该 entity,则放入 _reusableEntities 集合
  • IGroup GetGroup(IMatcher matcher) 根据指定的 matcher 返回 group 实例。如果是第一次获取指定 matcher 的 group,则按下面步骤创建
    1. 创建 group 实例
    2. 遍历 context 下的 entities 集合,将所有满足 matcher 条件的加入到 group 中
    3. group 实例放入到 groups 集合
    4. group 实例放入 _groupsForIndex 集合
    5. 触发调用 OnGroupCreated
  • UniqueComponent相关的一组函数 GetSingleEntity GetSingleEntty GetUnique GetUniqueComponent AddUnique ModifyUnique ModifyUniqueComponent
  • updateGroupsComponentAddedOrRemoved 所有 entity 实例的 OnComponentAdded 和 OnComponentRemoved 事件回调的时候都会调用到该函数。根据 component-index 从 _groupsForIndex 集合中直接找到与此 component 相关的 groups,然后遍历这些 group 以决定 entity 是否应该从 group 中移除或添加。

ContextAttribute

context 属性标签类,用于标记 component 属于哪个 context。

ContextInfo

每一个 context 对应一个 contextInfo 实例,存储了 context 的名称、context 下所有 component 类型的名称和类型信息。另外,提供了一个根据 component 类型信息查询其 Index 的接口,这个 Index 是 Component 的唯一索引值,在 Entitas 中有大量使用。

ComponentIndex

以模板类实现,可以快速根据 component 类型查询到其 Index。第一次查询的时候是调用 ContextInfo 的查询接口,之后就直接使用缓存值。注意:同一个 Component 是可以出现在多个 context 下的,在不同 context 下的 component-index 之间没有任何关系。

开发原则

  • ECS 基于数据编程 数据和行为充分解耦,数据驱动一切,这个依赖应该分为两个方向来看,第一是变化的数据,它造成了事件的发生;第二是非变化的数据,它提供了 Update 中逻辑执行的数据。ECS 中只能包含 entity、component、system,以及其它辅助的类。 忘却继承多态,写出更符合 ECS style 的代码。
  • ECS 与外部实现解耦 ECS 与外部交互时遵从面向接口编程的原则:从函数调用层面讲不要在 ECS 系统中直接调用外部对象的函数,因为这样必然要求 component 持有一个具体的对象,导致 ECS 引用了外部的类定义,此时 ECS 应该是持有一个接口的实现对象。从数据层面讲 ECS 中的类型不依赖于任何第三方定义。比如表示位置的 Vector3,它应该是 ECS 内部定义的,而不应该依赖于外部定义的类型(比如 Unity 中的 Vector3)。 尽量使用事件机制来做到更好的解耦。在事件机制基础上,上层功能依赖底层功能,反之则不行。
  • 外部实现(Service层,View层)可以使用 ECS 的方法、修改 component 的数据,但是 ECS 内部不能直接访问外部实现,只能通过接口对象访问 Service 层或者通过事件机制通知 View 层。
  • component 只存储游戏状态并且没有任何行为(简单理解就是没有任何行为类函数,但可以有一些用来方便地访问内部状态的helper函数)
  • system 有行为但是没有状态(简单理解就是没有任何游戏状态类字段,但可以有一些非状态类字段,避免每帧执行的时候都重新获取)。
  • system 不关心 entity 到底是什么,它只关心是否包含某个 component 集合。
  • 一个 component 可以被多个 system 依赖,但是 component 的状态改变必须尽量限制在单个 system 内。

ECS

Gameplay 与 unity 解耦

  • EntityProxy,ComponentProxy:服务端,客户端各自实现
  • service:服务端,客户端各自实现

Other

主要模式:Utility 函数,单例,推迟
在 ECS 框架内,任何需要进行预表现、或者基于玩家的输入模拟结果的 System,都不会使用 Update,而是用 UpdateFixed。UpdateFixed 会在每个固定的命令帧调用。
ECS 开发准则:组件没有函数;System 没有状态;共享代码要放到 Utils 里;组件里复杂的副作用要通过队列的方式推迟处理,尤其是单例组件;System 不能调用其他 System 的函数,即使是我们自己的取名 System 也不行 全局状态:SingletonEntity,SingletonComponent
共享行为:Utility函数

开发用例

  • 定义 Component 类
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[Game]
public class PositionComponent : IComponent
{
    public int x;
    public int y;

    public void SetValue(int nx, int ny)
    {
        x = nx;
        y = ny;
    }
}

// 1. component 只有数据,没有行为。但是可以定义 helper accessor;
// 2. 使用属性标签标注 component 属于哪个 context,如果未标记则属于 Default context;
  • 定义 System 类
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class MoveSystem : IExecuteSystem
{
    public void Execute()
    {
        var entities = Context<Default>.AllOf<PositionComponent, VelocityComponent>().GetEntities();
        foreach (var e in entities)
        {
            var vel = e.Get<VelocityComponent>();
            var pos = e.Modify<PositionComponent>();
            pos.x += vel.x;
            pos.y += vel.y;
        }
    }
}

// 1. system 只有行为,没有数据;
// 2. 使用属性标签标注 system 属于哪个 Feature,如果未标记则属于 UnnamedFeature
  • 创建 entity