Unity手游实战:从0开始SLG——ECS战斗(一)ECS设计思想

2020-07-10 17:28:53 浏览数 (1)

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

编程思想的演变

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

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

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

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

面向过程强调的是步骤和过程,所以它只要用过程来解决整体流程就好了。

面向对象强调的是个体,所以它告诉个体,你要做什么。

面向数据强调的是部件(部件是数据的容器),那么我要先收集所有的部件(尾巴),然后一起摇。

面向数据的编程

从17年到现在,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关系的本质。

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

OOP示例

定义DOG和PIG类:

处理摇尾巴的过程:

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

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

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

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

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一个,只是方便展示)

ECS的优势

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

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

  • 想象一下你是一个重度的游戏,里面有一个Player对象,对象有非常非常多的数据和逻辑,有很多人的工作都和这个对象有牵连。当A在进行逻辑处理的时候,他不得不把整个Player对象传给对于的函数,对吧?如果他不小心动到了BCDE所维护或者负责的部分,对于A来说没什么代价,但是对于其他人来说要怎么去查找和修复BUG?拆离之后,A把自己要的数据封在特殊的Component里,并且用自己的System去处理它们,大大减少出问题的概率。但是代价就是代码变的复杂,分成了很多个部分。
  • 组合优于继承,这句话相信很多人都听过。这是在设计层面所表述的东西,很多时候我们处理逻辑只需要关注对象的某个局部,比如你的自行车胎破了,如果你的自行车是方便拆卸的,你会扛着自己车去修还是只拿轮胎去修?
  • 既然是组合关系了,那么热插拔和复用的特性也能用上了,想象一下IPhone手机的电池和诺基亚手机的电池,哪种更方便。再想象一下,如果你手机的耳机是焊死在手机上,而不是现在可以随意插拔的。。。
  • 有了热插拔,那么扩展也可以提到台面上来了。对某个功能系统进行扩展(不是升级),几乎不会影响到其他的功能模块,也不需要考虑之前的代码逻辑,因为每一个部分都是不关联或者是互相感知不到的。
  • 既然能热插拔,也容易扩展,说明耦合性极低,这不是这些年程序员所追求的极致嘛?
  • 除上述提到的优势之外,因为数据和状态都在Componet里,所以对于预测和回滚来说非常非常容易(记录关键帧的数据和状态),这是游戏开发,特别是网络游戏最垂涎的部分了,极大提高流畅度和打击感。
  • 还有,组件分离的方式天然适合游戏开发层面做逻辑和表现分离。特别是战斗部分,加了表现组件就有表现,可以放在客户端,不加的话就是纯逻辑,放在服务器。怎么样,一套代码又能做服务器,又能做客户端。
  • 还有还有,这种面向数据的方式,让内存排列天然紧密。非常适合现代CPU的缓存机制,极大增加CPU的缓存命中率,大幅提升性能。所以仅仅是多写了一些代码,带来了这么多的优势,为什么不去用呢?

ECS的问题

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

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

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

0 人点赞