Unity手游实战:ECS设计思想和Entitas插件

Unity手游实战:ECS设计思想和Entitas插件

一、ECS设计思想

ECS设计理念并不是一个新兴的事物,早在90年代就存在了。但是走入大众视野则要归功于《守望先锋》这款游戏。2017年的GDC大会上,《守望先锋》团队在大会上分享了《Overwatch Gameplay Architecture and Netcode》,但他们设计的初衷是用来解决预测和回滚的问题。

1、编程思想的演变

对于我们这代程序员来说,接触和学习的时候就已经是面向对象Object普及的时代。很多人只是在打基础的时候接触过C语言的过程编程。现在又出了一种面向数据的编程,所以现在一起来分析这三种编程思想的不同:

比如:现在有一群狗和一群猪,我们要让它们的尾巴摇起来:

  • 面向过程:1、摇(所有狗的尾巴);2、摇(所有猪的尾巴)
  • 面向对象:1、所有狗.(摇尾巴);2、所有猪.(摇尾巴)
  • 面向数据:1、收集所有的尾巴;2、摇(尾巴)

可以看到,面向什么,就重视什么。

  • 面向过程强调的是步骤和过程,所以它只要用过程来解决整体流程就好了。
  • 面向对象强调的是个体,所以它告诉个体,你要做什么。
  • 面向数据强调的是部件(部件是数据的容器),那么我要先收集所有的部件(尾巴),然后一起摇。

2、面向数据的编程

从2017年到现在,ECS在游戏程序员里应该是急速膨胀的话题,有很多优秀的文章都介绍过ECS了。尤其是对于Unity的开发人员而言,除了Unity本身的设计理念相近,面向数据栈的编程也是Unity蓝图计划里的一个部分。用ECS插件,Jobs System Burst编译器等技术内容,来打造一个DOTS的开发理念。

所以说了这么多,ECS究竟是什么?

  • E:Entity 一个不代表任何意义的实体(可以理解为Unity里的一个空的GameObject)
  • C:Component 一个只包含数据的组件(可以理解为Unity的一个自定义组件,里面只有数据,没有任何方法)
  • S:System 一个用来处理数据的系统(可以理解为Unity的一个自定义组件,里面只有方法,没有任何数据)

这里的理解仅仅是从概念上的理解,而不是代码层面的理解,因为Unity的GameObject和Component还是比较重度的继承关系,不适合描述ECS关系的本质。

用上面的例子,写最基础的代码示例对比。

3、OOP示例

定义Dog和Pig类:

处理摇尾巴的过程:

这是我们常规能理解的面向对象编程,当然它是有一定优化空间的,我们可以引入interface来抽象摇尾巴这个动作,那么改良之后长这样:

定义了一个interface,然后让两个动物的类从接口继承。

这个时候处理过程就会变成如下:

这里其实就淡化了狗和猪的本体,只关注它们相近的可以摇尾巴的这个特性。但是它所处理的过程仍然是需要找到对象本身,虽然我们不关注它是猪还是狗,但是我们必须要拿到这个对象才能调用它的方法或者是改变它的属性。

4、OOD示例

再看一下ECS的部分:

首先我们需要一个实体类,这个类真正意义上是一个空对象,只会包含一些常用的组件处理:

这里其实只提供了接口,实现并没有去写,实际的Entity需要对Component进行的管理要复杂一些。这里引入了一个IComponent的组件基类,我们也看一下:

就是一个空的基类,这里为什么要使用class,因为C#语言特性,struct不能继承。interface只关注方法,而我们需要的Component其实是一个数据的集合,所以这里作为演示代码就不写那么复杂的设计,理解概念就好。

这样我们就定义好了Entity和Component的组合关系,接下来实现刚才的例子。因为所有的对象都是一个无意义的Entity,那么我们要标识一个Entity是猪还是狗,直接给它绑定对应的Component就好。(是不是很像Unity的GameObject,绑定Button组件它就是Button,绑定Text组件就是Text)如下:

好了,现在我们能标识这个Entity是什么动物了,但也仅仅如此而已,这个动物有没有尾巴取决于什么?没错,还是组件:

