漫谈Entity-Component-System

本文最后更新于:2023年3月23日 晚上

简介

对于很多人来说,ECS只是一个可以提升性能的架构,但是我觉得ECS更强大的地方在于可以降低代码复杂度。

在游戏项目开发的过程中,一般会使用OOP的设计方式让GameObject处理自身的业务,然后框架去管理GameObject的集合。但是使用OOP的思想进行框架设计的难点在于一开始就要构建出一个清晰类层次结构。而且在开发过程中需要改动类层次结构的可能性非常大,越到开发后期对类层次结构的改动就会越困难。

经过一段时间的开发,总会在某个时间点开始引入多重继承。实现一个又可工作、又易理解、又易维护的多重继承类层次结构的难度通常超过其得益。因此多数游戏工作室禁止或严格限制在类层次结构中使用多重继承。若非要使用多重继承,要求一个类只能多重继承一些 简单且无父类的类(min-in class),例如Shape和Animator。

也就是说在大型游戏项目中,OOP并不适用于框架设计。但是也不用完全抛弃OOP,只是在很大程度上,代码中的类不再具体地对应现实世界中的具体物件,ECS中类的语义变得更加抽象了。

ECS有一个很重要的思想:数据都放在一边,需要的时候就去用,不需要的时候不要动。ECS 的本质就是数据和操作分离。传统OOP思想常常会面临一种情况,A打了B,那么到底是A主动打了B还是B被A打了,这个函数该放在哪里。但是ECS不用纠结这个问题,数据存放到Component种,逻辑直接由System接管。借着这个思想,我们可以大幅度减少函数调用的层次,进而缩短数据流传递的深度。

基本概念

Entity由多个Component组成,Component由数据组成,System由逻辑组成。

Component(组件)

Component是数据的集合,只有变量,没有函数,但可以有getter和setter函数。Component之间不可以直接通信。

1
2
3
struct Component{
//子类将会有大量变量,以供System利用
}

Entity(实体)

Entity用来代表游戏世界中任意类型的游戏对象,宏观上Entity是一个Component实例的集合,且拥有一个全局唯一的EntityID,用于标识Entity本身。

1
2
3
4
5
class Entity{
Int32 ID;
List<Component> components;
//通过观察者模式将自己注册到System可以提升System遍历的速度,因为只需要遍历已经注册的entity
}
Entity需要遵循立即创建和延迟销毁原则,销毁放在帧末执行。因为可能会出现这样的情况:systemA提出要在entityA所在位置创建一个特效,然后systemB认为需要销毁entityA。如果systemB直接销毁了entityA,那么稍后FxSystem就会拿不到entityA的位置导致特效播放失败(你可能会问为什么不直接把entityA的位置记录下来,这样就不会有问题了。这里只是简单举个例子,不要太深究(●'◡'●))。理想的表现效果应该是,播放特效后消失。

System(系统)

System用来制定游戏的运行规则,只有函数,没有变量。System之间的执行顺序需要严格制定。System之间不可以直接通信。

一个 System只关心某一个固定的Component组合,这个组合集合称为tuple。

各个System的Update顺序要根据具体情况设置好,System在Update时都会遍历所有的Entity,如果一个Entity拥有该System的tuple中指定的所有Component实例,则对该Entity进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class System{
public abstract void Update();
}

class ASystem:System{
Tuple tuple;

public override void Update(){
for(Entity entity in World.entitys){
if(entity.components中有tuple指定的所有Component实例){
//do something for Components
}
}
}
}

一个Component会被不同System区别对待,因为每个System用到的数据可能只有其中一部分,且不一定相同。

World(世界)

World代表整个游戏世界,游戏会视情况来创建一个或两个World。通常情况下只有一个,但是守望先锋为了做死亡回放,有两个World,分别是liveGame和replyGame。World下面会包含所有的System实例和Entity实例。

