从一开始的面向过程编程,再到后来面向对象编程。随着硬件性能的不断改进,用户对软件应用的要求也水涨船高。愈发庞大的应用不再是一个人或几个人的小团队能够完成的呢,分工愈来愈明显,逼迫着编程思想不断进步。
ECS设计理念并不是一个新兴的事物,早就在90年代存在了。但是最近,由于2017年《守望先锋》游戏团队在大会上的分享,再次走入了大众的视野。
ECS,Entity(实体) Component(组件) System(系统),是一个gameplay层面的框架,它是建立在渲染引擎,物理引擎之上的,主要解决的问题是如何建立一个模型来处理游戏对象的更新操作,是对数据集合的操作。
Entity,有点类似于Unity中的Game Object,在ECS中,它仅仅是一个Component的组合,不具有任何代表意义,它的意义就在于对其上的Component进行生命周期管理。
而Component和System则是这个框架的核心,Component是一个只包含数据的组件,将每个可能单独使用的对象属性归纳,里面只有数据没有任何方法,如角色的生命值蓝量就是一个Component,每个Entity是由多个Component组合而成,共享一个生命周期。
而System则是用来处理数据的系统,里面只有方法,没有任何数据,每个System只关注于干好一件事情,它只关心那件事作用于游戏世界里同类的一组对象的每单个个体,或是这类对象的某种特定的交互行为。
而游戏的业务循环就是在调用很多不同的系统,每个系统自己遍历自己关心的对象,只有使用预定义组件部分可以被子系统感知到,这样的系统具有很强的内聚性。
ECS的设计就是为了管理复杂度,其指导方法就是:Component是纯数据组合,没有任何操作数据的方法;而System是纯方法组合,它没有自己的内部状态,它要么做成无副作用的纯函数,根据它所见到的对象Component组合计算出某种结果;要么用来更新特定Component状态。
在System之间也不用相互调用(减少相互耦合),是由游戏外部框架来驱动若干System的。这样一来每个System都可以独立开发,它只需要遍历框架提供给他的组件集合,做出正确的处理,更新组件状态就够了。而编写Gamplay的人只需要清楚每个System到底做了什么,操作本身对哪些Component产生影响,正确书写System的更新次序就可以了。这样一来容易管理复杂度,二来哥并行处理留下了优化空间(是不是感觉和Unity3D有种莫名的相似感?实际上Unity中大量地方采用了ECS设计思想)。
由于System只是对定义好的Component状态的加工过程,那么就会有多个System中处理同一个问题,涉及到相同的Component的情况。当这个问题只相关到一个Entity时,还可以设计出一个System逐个把结果计算出来存为Component的状态;但是当这个问题涉及到多个Entity时候,或者这个行为并不想额外修改Component的状态,保持无副作用。
这个时候就需要引入Utlity函数概念,Utility函数是共享给不同的System调用。为了降低系统复杂度,就要求函数是无副作用的,或是仅仅在很少的地方调用;如果副作用实在存在,又会在很多System中触发,那么就先将行为发生时需要的状态保存起来,放在一个队列里,由一个单独的System在独立环节来处理,推迟到当前帧末尾或下一帧开头来做。
相比于传统的OOP1,ECS在写法上要复杂很多,一个对象可以集中的数据来用多个Component来管理,还要额外的System来处理逻辑。但是,ECS它做到让设计分离了,由此的影响如下:
如果有一个非常复杂的对象,许多人的工作都和这个对象有牵连,当A在进行逻辑处理时,他不得不传整个对象,还要考虑修改对其他人的影响;但是拆离后,A可以把自己的数据封在特殊的Component里,用自己的System处理,减少出问题概率(但是这样会增加代码复杂度);
组合优于继承2,这是设计层面上的原则,而ECS的Entity则是Component的组合,提高了复用性,也方便我们只关注处理对下对象的某个局部;且当我们对某个功能进行拓展时,几乎不会影响到其它功能模块,因为每个部分都是几乎不关联的;
ECS的初衷就是为解决预测和回滚的,因为数据和状态都存储在Component里面,因此记录关键帧的数据和状态非常方便,这就使得实现预测和回滚容易许多;
同一套逻辑处理系统,加了表现组件就有了表现,可以放在客户端,不加的话就是纯逻辑,放在服务端确认客户端回传的数据。一套代码又能做服务器又能做客户端;
ECS这种面向数据的方式,使得内存排列天然紧密,非常适合现代CPU的缓存机制,极大增加了CPU的缓存命中率3,大幅提升了性能。
ECS在处理大批量数据上有明显的优势,但是在处理小数据上如UI层面,网络层面上等就不太适合使用。而且Component本身不知道哪些System关注它,System也不知道什么时候关注的Component发生改变,即无法做到自驱动,必须有外部的东西来驱动这些System去工作,其实还需要许多Utility来辅助工作。
参考文章:
OOP, Object Oriented Programming,面向对象的编程;OOD,面向对象的设计;OOA,面向对象的分析; ↩︎
组合优于继承,将不变的部分使用继承以方便复用,将多变的部分用组合以方便拓展; ↩︎
当CPU到缓存中去寻找数据时,发生找不到的情况,就是未命中;简单了解缓存命中率,了解更多关于内存和缓存的知识; ↩︎