Unity手游实战:从0开始SLG——ECS战斗(二)Entitas插件

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

Entitas早于《守望先锋》出现在公众视野,2015年的欧洲Unite大会上,Simon sschmid 分享的演讲《Entity System Architecture with Unity》,然后2016年的 《ECS Architecture with Unity by Example》。除了这两个演讲之外,其他还有很多的资料都存在作者的gitHub上。地址为:Entitas-CSharp。想要深入学习的可以跳到这里去查看资料。

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

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

Entitas是什么

用官方的话来描述。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去收集,然后根据原因作出表现。

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),它的执行顺序取决于你将它加入到运行时的顺序(也是也非常坑的地方,很多时候开发者也不能保证几个系统之间谁先执行),所有你最好按照下面这样添加系统:

代码生成器

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了):

自闭的Entitas

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

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

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

如下:

 

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

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

调试

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

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

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

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

够强大吧?嘿嘿~

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

Entitas和UnityECS

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

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

这篇文章主要是大致讲解了Entitas的框架,这个插件内容很多,要真的吃透的话要仔细过源码和手册才行,并且很多设计在实际项目里还是不太符合,需要因地制宜。这是这个系列的第二篇文章,第四篇的时候,我会拿出项目实际改动的部分给大家分享,在那之前,我们还需要完成一部分设计上的讲解,即逻辑和表现分离。讲解我们战斗部分是如何做到服务器和客户端共用一套战斗代码实现战斗服务器和客户端战斗表现的。