1
2
3
4
5
6
7
8
9
10
class World{
List<System> systems; //所有System
dictionary<Int32, Entity> entitys; //所有Entity,Int32是Entity.ID

//由引擎帧循环驱动
void Update(){
for(System sys in systems)
sys.Update();
}
}
由ECS架构出来的游戏世界就像是一个数据库表,每个Entity对应一行,每个Component对应一列,打了✔代表Entity拥有Component。

Component1 Component2 ... ComponentN
EntityId1
EntityId2
...
EntityIdN

单例Component

在定义一个Component时最好先搞清楚它的数据是System数据还是Entity数据。如果是System的数据,一般设计成单例Component。例如存放玩家键盘输入的 Component ,全局只需要一个,很多 System 都需要去读这个唯一的 Component 中的数据。 单例Component顾名思义就是只有一个实例的Component,它只能用来存储某些System状态。单例Component在整个架构中的占比通常会很高,据说在守望先锋中占比高达40%。其实换一个角度来看,单例Component可以看成是只有一个Component的匿名Entity单例,但可以通过GetSingletonIns接口来直接访问,而不用通过EntityID。

例子

守望先锋种有一个根据输入状态来决定是不是要把长期不产生输入的对象踢下线的AFKSystem,该System需要对象同时具备连接Component、输入Component等,然后AFKSystem遍历所有符合要求的对象,根据最近输入事件产生的时间,把长期没有输入事件的对象通知下线。

设计需要遵循的原则

  1. 设计并不是从Entity开始的,而是应该从System抽象出Component,最后组装到Entity中。
  2. 设计的过程中尽量确保每个System都依赖很多Component去运行,也就是说System和Component并不是一对一的关系,而是一对多的关系。所以xxxCOM不一定有xxxSys,xxxSys不一定有xxxCOM。
    • System和Component的划分很难在一开始就确定好,一般都是在实现的过程中看情况一步一步地去划分System和Component。而且最终划分出来的System和Component一般都是比较抽象的,也就是说通常不会对应现实世界中的具体物件,可以参考下图守望先锋System和Component划分的例子。 20221029195909
  3. System尽量不改变Component的数据。
    • 可以读数据完成的功能就不要写数据来完成。因为写数据会影响到使用了这些数据的模块,如果对于其它模块不熟悉的话,就会产生Bug。如果只是读数据来增加功能的话,即使出Bug也只局限于新功能中,而不会影响其它模块。这样容易管理复杂度,而且给并行处理留下了优化空间。

使用心得

我在一个游戏demo里尝试使用ECS去进行设计,最大的感受是所有游戏逻辑都变得那么的合理,应对改动、扩展也变得那么的轻松。加班变少了,也不再焦虑。在开始使用ECS来架构业务层之前,我对ECS还是存有一丝疑虑的。担心会不会因为规矩太多了,导致有些功能写不出来。中途也确实因为ECS的种种规矩,导致有些功能不好写出来,需要用到一些奇技淫巧,剑走偏锋。但这些技术最终造就了一个可持续维护的、解耦合的、简洁易读的代码系统。据说守望团队在将整个游戏转成ECS之前也不确定ECS是不是真的好使。现在他们说ECS可以管理快速增长的代码复杂性,也是事后诸葛亮。

引擎层的System比较好定义,因为引擎相关层级划分比较明确。但是游戏业务逻辑层可能会出现各种奇奇怪怪的System,因为业务层的需求千变万化,有时没有办法划分出一个对应具体业务的System。例如我曾经在业务层定义过DamageHitSystem、PointForceSys。

推迟技术:不是非常必要马上执行的内容可以推迟到合适的时再执行,这样可以将副作用集中到一处,易于做优化。例如游戏可能会在某个瞬间产生大量的贴花,利用延迟技术可以将这些需要产生的贴花数据保存下来,稍后可以将部分重叠的贴花删除,再依据性能情况分到多个帧中去创建,可以有效平滑性能毛刺。

