一个无框架的ECS实现

咱们先从一切的起源说起——只要是游戏,大多都会出现这样一个Enity-Manager系统。因为游戏本质就是大量实体行为(Enity)以及他们之间的交互(Manager)。

640?wx_fmt=png&wxfrom=5&wx_lazy=1

但很显然,一个游戏不可能只有两个类。随着逻辑的膨胀,出于各种原因都会进行逻辑的拆分。而比起继承,复合的灵活性更强,所以最后基本都会变成这样一个状态:


640?wx_fmt=jpeg&wxfrom=5&wx_lazy=1

其实一般的游戏到这个状态就可以了,偶尔也会有一些继承关系穿插其中。但在实际的逻辑编写过程中,经常会出现一些恼人的两择问题:


同一段逻辑,我到底是应该放在Component,还是Manager里呢?


因为这两个东西是相互依赖的,放哪儿其实都一样,而到底放那里才合适往往并不是那么容易判断的。因此过一阵子,即使是代码的编写者也不记得到底是放在Component还是Manager里了,得两边都找一次才可以。假如,Component和Manager并不是简单的两级关系,而是多级,就更好玩儿了。


通常,与多个Component相关的逻辑代码,放在Manager更合适。但是假如把只和一个Component相关的代码放在Manager,也只是看起来有点像静态方法,有点蠢,但并没有大碍。所以,经过权衡之后,开发者决定把Component的所有逻辑全部移动到对应的Manager上,以消除这种二择难题,这就产生了System:


640?wx_fmt=jpeg 

写作Component,实为Data


这样移动逻辑之后,由于Data(Component)的依赖关系变得很简单,开发者又发现,其实Data胡乱拆分也没有关系,System也可以不受限制根据需要操作多个Data的数据,于是就变成了这样:


640?wx_fmt=jpeg 

这就是ECS(Entity-Component-System)


实际上,这只是一个正常的架构优化,最主要的“特色”是将Component的逻辑全部移动到了System上,其他部分都是顺理成章的结果。


基本特征如下:


  • System是唯一承载逻辑的地方

  • Data(阿呸,是Component)不允许有逻辑,对外依赖就更不能有了

  • Entity首先是一个Data,但本质上是个多个Data的桥梁,用于标识它们属于同一物体。在不同的数据结构下,它甚至可以仅仅是一个int。

  • 在允许的情况下,System并不直接依赖Entity,因为并不需要。System直接依赖Data也有利于清晰判断依赖关系。

  • 至于System之间的相互依赖关系,和以前Component,Manager之间的相互依赖还是一样的。该怎么处理就怎么处理,这是ECS之外的问题。

一些意外的收获


  • 由于Data被拆散了,不容易遇到读入整个对象却只使用其中一个属性的情况(比如我们常见的读入一个Vector3却只使用一个x),有利于Cache(不过一般不会抠到这个份儿上)

  • 由于Data被拆散了,状态同步的功能可以直接放在Data上,同步逻辑会变得简单。

  • 由于Data和System之间依赖关系明确,交叉较少,对线程安全非常友好。在摩尔定律单核失效的现在,多线程会变得越来越重要


下面是个示例,玩家控制的两个球会吞吃屏幕中的点变大,球之间会相互推挤保证不重叠,包含一个吞食运动动画。


640?wx_fmt=gif


首先是Component,也就是些纯数据类。数据类固定包含一个Entity的链接让它们能联系在一起。


640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

Entity部分,这里并没有维护Component数组,而是以“写死”的方式把固定的Component创建出来并保存在字段里。因为背后并没有框架,并不需要提供框架需要的数据。而即使背后有框架,为了性能通常也会像这样把每个Component取出来“写死”放在一个固定的地方,其实也没啥太大的区别。


这样做的缺陷是无法动态增加Component,但是在项目逻辑代码内,需要动态Component的情况又有多少呢?真需要动态Component的时候(比如Buff),再加一个专门的数组管理也不迟。


640?wx_fmt=png


System部分其实近似于静态类,仅仅保留一个Root对象的链接以避免出现单例。而整个系统中,也只有System才有权限访问Root对象。


目前所有的数据列表都保存在GameWorld这个Root对象中,通过Root对象也可以访问到其他的System。


可以看到,下面这些类只有EatSystem直接依赖了Entity,那是因为它涉及到了Entity本身的增删。其他的System都避免了对具体Entity的依赖,而只依赖零散的Component。

虽然看上去有点蠢,但这样在Entity拥有多个版本的时候,System并不需要关心自己操作的具体是哪一个,也就是Entity实际上拥有了“无限的多态特性”。


640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png


这里要专门提下这个EatingSystem。它对应的是特殊的Component,和Entity无关,是在小球被吃掉时临时创建并管理它被吃掉的过程动画,操作的对象也仅仅是GameObjectComponent ,在它被创建之后,原本的Entity的生命周期其实已经结束了。