如上图所示,我们再定义尾巴的组件,只有绑定了tail组件的Entity才有尾巴,哪怕它不是动物也有尾巴。

现在知道如何标识Entity,那么接下来如何创建呢?如下:

代码展示了,创建100个对象,前面50个是狗,后面50个是猪,并且它们都有尾巴。(假如这些狗里有泰迪,你可以不用绑定tail组件【手动滑稽】)。

现在E和C都OK了,再看看S长什么样:

瞧,这就是一个摇尾巴的System,简单至极。现在ECS都有了,怎么协同工作?如下:

这里的演示没有考虑性能和设计,只是展示了这个部分的组合工作。前面我们创建了100个Entity,然后用一种方式收集所有的尾巴,交给尾巴的System去摇。(这里的System肯定不是用到一次New一个,只是方便展示)

5、ECS的优势

经过上面两个示例来看,ECS在写法上面要比传统OOP的方式复杂很多,明明一个对象就可以集中包含的数据要多写这么多的Componet来管理,并且System也是多余的,完全可以在类的对象里写完处理逻辑,不是吗?

是的,所以这就是ECS的魅力所在,它让设计分离了。

  • 想象一下你是一个重度的游戏,里面有一个Player对象,对象有非常非常多的数据和逻辑,有很多人的工作都和这个对象有牵连。当A在进行逻辑处理的时候,它不得不把整个Player对象传给对于的函数,对吧?如果它不小心动到了B\C\D\E所维护或者负责的部分,对于A来说没什么代价,但是对于其它“人”来说要怎么去查找和修复Bug?拆离之后,A把自己要的数据封在特殊的Component里,并且用自己的System去处理它们,大大降低了出问题的概率。但是代价就是代码变的复杂,分成了很多个部分。

  • 组合优于继承,这句话相信很多人都听过。这是在设计层面所表述的东西,很多时候我们处理逻辑只需要关注对象的某个局部。比如,你的自行车胎破了,如果你的自行车是方便拆卸的,你会扛着自己车去修还是只拿轮胎去修?

  • 既然是组合关系了,那么热插拔和复用的特性也能用上了,想象一下iPhone手机的电池和诺基亚手机的电池,哪种更方便?再想象一下,如果你手机的耳机是焊死在手机上,而不是现在可以随意插拔的。

  • 有了热插拔,那么扩展也可以提到台面上来了。对某个功能系统进行扩展(不是升级),几乎不会影响到其它的功能模块,也不需要考虑之前的代码逻辑,因为每一个部分都是不关联或者是互相感知不到的。

  • 既然能热插拔,也容易扩展,说明耦合性极低,这不是这些年程序员所追求的极致吗?

  • 除上述提到的优势之外,因为数据和状态都在Componet里,所以对于预测和回滚来说非常容易(记录关键帧的数据和状态),这是游戏开发,特别是网络游戏最垂涎的部分,极大地提高了流畅度和打击感。

  • 组件分离的方式,天然适合游戏开发层面做逻辑和表现分离。特别是战斗部分,加了表现组件就有表现,可以放在客户端,不加就是纯逻辑,放在服务器。怎么样,一套代码又能做服务器,又能做客户端。

  • 这种面相数据的方式,让内存排列天然紧密。非常适合现代CPU的缓存机制,极大增加CPU的缓存命中率,大幅提升性能。所以仅仅是多写了一些代码,带来了这么多的优势,为什么不去用呢?

6、ECS的问题

虽然ECS设计初衷是为了解决预测和回滚,但是现在的游戏(包括Unity的演示和推广)都是推荐用来处理大批量数据的(展示性能优势)。所以在处理小数据的时候,成果并没有那么好。比如,UI层面、网络层面等其实就不太适合使用ECS。

另外使用ECS工作,因为本身是C和S分离架空的(C不会知道哪些S关注它,一个C可能会被很多个S关注,一个S也会关注很多个C,所以当C发生改变的时候,其它关注它的S怎么响应?)无法做到自驱动,必须有东西来驱动这些System去工作,所以其实还需要很多的Utility来辅助工作。

当然这些只是这套思想在实现过程中的问题,既然问题在实现层面那么就肯定有框架能解决,下文我们会讲Entitas,一个基于Unity实现的ECS框架。


