美文网首页
基于ECS模型的卡牌战斗框架2

基于ECS模型的卡牌战斗框架2

作者: 卅云川 | 来源:发表于2022-06-20 16:23 被阅读0次

    上一章记录了战斗框架

    在战斗框架内,就需要具体的业务逻辑进行填充,使得游戏拥有内容。而战斗逻辑部分的内容则以ECS模式进行开发,所以这一章就着重讲述ECS模式开发的思路,以及这套框架的具体实现与实现时注意的性能点。

    需要声明,这里的ECS模式是我整理的一套基本遵照ECS思想的代码框架,而非真正意义上能够极致利用CPU性能或是Unity所实现的那一套ECS组件。

    ECS模式

    ECS模式简述

    ECS模式,其实质只是数据与逻辑的一种组织形式,但其使用思路与我们常用的面向对象的设计思路已有了本质区别。

    在ECS模式下,实体、组件、各种数据结构依旧可以采用类(class)实现,而不必完全遵循教条的限制使用结构体(struct)。完全陷入ECS的教条模式,会在后续开发中给自己添加太多限制,导致我们原本是为了开发效率与性能采取ECS模式的做法反而成为限制开发效率的绊脚石。

    思路的转变

    最开始的面向过程

    在编程语言刚出现时,所有的编程都是面向过程的,其编程目标都是“什么正在发生”(面向对象的目标则是“谁在受到影响”),这样的思路在早期应对某些特殊指定问题是得心应手的,即便如今我们编写一些对象的函数方法或公共方法,其内里的本质依旧是面向过程的。

    面向对象

    随着问题的复杂化,面向对象的思路应运而生。

    面向对象的设计思路,使我们得以借助抽象思维,去分析我们面对的各种问题,即分析“谁在受到影响”。在抽象思维的指导下,我们拥有了解决更多更复杂问题的手段。

    但是伴随着面向对象思路的,还有因抽象、派生等面向对象的特性而导致的代码混乱、管理复杂等问题。想必在过往经验中,总会找到那么几个类,因为无止尽的派生与继承,导致类内部方法的实现混乱不堪,或base方法的时调时不调、或虚方法时而重写时而调取base的困惑、或方法覆盖经过多层继承之后早已忘记原来的模样。

    为了解决面向对象的这些问题,业内也曾早早开始探索将数据与分离的做法。

    数据与逻辑分离

    数据与逻辑分离做法,我最早是通过与服务器的交流得知的。我自己也曾寻找过各种方案,而我认为数据与逻辑的分离则是一种有益的尝试。

    通过使用类实现数据的存储,再单独定义API方法实现处理数据的独立逻辑,这样的尝试起码在一定程度上降低了一个动辄成千上万行的类出现的可能,使得代码的管理更有条理。而且这样的实现,也在一定程度上解决了虚方法与方法覆盖所带来的开发的困惑。

    但是作为数据与逻辑的一种组织形式,数据本身依旧可能有派生关系,那么将逻辑单独提取之后的实现依旧需要很多代码去处理这些派生关系。比如鸡和狗都派生自动物,那么单独将动物的奔跑逻辑提取出来依旧需要判断实例的类型,才能知道奔跑究竟需要两条腿还是四条腿。所以这样的处理依旧是不够彻底的。如果参考面向过程的解决思路,那么两条腿奔跑与四条腿奔跑则都是值得单独实现的,没有了派生关系,逻辑自然能够更加独立与纯粹。

    重回面向过程

    所以当我们要做到数据与逻辑分离的效果时,我们就需要将目光重新回到面向过程的思路上。此时的核心矛盾,就是数据逻辑的组织形式与面向过程开发思路之间的矛盾。为了解决这个核心矛盾,这才有了ECS模式的引入。

    ECS模式的本质,就是在将数据与逻辑分离后,面向过程编程。

    我理解的ECS的工作模式下,每个系统就好比一条单独的渲染管线(只是我们因为各种原因,而没有多线程运算)。每个系统的本质就是逐个获取自己关心的实体,对他们进行面向过程的逻辑运算,直到所有系统都计算完毕。

    ECS组织形式概述

    相信网上很多文章已经对这部分内容有了详尽的描述,这里只简单重温一下ECS的三大件,以及原型的概念,具体的组织实现在下文给出。

    Entity,实体

    实体的本质可以单纯理解为一个数组下标。通过唯一id(数组下标),将一个实体与n个组件进行关联。

    Component,组件

    组件,即数据,不同的组件记录的是不同的数据。理论上,任意两种组件之间应该不会存有相同的数据内容。以面向对象中的例子,则可以有两个组件,分别是两腿数据组件与四腿数据组件,如果这两个组件内有相同的数据,那么相同的数据可以单独提出作为一个单独的组件存在。

    System,系统

    系统是所有逻辑的集合。通过筛选出系统关心的实体,对他们执行统一的逻辑操作。以前面的例子,那么就可以定义两腿系统与四腿系统,他们分别关注拥有对应组件的实体,然后取出对应腿数的组件执行各自的奔跑逻辑。

    Archetype,原型

    原型的本质概念其实就是组件类型的集合。原型数据的具体实现可以有不同的方式,但其在ECS模型中所承担的概念,就是记录一种组件的组合方式。原型在ECS中主要的作用,就是借助原型去获取特定组件组合类型的实体。ECS的系统通过定义原型数据记录自己所关心的数据组合,再从实体中去过滤出拥有原型中组件的实体,去参与系统的逻辑运算。

    内部实现与细节优化

    世界

    ECS模式本身构建起了一个遵循ECS规则的世界,所以我们需要有一个World的概念,去包含整个ECS世界。大致结构如下:

    ECS的World结构

    ECS的World的设计本质不困难,因为ECS的结构是足够清晰的。困难的是向性能妥协的设计,所以在World的类图中可以看到组件缓存池与原型管理的存在。

    实体Entity本质就是一个数组,这里最简单的方式就是使用List实现,并借助List的下标对实体的唯一id进行赋值。

    系统System存放于一个List中,在World的Tick方法中便利系统。这里涉及到系统执行先后顺序的问题。针对自身项目开发时,系统的执行顺序需要在代码实现阶段就被设计好。我在项目中采用自定义属性的方式对系统进行排序,然后由自动化代码依据排序结果生成系统的注册方法,下文给出详情此处不作赘述。

    以上就是ECS系统实现的基础。根据不同的需求,一些全局性的数据也是可以放到这里的,这样即避免了单例形式的滥用,也可以有统一的地方存放公共数据。比如我的项目中需要用到运行时间的信息以及对应帧号数据,那么这一部分就可以封装为一个Time对象放在World内。至于其他没有介绍到的字段由下文介绍,主要与性能优化相关。

    实体

    实体自身结构:

    实体本质仅仅是一个id,但是在实际项目中,一个实体往往并不局限于这单一的数据。那么为了运算性能考虑,减少某些公共数据的记载流程,那么就可以在实体实例中加入公共数据存储。这部分公共数据根据项目开发需求设计即可。

    ECS原始模型中,实体与组件是通过相同的唯一id进行关联的。但是在实际开发中,如果完全照搬这套模式,那么加载数据组件本身将成为一个性能热点。所以在具体实现时,数据组件实例本身将存放于实体中,当实体销毁时自动释放组件实例。

    具体结构如下:

    实体结构

    实体内部需要记录实体的唯一id、原型数据,以及一个组件数组。原型数据依据当前实体上已添加的组件进行生成。而组件数组在这里是一个特殊设计点。

    若我们有n种组件,那么每个实体内部都包含有一个长度为n的组件数组。这样,实体身上需要的组件就以组件类型作为数组下标添加到这个数组中。这样的操作模式,即获取一个实体即可获取到它所有的数据组件,避免了其他调用方式可能造成的性能损耗。

    实体在World的存储

    实体在World中是以数组形式存放的,实现简单些便是List,这本身是没有问题的。但当我们实体频繁创建与删除时,这个数组内便会出现很多被浪费的空间,如果不对这些空间进行回收,便会白白占据着内存,在World长时间运行后就可能造成内存压力。所以这里使用块状数组解决这个问题。

    块状数组其实就是对连续的内存进行了分层设计。我以64个实体为1块,实体数组就转换为了块数组。如果0-63内存在实体,那么块数组0便有数据,这个数据就是数据块,然后在数据块内部存储有我们尚且有效的实体。如果0-63内的实体全部被释放掉了,那么块数组0就指向空指针,表明这个范围内已经没有有效的实体存在。具体实现遵照这个思路即可,利用实例缓存技术与位运算,便可在计算性能没有太大影响的前提下精简内存压力。

    组件

    组件作为数据集合,设计便简单许多:

    组件基类结构

    具体的组件类型,需要实现CompId属性,这里可以考虑使用枚举类型,也可单独使用int类型的id,一切以项目定义为准。

    OnLoad方法与OnRelease方法则在组件生成与回收时调用,以方便内部数据的重置。为了避免数据清空操作遗漏导致的运算不一致,我有使用属性实现对应代码的自动化生成,后续介绍。

    内部的实体属性在组件绑定实体时赋值,作用是为了能快速获取对应实体而设计,也是为了降低调用实体性能开销的一种设计方案。

    值得提一句的是组件池。组件池需要能够自动生成组件,以及组件回收。我在设计时直接使用了组件的基类Comp,这就要求在初始化组件池实例时,需要将创建对应组件的方法传递给组件池。这部分也通过自动化代码生成。

    原型

    原型其实就是一套位运算结构,如果组件数量够少,甚至使用int表示原型都是可行的。

    我这里默认采用自定义的UInt256结构指代原型,任意组件只要使用组件的类型id到原型内使用位运算进行标记即可,这里最大可支持设计256种组件。

    在原型的基础上,我们就需要一套原型管理的模块。这个模块负责的内容就是将所有的实体根据逻辑使用的原型进行分类缓存,通过空间换取时间,避免在获取指定原型实体时逐个遍历造成的性能开销。整个ECS所有的逻辑,基本都围绕遍历实体展开,所以减少遍历的性能消耗,可以极大提升运算性能。

    原型缓存内通过数组存储相同原型的实体,且数组内元素允许为空,缓存内有脏标记。这样的设计,是为了在ECS心跳末尾阶段刷新原型缓存,避免数据无变化时依旧执行运算。而数组内元素允许为空,则是因为对实体的遍历采用迭代器实现,空判断只需迭代器内部执行即可,上层无察觉,且当实体出现增删时,避免数组内元素的频繁移动造成的性能损耗,而且通过迭代器模式,可以确保在单次循环内不会遗漏实体。

    系统

    系统基类结构

    系统的一切围绕逻辑展开,所以除了初始化时引用ECS的世界实例,剩下都在Tick中实现逻辑。


    整个ECS的World心跳足够简单:

    ECS心跳流程

    以上就是我项目中ECS的所有心跳逻辑,根据项目需求的不同,此处可能会有些许变化,但核心只要注意逻辑顺序即可。

    数据快照与回滚

    因为ECS能够方便地将逻辑与数据分离,那么单纯将数据拿出按照某种格式存储,即可已完成数据快照。

    这里先记录ECS的快照与回滚,在后续帧同步记录文档内,会详细介绍其作用为何。需要说明的是,在我的上一个项目中,快照与回滚因没有需求而未完全实现,所以下文仅介绍基本设计思路。

    其实ECS的快照与回滚的实现,只需实体与组件定义两个方法即可,传入参数是自定义的数据流,两个方法需要做的就是存储数据入流或从流中加载数据。

    快照

    依据现有设计结构,快照可以以唯一ID开头,然后以此存入实体信息、组件数据。当所有实体的所有数据都存储到数据流中,就可以构成一个完整的快照。

    如果为了提升性能,可以考虑增量快照,但实现复杂度会提升,且需要以实体组件不变作为前提条件,否则就需要将实体与组件的快照数据分离,并重新设计排列结构,以期能够快速定位实体与组件数据的同时,还能较快地新增、删除组件数据快照以及快照数据之间的关联。

    回滚

    数据回滚较为特殊,是因为数据回滚时的状态与目标状态差异可能非常大,所以需要动态地新增、删除实体及其组件数据。

    所以,ECS需要有一个统一的回滚方法调取实体数据,并与ECS中已存在的实体作比较:有则进行数据回滚,否则就删除实体或完全恢复实体。


    这就完成了本篇记录的内容。下一篇,将记录卡牌类游戏战斗常用逻辑模块的通用设计与实现

    相关文章

      网友评论

          本文标题:基于ECS模型的卡牌战斗框架2

          本文链接:https://www.haomeiwen.com/subject/qnsjvrtx.html