Entity唯一的作用是连接相关的Component,如果你仅仅关心它的一部分内容,就只需要引用那部分内容。GameObjectComponent就是那个小球的一张皮,我们不需要小球身上的其他逻辑,借用这张皮播一个死亡动画就可以了。


另外EatingSystem对应的EatingComponent,本身也没有对应的Entity,因为它并不需要和其他的Component连接起来。


时刻记住,在ECS里,System直接相关的是Component,而非Entity。没有什么比Entity的地位更低了。


640?wx_fmt=png

640?wx_fmt=png


这是这个系统如何和Unity的可视部分连接的。因为System不能保持状态,Unity的对象是存在专门的Component里的。除非为了接受事件,尽量不要往Untity的GameObject上添加MonoBehaviour脚本。除了性能上的考虑(Update涉及到反射),不需要加的东西干嘛非要加上去呢。


640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png


最后是这个EntitySystem。它的逻辑都是最基本的增删Entity的过程。本来按道理System应该尽量少访问Entity,但Entity总得有个人管理才对啊。本来这段是放在GameWorld里的,犹豫了下还是单独列成了System,算是个特殊的System吧。而它的依赖关系也是最多的。


640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png


最后就是GameWorld部分了。首先它是一个MonoBehaviour,因为Unity程序的入口必须是MonoBehaviour。它的作用就是保存游戏里的全部对象,因为它们总得有一个地方保存。在它的Update方法里,决定不同数据的遍历逻辑,以及System的执行方式。ECS每个部分基本都很零散,总需要一个地方将它们连接在一起。


Gameworld应该是整个项目中交叉修改最多的一个文件,但也只有这个文件会这样。由于所有System同时也依赖了Gameworld,导致它的可替换性很弱,这也是这个无框架的系统最大的弱点了。


如果希望System可以多项目复用(或者更广范围的单元测试),需要对GameWorld做一些解耦处理,比如使用Event系统让System间通信,以及对数据提供通用化的存储方式,还有个办法是把System对GameWorld依赖的部分接口化。


嘛,需要的时候就做这样的修改就好了,毕竟要功能总有代价。但毕竟也有大量的团队并不需要这样做。耦合低,自然程序就会复杂,复杂就会导致成本,处理不好还会有性能损失和可靠性降低。关键在于,这种问题并不是ECS独有的,任何时候都存在这个权衡问题,没必要在这里讨论。如果要做到对GameWorld的解耦(同时保证可维护性和效率),代码量是肯定要增加的,也会让我这个示例看起来和别人写的没啥区别。


好在要处理的耦合度问题也只有System - GameWorld而已,起码问题被集中了。(此外在Update内我有意试图多写了几种遍历方式,其实并不需要这样,仅作抛砖引玉用)


640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

最后


本文的写作理由是:偶尔看到有人说“ECS只适合大项目”,这是一个对这个观点的反驳。确实某些ECS的“写法”很适合大项目不适合小项目,但这是那个“写法”导致的,和ECS本身其实没啥关系。事实上,在同样的“写法”下,ECS相比非ECS还是有不少优点的,而且代价并不高。


  • ECS的代价,个人认为是“Component无逻辑产生的反直觉会让工程师极端不适应”,“基本废掉了继承的多态特性,导致继承无用”。有一种大众言论是“ECS是反OOP的”,如果把OOP仅仅理解成“封装,继承,多态”,这种说法确实没错,因为多态才是三者最重要的部分,而ECS确实把继承的多态特性毁掉了。但是把“OOP”理解成“面向对象编程”,那“ECS”则依然是面向对象编程的,因为System依然是对象。毁灭了继承的“多态”虽然可惜,但“多态”还是有很多其他方式可以实现的(比如说利用策略模式)。ECS本身并不会造成项目的可维护性降低。


  • ECS虽然解决了一些两择难题,但当一段逻辑放在这个System上可以,放另一个System也可以,还是会出现两择难题。放到Util类上是能解决,但用不用Util,依然是个两择难题。


  • ECS还有一个显而易见的问题。由于逻辑和状态在两个不同的类里,状态要能访问就只能全public,这多多少少还是有些隐患。

  • 虽然都要求System不能持有状态,但是假如一个状态数组和System强相关,或者仅允许这个System访问,是否应该允许将数组放在System内以限制可见度?(Component同理),对于ECS的逻辑限制到底需要遵守到什么程度还需要摸索。设计模式是用来解决问题的,而非用来遵守的。总想着遵守,最终反而会解决不了问题。这就是所谓的“过度设计”了。


出处:https://mp.weixin.qq.com/s/GliDdYTZFMecMfQt2zKWpQ


版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。


640?wx_fmt=png

架构文摘

ID:ArchDigest

互联网应用架构丨架构技术丨大型网站丨大数据丨机器学习

640?wx_fmt=jpeg

更多精彩文章,请点击下方:阅读原文

阅读更多

没有更多推荐了,返回首页