二、Entities插件

上文大概讲了ECS的设计思想,有提到优势也有提到劣势,优势是设计层面的,劣势是实现层面的。那么一套好的框架就是要保证如何保持优势的设计,而在实现时规避劣势所带来的问题。

Entitas早于《守望先锋》出现在公众视野,2015年的欧洲Unite大会上,Simon sschmid 分享的演讲《Entity System Architecture with Unity》,2016年的 《ECS Architecture with Unity by Example》。除了这两个演讲之外,其它还有很多的资料都存在作者的GitHub上。

Entitas-CSharp:https://lab.uwa4d.com/lab/5b56081fd7f10a201fd7d40b
想要深入学习的可以跳到这里去查看资料。

同时Entitas也提供了Store版本的插件,核心内容没有区别,主要是提供了额外的代码生成器,辅助生成各种Component代码,传送门戳此

我们的ECS战斗,就是基于这套插件去做的。

1、Entities是什么

用官方的话来描述。Entitas是一个超级快速和轻量级的ECS框架,为Unity特殊设计,并且使用C#语言进行开发。内部的缓存性能和组件快速访问速度都是无与伦比的,并且它还经过了良好的设计来应对垃圾收集。

在前面的教程里我们引入了Entity-Component-System的概念,那么再这个框架下还要再理解一些概念。不过在理解概念之前,先看一张图:

从这张图里可以看到,首先我们有一个Context(可以理解为一个存放当前Entity的池),然后每一个Entity都携带了很多个Component。Component里存放了所需要的Data。再往下可以看到一个Groups的概念,可以理解为根据不同的筛选需求将Entity归属在不同的Groups里,主要是方便查找。

但是Group毕竟是一个被动分组的过程,那么在运行时还需要有动态收集的需求,这个时候要用到两个东西一个叫Matcher(匹配器),一个叫Collector(收集器)。下面可以看一下用法:

Group:

Collector:

看起来这两个东西好似分不清楚用法,但可以这么理解:

Group收集的是当前带有特定组件(Position)的Entity。而Collector收集的是发生了变化的Entity。比如,一个Entity失去了Position组件,或者一个Entity增加了Position。举个例子,比如:我的MoveSystem所关心的是所有MoveComponent的Entity,那么我只要用Group收集就好了。如果一个单位因为中了陷阱或者被束缚、击晕等行为导致被移除了MoveComponent,那么我们就可以使用Collector去收集,然后根据原因作出表现。

2、Entitas里的System

前面展示了Entity的E和C的部分,并且讲了跟它们相关的一些设计,那么接下来我们讲下剩下的System。

Entitas里面一共有5种类型的System,所有的其它类型System都需要继承自它才能正常工作。

  • InitializeSystem
  • ExecuteSystem
  • CleanupSystem
  • TearDownSystem
  • ReactiveSystem

简单做个对比:

InitializeSystem可以理解为OnAwake(),只调用一次;
ExecuteSystem可以理解为Update(),每帧调用;
CleanupSystem理解为LateUpdate(),每帧调用;
TearDownSystem理解为OnDestory(),销毁时调用。

比较难理解的是ReactiveSystem,它其实也是一个ExecuteSystem,但是它们俩的区别就是Collector和Group的区别。

想象一下你有100个单位在野外作战,我们的系统是每帧执行,但是在某一个帧里面只会有很少一部分单位在移动,所以你创建一个ExecuteSystem然后每帧去检查100个单位是否需要移动,性能开销肯定不如创建一个ReactiveSystem来监听需要移动的单位并处理它们。

另外还需要注意的是这些系统是没有内部既定的执行顺序的(不像MonoBehavior保证所有Awake执行完才执行Start),它的执行顺序取决于你将它加入到运行时的顺序(也是也非常坑的地方,很多时候开发者也不能保证几个系统之间谁先执行),所有你最好按照下面这样添加系统:

3、代码生成器

Entity有很多个Component,如果只从类型上去判定或者管理会非常困难。Entitas另一个非常方便开发的地方就是可以帮你自动生成代码。

看个例子:

