ECS近年来已然成为游戏开发中比较热门的一种架构模式,最近被大家所熟识并热烈讨论,还是源于GDC2017,《守望先锋》针对它们的ECS架构进行的一次技术分享。针对FPS,MOBA这类的竞技游戏,ECS架构有着得天独厚的优势。下面我们先简单地介绍一下什么是ECS。
- E -- Entity 实体,本质上是存放组件的容器
- C -- Component 组件,游戏所需的所有数据结构
- S -- System 系统,根据组件数据处理逻辑状态的管理器
这里需要强调一下,Componet组件只能存放数据,不能实现任何处理状态相关的函数,而System系统不可以自己去记录维护任何状态。说的通俗点,就是组件放数据,系统来处理。这么做的好处,就是为了尽可能地让数据与逻辑进行解耦。与此同时,一个良好的数据结构设计,也会以增加CPU缓存命中的形式来提升性能表现。但我认为,推动ECS流行的更深层次原因,是它的解耦理念,性能上还只是其次。
试想一下,你在开发一款多人实时在线竞技游戏,无论是吃鸡也好,农药也罢,日益堆积的变动需求与越来越多的状态维护肯定是开发人员所需面对的主旋律。也许游戏设计之初,策划会信誓旦旦地跟你说,咱们要整个5v5对战的MOBA游戏,就跟王者农药差不多,不过咱们牛逼的地方在于英雄可以变成汽车,就像变形金刚那样:
假设你用面向对象设计中的”类-继承“模式,肯定会优先设计出一个“英雄”类来,它可能会有不同的技能和武器,当然这需要通过策划来配置,我们则根据这些不同的配置数据,来实例化出一个个独立的英雄。同时,这个类还要包含“变身”的功能,变成汽车后,“人”的一些行为会被限制,比如无法开枪射击,但是移动速度会大大增加,物理耐性提升等等。
当你辛辛苦苦设计好满意的游戏架构后,策划又兴冲冲地跑过来,阐述他的新点子:英雄变成汽车状态后,应该有不同的形态表现,有的2个轮子的、4个轮子的,也有履带式的,最好还能支持没有轮子的概念车!在策划看来,这种仅仅调整一些配置性的东西,实现起来难度应该不大。但是对于开发者来说,移动的动作,车轮的印记特效等等,都需要重新考虑一番,好好地重构。
没过几天,策划又鬼魅般地出现,提出了新的需求:我体验了下游戏原型,只是变身汽车的话游戏画面不够立体,打击感也不强,你看能不能再加个变身飞机的机制,最好能海陆空全方位战斗,就像这样婶儿的:
没办法,还得继续扩展这个英雄类,让它的功能越来越“强大”,即使我们的内心几近崩溃的边缘。
又过了几天,策划再次出现,还未说话,卒。
当然,上面举的例子有很多玩笑的成分在里面,但如果你也是位游戏开发者的话,是不是会产生共鸣,有种似曾相识的感觉?策划的专业程度或许是一方面,但游戏的版本迭代与不断试错都是客观存在的事实,是无法逃避的。随着需求的不断累加,最初的那个“英雄类”会越来越臃肿。倘若你的项目中有多个程序员进行协作开发,那么恭喜你,代码的维护成本会指数级的增加!每个人都必须对英雄的方方面面了若指掌,否则一个不当的改动就可能造成毁灭性的灾难。
这个时候,ECS架构就体现出了它的优势。
与传统的“类-继承”奉行的“我是什么”不同,基于组件化的ECS架构更强调的是“我有什么”,是一种组合优先的编程模式。使用组合而非继承,会使你的代码更具灵活性。还是上面的例子,针对游戏的玩法,我们会构建出一个英雄的Entity实体类,它更像一个空盒子,可以在创建英雄Entity实例的时候赋予它一个ID作为唯一标识。当我们将这个实体放到world下,也许什么也看不见,什么也做不了,这是因为它现在还什么数据都没有。此时就需要根据游戏的需求,来设计出不同的组件填充到这个实体当中。注意,应尽可能地保证组件设计上的扁平化,会让你的模块结构更加清晰,也大大增加了CPU缓存命中的概率。
举个例子,常见的组件包括而不仅限于
- 渲染组件 :英雄的顶点、材质等数据,保证我们能正确地渲染到world中
- 位置组件 :记录着实体在这个world的真实位置
- 特效组件 :不同的时机,可能会需要播放不同的粒子特效以增强视觉感受
此外,根据策划的各种奇葩需求,还可以衍生出不同的功能性组件,本质上都是数据的集合,之后会交由 System 来进行各种状态修改与逻辑计算。比如,想要一个英雄既能变成汽车又能变成飞机,我们可以设计出 Wheel 和 Wing 两个组件,存储数据的同时也表明不同实体的对应功能或身份。当然对应着的是处理该组件的System,一个 FlightSystem 可以去关注那些持有 Wing 的实体。确切点说,FlightSystem 其实只需要关注 Wing 组件就足够了,它不应该关心是哪个实体持有这个组件,只要能修改 Wing 的状态就足矣。
这样,就实现了我们经常说的解耦。
将复杂的游戏拆解成不同的逻辑处理单元 (System) ,而每个逻辑处理单元只关心那些向它注册监听的数据,其他数据一概不管。并且最主要的是,System 是不保存状态的,Component 才是状态的真正持有者。刚开始的时候,也许会很不适应,总想着在 System 里加点什么标识,好方便地进行状态回溯或者复用。这时应该警惕起来,你所设计的 System 职责是否单一,组件持有的数据是否过于复杂。将一个复杂的模块拆解成若干个相对简单的单元,不失为明智的选择。
下面就是我们基于ECS而设计的新的游戏架构。
然而,现实总是残酷的。如果一个游戏真要这么简单,或许ECS也就没什么存在的价值了。仅就《守望先锋》分享所知,它们游戏中光 System 就上百个,并且为了保证 System 不保存状态,在游戏帧更新时,System 执行的时序就有了限制。并且很多情况下,很多System关心的组件只有一个(如输入事件),于是就有了Singleton Component。个人觉得,由于不同游戏的不同特质,某些情况下都很难去严格遵循ECS的架构约束,但毕竟架构是为人服务的,而不仅仅是束缚。在我们深刻理解了ECS的思想后,针对实际需要来做一些变通也是未尝不可的。
就拿我参与开发的一款轻MOBA类的多人对战游戏为例,采用的网络通讯方式是protobuf加状态同步,伤害、状态等判定结果几乎都是放在服务器端来处理,客户端主要就是根据每一帧接收的网络消息来处理相应的数据、更新状态。由于客户端使用的语言是Lua,因此会使用一个table数据结构来保存游戏中注册的 System 实例,在帧循环遍历这个注册表,按照顺序依次执行每个 System 的Update函数。System 会处理自己内部维护的组件池,里面放的是注册进来希望被处理的组件,从而根据这些组件的数据来进行一些逻辑上的操作。
解析protobuf数据后,每条协议消息发来的数据其实就是组件所要更新的,但由于服务器端并没有采用ECS这种设计模式,数据设计上也肯定会有些出入的地方,于是需要客户端来解析转换下。为此,引入了Driver的概念。首先,会有若干个表格来存放我们创建了的不同Entity 实例,每个Entity都会持有一个ID。服务器端下发的消息中总会包含某些实体ID,这样处理不同逻辑的 Driver 就会根据这些ID来找到它所需修改数据的Entity,再从Entity找出相关的 Component 组件,将proto消息里的数据更新给这个组件即可,剩下的工作就交给 System 了。
至此,我们的ECS设计变种成了这个样子:
上述设计中,由于多了一层Driver,并且游戏使用的是状态同步机制,因此帮助 System 分担了很多工作。System 仅仅是批量处理 Component 状态的管理者,每一帧遍历系统组件池里的所有组件 (此时的组件已经是 Driver 更新好数据了的) ,我们也可以根据自己的需要来设定刷新间隔,对于一些不需要在每个帧刷新都执行 Update 函数的 System ,可以降低它们的更新频率从而节约一些性能开销。而 Driver 层面,只是数据的一道“搬运工”,负责更新给 System 能够识别的组件的持有数据。
当所在团队改用ECS后,起初都很不习惯这种新的编程模式,总会不知不觉中切换回“类-继承”的编程思路。但随着项目的推进,ECS所带来的一些优势变得愈发明显。首先,就是降低了团队协作成本。往昔的项目经历中后期时,总会出现某些又臭又长的 God Class ,甚至会出现一些功能重复的模块,同一个功能的函数被实现了两次!这种情境下,代码的维护成本可想而知。而在ECS编程模式下,每名开发人员,只需要关心自己负责的模块即可,System 很好地隔离模块之间的耦合。
ECS也并非尽善尽美,随着系统不断地开发与扩展,会发现难免有时 System 要处理的数据过于复杂,很难只用一个单一组件就能表示,或是之前所说的只有一个 Component 会被 System 监控的Singleton情况十分常见。这就考验开发者的权衡取舍能力:如果一味细化 System 的种类,虽能保证模块与组件数据之间的解耦,但却无形中增加了 System 的维护成本,使得原本比较简单的逻辑变得复杂起来,难以维护。另一种做法是,建立组件之间的关联,可以是组件内部持有另一个组件的指针,也可以是几个组件组合成一个新的组件。但这样做就牺牲掉了组件设计上的扁平化原则。
综上所述,世界上没有两全其美的好事,我们经常要去做决策、去权衡每种做法的利弊。一味刻板地循规蹈矩也是不可取的,越来越多的团队开发衍生出了新的ECS变种。但只要我们真正地理解了ECS的主旨与精髓,不断探索与改进,找到适合自己项目的最佳平衡点,它一定会成为助你高效开发的利器。