参考
Overwatch Gameplay Architecture and Netcode
ECS全称Entity-Component-System
,即实体-组件-系统。是一种软件架构模式,主要用于游戏开发。
ECS 遵循组合优于继承的原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。
实体与组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件。通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。
换句话说,每个实体不是由“类型”定义的,而是由与其关联的组件定义的。组件如何与实体相关连,取决于所使用的ECS框架如何设计。
在ECS架构中,组件只保存状态而不具有行为,实体中挂载着组件,最后由系统负责获取相关组件完成对应运算,其核心思想就是对数据与运算的分离。系统只有方法,组件只有成员变量。
Video:Overwatch Gameplay Architecture and Netcode (【青幻译制】GDC讲座系列之三 守望先锋的游戏架构和网络代码)
在这个 2017 年的 GDC 会议上,暴雪的 Timothy Ford 解释了守望先锋如何使用实体组件系统 (ECS) 架构来创建丰富多样的分层游戏
虽然ECS作为一种游戏开发架构并非守望先锋首创,但正是这个GDC的讲座以及守望先锋的大获成功,再加上Unity对它的支持让ECS为广大游戏开发者所熟知。
在传统的OOP编程中,我们会先对游戏对象抽象出一个基类,例如为所有的玩家、怪物、小动物、建筑、资源等场景单元创建一个基类Class Base
。该基类中包含有它们共有的属性,如id、名称、坐标、所在场景等等。
————
由于玩家、怪物、小动物都可以移动,在场景中,它们还要能够知道是否有其它活体出现在自己的视野范围之内,即它们都需要做视野同步。
于是让它们都继承自Class Active
,该类包含视野同步的相关属性,如视野半径、视野内的对象列表
(在传统OOP编程中,类同时具有属性(状态)和函数(行为),为了方便描述,这里暂时将二者统称为"功能")
由于玩家特有背包、装备、战斗等功能;而怪物也有战斗功能,以及AI功能,如巡逻、拉脱、索敌等等(都需要配置相应的属性和编写函数);小动物相比于怪物少了战斗功能,但也有AI功能。
可以看到,玩家和怪物都具有战斗功能,怪物和小动物都具有AI功能,但我们 无法同时满足 把他们的相同功能封装在某一个父类。为任意两者创建一个父类,则两个父类中会有重复代码。如果把战斗和AI都放到Active
中也不合适,因为越上层的类具有的功能应该越具有普遍性,并且"Active"这个命名也与这几个功能无关。
不单单是这样,对象的继承还包含了分类,这种分类还要满足人的直观印象。
最终经过纠结取舍,我们让 玩家 都继承自Class Player
,该类包含背包、装备、战斗等。 Class Player -> Class Active -> Class Base
让 怪物、小动物 都继承自Class Monster
,该类包含战斗、AI等。 Class Monster -> Class Active -> Class Base
无论怎样都不尽人意。但让怪物和小动物都归属于怪物子类,比把玩家和怪物归属于一个不知道叫什么的子类要好一些。
————
然后我们再来聊一下建筑
因为建筑可以建造升级,所以所有类型的建筑都继承自一个Class Building
,该类包含了建筑的等级、升级材料及其数量等属性。
此时Class Building -> Class Base
然后过了一阵,策划告诉你我们打算引入家园对抗系统,处于对抗副本中的建筑可以被攻击,某些建筑还可以发射炮弹。说明这些建筑拥有了战斗和AI功能,由此还需要视野功能。
于是我们需要把Class Building
的父类从Class Base
改为Class Active
。甚至可能还要再为能够战斗和AI的建筑创建一个子类Class CombatBuilding
,因为不是所有建筑都有这种功能。此时Class CombatBuilding -> Class Building -> Class -> Active
这里遇到的问题是,当有另一个父类更满足新的需求时,我们改变了继承结构。所幸Class Base
和Class Active
中的内容不多,替换的工作量不大。然而不是任何时候都有这么幸运
另外,Class CombatBuilding
中又包含了与Class Player
、Class Monster
中重复功能的战斗和AI代码
————
这是一个简单的例子。它描述的让人难受的程度或许在你的接受范围之内,但实际情况往往比这更加糟糕。
我们来总结一下痛点:通过继承联系起来的对象,一旦某个共同功能需要改动,或增加功能,就要调整类的结构。不处于同一条继承线中的对象如果有相同的功能,也无法通过继承的方式复用代码,造成了代码重复。
现在我们尝试使用组合的方式完成上述内容。简单来说是这样的:
在Class Base
中添加一个Map
,并提供Get、Set
方法。所有对象的构造方法中包含了对Component Map
的初始化。所有的状态和功能由组件(Component)保存和负责。
例如移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个对象拥有了MoveComponent组件便可以认为它拥有了移动的能力。
在上述例子中,玩家、怪物、小动物、建筑,不再有继承关系,它们按需Set自己需要的组件
所有的功能都由组件提供和控制,对象本身之间再也不受继承的限制。对象与组件是一个一对多的关系,对象拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。
组合的优势显而易见,而ECS再此之上还多了一个"系统"的概念。ECS规定组件只保存状态(即数据),具体的行为由系统控制。例如处理移动的系统仅仅关心拥有移动能力的对象,它会遍历所有拥有MoveComponent组件的对象,并根据相关的数据(速度、位置、朝向等),更新对象的位置。
通过这个例子我们应该能大致了解到传统OOP的痛点,以及使用ECS来解决的好处,接下来让我们来好好了解一下ECS。
以下内容均基于 GDC会议视频 整理
一个典型的ECS架构是这样的:
ECS 将认为游戏世界仅仅是系统和组件的集合体。而一个实体也只是 组件的集合 所对应的ID。
组件仅仅用于存储游戏状态(即数据),而不具备行为。系统具有行为却不保存游戏状态。或者这样说,组件是没有函数(行为)的,系统是没有成员变量的(数据/状态)。数据和行为分离,每个子类组件都有自己的成员变量,系统利用这些变量来表现行为。
“组件没有函数,系统没有状态” —— 这是ECS的规则
下面是守望先锋 客户端和服务端 分解的系统和组件。(截取自GDC在YouTube发表的原视频。看不清,此时需要一位热心大佬给修复成高清图)
客户端
服务端
系统完全不知道每个实体是什么样的,只关心它的一小部分的组件,然后对这一小部分组件,执行一系列共同的行为。有些实体可能会有30个组件,有些只有2-3个,但是系统不关心这些,只关心它们的行为所需要的那些组件子集。每一个系统在运行的时候,即不知道也不关心这些实体是什么,只是基于与实体相关的一个组件子集来运行。
守望先锋的 实现 基本上看起来如下图所示。
EntityAdmin会调用每个系统的更新函数(每帧的更新),每个系统都会做一些事情
实例
这是一个玩家联网系统。他负责处理所有游戏服务器上的挂机行为,这个系统会遍历所有的联网组件,联网组件是在服务器上对应每个玩家连接的组件,它在一个代表玩家的实体当中。联网组件会有一个输入流和状态,会读取玩家的输入流,确保你在做某一些事情比如按下了一个按键,读取状态是为了确保你通过某种方式对游戏做出了影响。只要有以上的行为,该系统就会把你的挂机时间清零,否则就会通过保存在联网组件里的联网引用,对你发出一个让你行动的警告信息。
为了让这个行为能够运行,这个系统需要处理的实体,必须拥有完整的元组。比如一个AI机器人,它会有状态组件,但是它没有联网组件和输入流组件,所以它不受这个行为(挂机检测)的约束。我们没有必要因为挂机而把AI踢出去。
我们为什么不采用传统的OOP编程,使用组件模式(Template Method,模板方法模式,也叫组件模式)来做这件事呢?让联网组件重写Update函数,来对挂机行为进行跟踪。
因为联网组件实现了多种行为,它不光涉及到挂机检测,还涉及到互相联网的玩家之间的网络消息广播,它保存了你用于确定玩家姓名的状态,它保存了玩家的持久化记录(比如他们所解锁的成就)。所以到底哪种行为需要放到组件的Update函数里呢?那其它的行为又放到哪里呢?
在传统的OOP中,一个类既包含行为(函数)又包含状态(属性),但是联网组件并没有行为,只有状态。从OOP的角度来讲,联网组件不是一个对象,它在不同的时间,对于不同的系统来说,是不同的东西。
从概念上讲,这种对于 行为和状态 的分离有什么优点呢?
想象一下这是你前院里的樱花。这些前院里的树,对于每个人主观上的意义都是不同的,对于你、屋主协会的主席、园丁、鸟、房屋估价师、白蚁。每个观察方都在描述那棵树的状态中看到不同的行为。这棵树是一个主观的存在,不同的观察者会用不同的方式对待它。
玩家实体,或者更具体的说是其中的联网组件,不同的系统对它的处理机制是不同的。我们之前看到的玩家联网系统,将联网组件看作是挂机踢除的目标。而联网辅助系统,将联网组件看成是玩家网络消息广播的目标。在客户端,游戏的界面上会利用联网组件,来生成显示所有玩家姓名的计分板界面。
所以为什么要这样来设计行为和状态呢,因为这样能够更容易的描述一棵树的所有行为。通过主观的感知来划分它的个体行为,而对游戏对象也是如此。
这里讲的有点快,如果整理有误欢迎指正
ECS 遵守"组件没有函数,系统没有状态"这条规则。
但如果我们把规则放宽,让系统具有成员变量,某些功能设计起来其实会方便很多,不需要绕来绕去。
例如输入系统,你可以把输入状态直接保存在输入系统中。任何需要知道某个按键是否按下的系统都可以通过一个指向输入系统的指针来查询。
这个命令系统的职责是将玩家的输入数据填充到PlayerCommand中,并发送至服务器。(g_game->m_inputSystem
表示通过全局访问器 在一个系统中调用另一个系统。输入系统保存了输入状态,所以命令系统需要调用输入系统获取这些数据)
按照原本的规则,我们其实需要创建一个组件来保存输入状态,让命令系统直接调用这个组件获取输入数据。
但在单个组件中保存一个全局输入似乎看起来很傻。你当然也会觉得,只有在出现多个组件实例的时候,才需要建立一个新的组件类型,不然就没有必要编写那些实例化的代码。
而且像之前看到的代码那样,系统通常都是通过一个迭代器来访问这些组件。然而如果只有一个组件,那么对这种组件进行遍历也会有些奇怪。
不管怎样,这一开始是可行的:把这个状态保存在系统中,然后使用一个全局访问器,让一个系统访问另一个系统。
然而慢慢的又发展出了一些新问题。在守望先锋中,"死亡回放"会涉及到两个世界:死亡回放的World和正常游戏的World是两个不同的World。不再只有一个全局的EntityAdmin,而是变成了两个。
系统A没法再通过全局访问器(g_game
)直接访问系统B,因为现在有两个系统A和两个系统B,它们不能共用同一个全局访问器,否则很容易造成状态上的错误。现在只能通过EntityAdmin来访问系统B,这样做很难受(说实话这里我没理解为何会很难受)。
实际上这不是最严重的,我们重新审视了跨系统调用的问题:跨系统的调用 将系统的行为暴露给了其它的系统,导致了职责模糊,以及C++头文件引用导致的编译时长问题,但最为危险的是内部系统的耦合(高耦合会让系统交叉缠绕在一起,结构复杂不便于编写和维护)。
解决方案是 允许在EntityAdmin中,定义仅存在一个实例的组件类型,我们把它称之为单例组件。这种组件存在于一个匿名的实体上,并且一般通过EntityAdmin来直接访问。我们把绝大多数这样的系统状态迁移到了这样的单例中。
值得一提的是,一个单例状态仅仅只被一个系统所访问的情况是非常少的,之后我们得到了这样的决定:当我们在写一个新的系统时,如果它需要访问某些状态,我们就会继续为这个系统创建一个单例来保存那些状态。事实上几乎每次都会有其它的系统也需要那些状态。这真正的解决了之前的架构里存在的内部耦合问题。
使用单例组件实现保存输入状态是这样的:输入系统依然存在,但它不在保存状态。所有的按键信息都从输入系统中移出,保存在这个"输入单例"中,任何想要知道按键是否按下的系统,只要拿到这个组件查询就行。这立刻就消除了讨厌的耦合,并且让我们更加贴近ECS的理念——系统没有状态,组件没有行为。
按键的状态并非行为。而本地的玩家移动系统具有的行为是,利用这个单例来预测玩家的移动,运动状态系统的行为是把这些输入打包上传至服务器以供利用。
如此一来,输入系统已经不再和命令系统存在耦合了。我们也把这个小小的PostBuildPlayerCommand
函数移到了命令系统中,这也是它应有的归属。
我们发现这种单例的模式非常的普遍,实际上守望先锋的组件中有40%是单例组件。
总结:把你想存放在系统中的状态移出放进单例组件中。
如果有一个行为会在多个系统更新(Update
, 每帧更新)中被调用。根据之前那个树的比喻,有时两个系统会对同一种行为感兴趣:屋主协会的主席和你的园丁,他们都想知道春天会有多少树叶从这棵树上掉下来,他们会用得到的结果做不同的事情,就像屋主协会的主席可能会批评你,而园丁会转身回去干活,但行为是一致的。
举例来说,很多地方的代码都会对互相的敌对关系感兴趣,比如实体A是不是实体B的敌人?
敌对关系是由三个可选的组件来决定的:过滤器位(filter bit)、宠物主人和宠物。过滤器位保存的是实体的团队索引,宠物主人组件保存了一个独一无二的键值,用于匹配他的所有对应的宠物,像托比昂的炮台就是当做宠物来处理。如果两个实体都没有过滤器位,那么它们互相不敌对,比如两个门之间没有敌对关系,它们都没有在过滤器位上设置任何团队。
如果双方在同一个队里,那么互相之间不敌对,这是简单的情况。如果它们属于总是保持敌对的队伍,它们就会检查之间是否存在宠物和主人的关系。它解决了这样一个bug,如果你在与所有人都敌对的队伍里生成一个炮台,结果它立刻开始攻击你。
如果你想要检查一个飞行中的炮弹的敌对关系,很简单,你只要去追溯看哪个实体生成的这发炮弹就行了。这个例子里我所讲到的这个函数叫做"CombatUtilityHostileTo"("是否在战斗中敌对"的辅助函数),它以两个实体作为输入,返回双方是否敌对,有非常多的系统都调用了这个函数。
以此为例,我们在处理具有共享行为(会被多个系统调用)的这些辅助函数的时,有一些不同的规则:
总结:对于系统间的共享行为,使用辅助函数来替换系统间的调用。
当用一个共享的辅助函数来替换系统间的函数时,他不会魔术般的就减少了复杂度,因为复杂度主要是由语法和结构带来的,就像很多副作用都隐藏在一个可以公共访问的系统函数中一样,它会有很多副作用隐藏在辅助函数之中,所以如果你在好几个地方调用这个辅助函数,那么整个游戏的更新循环都会造成很大的副作用,它可能不太明显,因为是隐藏在函数调用后的,但依然是很严重的耦合。
在开头所举的例子中,我们了解到了传统OOP的缺陷:通过继承联系起来的对象,一旦某个共同功能需要改动,或增加功能,就要调整类的结构。不处于同一条继承线中的对象如果有相同的功能,也无法通过继承的方式复用代码,造成了代码重复。
这是随着OOP(面向对象)的发展必然会面对的问题,可以说ECS基于组合优于基于继承的原则在一定程度上规避的系统中不必要的耦合,提高了效率,使其有了存在的必要性。
Java之父James Gosling也曾提到"继承"的缺点。这是一篇编写日期为2003年的文章:《Why extends is evil》
作者提到他曾经参加过一个Java用户组的会议,James Gosling(Java的发明者)是会议的主要发言人。在令人难忘的问答环节中,有人问他。“如果你能重新做一次Java,你会改变什么?” 。他回答说:“我会去掉Class”。笑声平息后,他解释说,真正的问题不是Class本身,而是实现继承(extends
继承关系)。接口继承(implements
实现关系)是更好的。你应该尽可能地避免实现继承(extends
)。
高度的"继承"会导致结构及其不灵活,这尤其体现在游戏项目这种 实体繁多且高度集中 的架构上。
而"组合"无论在简单系统还是复杂系统中都游刃有余。
ECS的架构目前使用的非常的多,很多有名的框架设计都或多或少的受到了其影响,有:
U3D的ECS架构:不是指原来的GameObj那套,有专门的插件,有内存优化
UE4的组件设计:采用了特殊的组件实现父子关系
ET框架:消息 + ECS,采用ECS解耦,更注重消息驱动的响应式设计,Entity和Comp的思路也独特:Entity同时是组件,并有父子关系