对自定义的Component打个标签(Game表示是生成在一个命名为Game的Context下),然后从指定的类继承,调用Entitas的代码生成器之后,后面对Entitas的操作可以这样写:

上面展示的是不带数据的Component,下面看看标准的是如何处理:

生成代码之后如下:

代码生成器其实是通过Partial Class的方式给Entity生成了扩展方法,没有数据的Component只生成简单的bool变量,用true和false表示Entity是否拥有某个组件。而有数据的则会额外生成数据的操作方法,比如:Add、Replace、Remove等。这些操作方法会引起Collector的注意,收集并传递给关心它的ReactiveSystem来处理。

要稍微注意一下之前提到的标签,[Game]表示的是Context的命名,比如:这里的Entity也叫GameEntity,这个Position组件只针对GameEntity才有效。假如你还有一个Context叫UI,那么UIEntity是没有这个变量或者函数接口的。除了对Context起效之外,Entitas还提供了很多其它的标签,诸如[Unique]、[Index]等等。这个可以自己去查阅手册理解每个标签含义,这里大致看下代码生成器生成的代码样式(项目实际代码,把x、y、z替换成Vector3了):

3、自闭的Entitas

尽管还是有一些不便之处,但是这个插件已经是比较优秀和高效的了。开发只需要关注设计,苦力的代码生成工作代码生成器已经帮我们搞好了。

但是这一套生态只是针对ECS本身所建立的,系统运作、Entity变化查找、不同的实体池重用等,是一个比较自闭的生态系统。

之前也说了,很多其它部分的设计并不适用于ECS,所以如果我们想要和外部交互,就需要走特殊的途径。怎样得到游戏的输入和驱动需求?怎么把处理的结果告知外部的接受方?如何感知不同的表现层,做出差异化的实现?(比如,将ECS内部的日志输送出来,在Unity的环境里,我们可以使用Debug.Log就可以了,但是如果是布置在服务器上的呢?服务器可能需要把日志发送到日志服务器才能处理。)

如下:

从上面的交互图里可以看出来,从外部定义接口,然后将接口传入到Entitas内部。Entitas内部会统一管理各种传入的接口,并在适当的时机调用接口。Entitas提供了Event的标签,在外部可以监听这些事件,一旦事件就会被外部捕捉到,从而获取内部携带的数据,完成传递。

上图就是一个ECS外部监听内部Position移动的实现。

4、调试

既然是一个Unity的插件,又不基于GameObject,那么调试的时候怎么办?怎么得到可视化的信息?不用急看下面:

第一张图是总览,告诉你ECS系统一共注册了哪些系统,每个系统的性能开销。注意这里是每帧都动态变化的。

第二张图展示了当前都注册了哪些Group,因为Entity的回收跟是否还在被引用有关系,这里可以查看“泄漏”的Entity。

第三张图展示的是单个Entity所含有的Component,以及每个Component里的数据情况。

调试信息是可以用宏开关的,但是在编辑器上还是建议打开便于调试,如果需要测试性能就关掉,毕竟这部分统计也是占用不少资源的。

5、Entitas和Unity ECS

开篇有提到,Entitas是2015年就完成雏形并在Unite大会上分享的,到现在为止是一个稳定的Product环境。而UnityECS是2019才有的正式功能,并且也才刚刚脱离preview阶段,所以在资料和支持程度上会比Entitas差很多。但是Unity ECS毕竟是亲儿子,所以在性能支持和多线程(jobs)上要优于Entitas。

二者之间在实现和开发上还是有较大的差异,但是理念上还是一致的。Entitas要想实现ECS本身的内存排布上的优化,对开发者有较高的要求,至少在写功能的时候能在脑海里想象出来我的数据结构在内存里现在是什么情况,这对于大多数开发来说还是比较难的,所以使用Entitas,基本上就可以放弃这部分的优势了。

本文第二节主要是大致讲解了Entitas的框架,这个插件内容很多,要想真的吃透还要仔细过源码和手册才行,并且很多设计在实际项目里还是不太符合,需要因地制宜。下期我们将继续分享ECS战斗的逻辑与表现分离部分,敬请关注。

封面图来自网络


这是侑虎科技第605篇文章,感谢作者放牛的星星供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/niuxingxing,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!