原文链接
简介
对于很多人来说,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之间不能够间接通信。
struct Component{ //子类将会有大量变量,以供System利用}
Entity(实体)
Entity用来代表游戏世界中任意类型的游戏对象,宏观上Entity是一个Component实例的汇合,且领有一个全局惟一的EntityID,用于标识Entity自身。
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进行解决。
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实例。
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遍历所有符合要求的对象,依据最近输出事件产生的工夫,把长期没有输出事件的对象告诉下线。
设计须要遵循的准则
- 设计并不是从Entity开始的,而是应该从System形象出Component,最初组装到Entity中。
设计的过程中尽量确保每个System都依赖很多Component去运行,也就是说System和Component并不是一对一的关系,而是一对多的关系。所以xxxCOM不肯定有xxxSys,xxxSys不肯定有xxxCOM。
- System和Component的划分很难在一开始就确定好,个别都是在实现的过程中看状况一步一步地去划分System和Component。而且最终划分进去的System和Component个别都是比拟形象的,也就是说通常不会对应事实世界中的具体物件,能够参考下图守望先锋System和Component划分的例子。
- System和Component的划分很难在一开始就确定好,个别都是在实现的过程中看状况一步一步地去划分System和Component。而且最终划分进去的System和Component个别都是比拟形象的,也就是说通常不会对应事实世界中的具体物件,能够参考下图守望先锋System和Component划分的例子。
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命中率。
//数组下标代表entity的IDComponentA[] componentAs;ComponentB[] componentBs;ComponentC[] componentCs;ComponentD[] componentDs;...
参考资料
- 《守望先锋》架构设计与网络同步 -- GDC2017 精品分享实录
- http://gamadu.com/artemis/
- http://gameprogrammingpattern...
- http://t-machine.org/index.ph...
- http://blog.lmorchard.com/201...
- 浅谈《守望先锋》中的 ECS 构架
小弟才浅,如果本篇文章有任何谬误和倡议,欢送大家留言
感激大家的点赞、珍藏
微信搜「三年游戏人」第一工夫浏览最新内容,获取一份收集多年的书籍包 以及 优质工作内推