如果不知道该如何去划分System,而导致System之间一定要相互通信才能完成功能,可以通过将数据放在中的一个队列里延迟处理。比如SystemA在执行Update的时候,需要执行SystemB中的逻辑。但是这个时候还没轮到SystemB执行Update,只能先将需要执行的内容保存到一个地方。但是System本身又没有数据,所以SystemA只好将需要执行的内容保存到单例Component中的一个队列里,等轮到SystemB执行Update的时候再从队列里拿出数据来执行逻辑。

但是System之间通过单例Component有个缺点。如果向单例Component中添加太多需要延迟处理的数据,一旦出现bug就不好查了。因为这类数据是一段时间之前添加进来的,到后面才出问题的话,不好定位是何处、何时、基于什么情况添加进来的。解决方案是给每一条需要延迟处理的数据加上调用堆栈信息、时间戳、一个用于描述为什么添加进来的字符串。

各个System都用到的公共函数可以定义在全局,也可以作为对应System的静态函数,这类函数叫做Utility函数。Utility函数涉及的Component最好尽可能少,不然需要作为参数传进函数Component会很多,导致函数调用不太雅观。Utility函数最好是无副作用的,即不对Component的数据做任何写操作,只读取数据,最后返回计算结果。要改Component的数据的话,也要交给System来改。

函数调用堆栈的层次变浅了,因为逻辑被摊开到各个System,而System之间又禁止直接访问。代码变得扁平化,扁平化意味的函数封装少了,所以阅读、修改、扩展也很轻松。

如果可以把整个游戏世界都抽象成数据,存档/读档功能的实现也变得容易了。存档时只需要将所有Component数据保存下来,读档时只需要将所有Component数据加载进来,然后System照常运行。想想就觉得强大,这就是DOP的魅力。

优点

模式简单

结构清晰

通过组合高度复用。用组合代替继承,可以像拼积木一样将任意Component组装到任意Entity中。

扩展性强。Component和System可以随意增删。因为Component之间不可以直接访问,System之间也不可以直接访问,也就是说Component之间不存在耦合,System之间也不存在耦合。System和Component在设计原则上也不存在耦合。对于System来说,Component只是放在一边的数据,Component提供的数据足够就update,数据不够就不update。所以随时增删任意Component和System都不会导致游戏崩溃报错。

天然与DOP(data-oriented processing)亲和。数据都被统一存放到各种各样的Component中,System直接对这些数据进行处理。函数调用堆栈深度大幅度降低,流程被弱化。

易优化性能。因为数据都被统一存放到Component中,所以如果能够在内存中以合理的方式将所有Component聚合到连续的内存中,这样可以大幅度提升cpu cache命中率。cpu cache命中良好的情况下,Entity的遍历速度可以提升50倍,游戏对象越多,性能提升越明显。ECS的这项特性给大部分人留下了深刻印象,但是大部分人也认为这就是ECS的全部。我觉得可能是被Unity的官方演示带歪的。

易实现多线程。由于System之间不可以直接访问,已经完全解耦,所以理论上可以为每个System分配一个线程来运行。需要注意的是,部分System的执行顺序需要严格制定,为这部分System分配线程时需要注意一下执行先后顺序。

缺点

在充满限制的情况下写代码,有时速度会慢一些。但是习惯之后,后期开发速度会越来越快。

优化

一个entity就是一个ID,所有组成这个entity的component将会被这个ID给标记。因为不用创建entity类,可以降低内存的消耗。如果通过以下方式来组织架构,还可以提升cpu cache命中率。

1
2
3
4
5
6
//数组下标代表entity的ID
ComponentA[] componentAs;
ComponentB[] componentBs;
ComponentC[] componentCs;
ComponentD[] componentDs;
...

参考资料


漫谈Entity-Component-System
https://roudersky.com/posts/1abc5785.html
作者
Rouder
发布于
2020年3月29日
许可协议