ECS与DOP(面向数据编程)浅析

序言

以下内容大部分是文字读起来确实会有点繁琐,内容主要是个人对ECS以及DOP的一些理解,如有错误欢迎指出,我觉得如果你正在学习ECS以及DOP,好好地读一下这篇文章是能够给你带来收获的。

什么是DOP

DOP(data-oriented programming),也就是面向数据编程,一种编程范式。与面向对象以及面向函数类似,可以理解为是一种“指导你进行程序设计”的方法。将它与更为熟知的面向对象(OOP object-oriented programming)作比较的话,面向对象更关心“类”之间的关系,而DOP更关心“数据”之间的关系。或者更直白地说,面向数据编程倾向于将“大概率会被一并使用”的数据放在连续的地址空间中,从而提高访问数据时CPU击中的概率,进而提高数据访问的效率。而面向对象则更关心开发人员对抽象关系的理解,也就更易于被理解一点。至于为什么可以提高CPU击中的概率,简单说的话就是CPU也是有缓存的,当一个数据被加载的时候,其附近的数据会被加载到缓存中,而直接从缓存中读取数据要比从内存中读取快得多,这部分不是很理解的朋友们建议还是找一篇文章详细地了解一下,对于理解DOP很有帮助,可以说这就是DOP之所以被提出以及之所以能够实现优化的核心。

举一个简单的例子,当要建立一个森林时,面向对象往往是先建立一个树的类,树包含的树需要的各种信息,比如种植日期,品类,坐标,然后由多个树的对象组成森林。而面向数据可能会选择的方式是,一个叫做森林的数据包含着多个数组,种植日期数组,品类数组,坐标数组,最后我们可以通过一个索引找到一颗树所需的所有数据。这样做的话,一棵树的组成就不像一个类的对象那么直观,但当我们试图访问所有树的种植日期时,面向数据的方式将会比面向对象更加快速,因为种植日期被存储到了连数组中(一段连续的地址空间,支持随机访问)。这只是一个很简单的例子,不要简单地理解为使用数组就是面向数据。个人认为一个优秀的面向数据架构应该是能够在”易于理解“以及优秀的数据读取效率上达到一定平衡的。ECS就是一个很好的例子,在本文的后续会提到。

值得一提的是,虽然都是编程范式,但DOP与OOP并不冲突。或者说我更愿意称其为一种”优化方法“。你大可以在OOP的项目中引入DOP,从而达到优化的目的。这种优化在数据量越大时越明显,特别是在游戏行业中。戴森球是一个典型的DOP项目,具体的内容可以从他们自己的分享文章中查看。

什么是ECS

ECS指的是Entity(实体) Component(组件) System(系统)。简单地描述一下这三者之间地关系,那就是Entity持有component,system通过获取所需地component来完成逻辑运算。一般来说Entity除了Component之外不太会包含别的数据(但还是会保存数据比如版本信息什么的),这应该很好理解,如果Entity把所有数据都直接包含了,那Component还有什么用?换句话说,那他和OOP又有什么区别。对于Component而言,它只关心数据,不包含任何方法,在ECS体系下,方法几乎只应存在与system中。

ECS与DOP的关系

首先明确一点,ECS算是DOP的产物,但一个项目使用了ECS并不意味着这就是一个经过DOP优化的项目。

继续借用之前树的例子,那么在ECS的结构下,位置,品类,种植日期就成了Component,这些component组成了一个Entity,也就是树。值得注意的是,当我们需要一个方法来获取植物的年龄时,如果使用的是OOP,那么我们或许会在Tree这个Class里写一个方法通过传入当前时间来与种植时间计算得出当前年龄。但ECS并不会在Component中写方法,而是会有一个处理这件事的system(或者说能够处理这件事的system,这里我想要明确的是system并不被要求只能做一件事,也不被要求只能使用一种component),这个system会获取到种植日期的component然后计算得到树的年龄。你或许想问,那我创建一个Class Tree,里面只包含属性,没有方法,然后写一个TreeProcesser来处理所有Tree Class的逻辑,那不就和ECS一个意思了吗?我想说,确实。在将数据与方法分开这件事上,二者达成的目的是一样的,但System真正的重点其实是在于当你试图或许一类component时,例如需要计算五棵树的平均年龄时,理想状态下这五个树的种植时间Component在内存中应该是连续的,而TreeProcesser获取到的会是五个tree,再从每个Tree中获取到种植时间,这五个终止时间在内存中必然不是连续地址(这很关键)

那么回到本小节开头提到的问题,为什么一个项目使用了ECS并不意味着这就是一个经过DOP优化的项目?其实刚才的例子已经回答了这个问题,当五个种植时间的Component并非连续的地址时,在数据读取的相率上,其实也就退化为了和普通的OOP一个水平。换句话说,如果同样的创建森林的需求,我们创建了一个森林类,使用了之前用数组来分别保存数据的方法,那么在取用五棵树的种植时间时,他就是连续的(地址上是连续的,但真的被cpu读取时,因为缓存有大小限制,并不一定能一次全读到缓存中,但即便如此,其效率还是要高于分散地址的数据),那么就算这是一个OOP的项目,它同样是经过DOP优化的。

由此可以看出,”连续的地址空间“是DOP中的一个关键点。那么在编写代码时,我们怎么知道数据是否被放在连续的地址空间中呢?这就比较考验大家的基本功了,举个简单的例子,数组是连续空间,链表不是,又比如在c++中,vector是连续空间,list不是,还有一个比较特殊的deque(由多段不连续的连续地址空间构成),感兴趣的可以去了解一下。或者还有一种方法,我们可以自己分配连续的空间。

那么直接都用数组是不是就解决问题了?确实。这个想法在我看来是可以给予肯定的,但就像我之前说的 ”一个优秀的面向数据架构应该是能够在”易于理解“以及优秀的数据读取效率上达到一定平衡的”。在部分情况下,直接大范围地使用数组在我看来完全是一个可取的方法,但撇开可能造成的理解成本,如果这个数组真的非常巨大,那么在数组中间进行插入和删除操作时,效率问题或许也是需要被考虑的。所以使用简单粗暴地使用数组在部分情况下是ok的,那么有没有别的更加优雅的方法呢?有的,Unity的ECS给出了一个不错的答案。

Unity的ECS

这里先直接放一个unity官方文档的链接,感兴趣的可以直接去看,讲的也必然比我详细:Unity的ECS

这里着重讲述以下Unity的ECS中是怎么解决内存分配问题的。

首先Unity的ECS中也有着Entity,Component,System及部分,其各部分之间的关系如下图:
图片来源Unity官网
上图大概意思就是,有三个Entity,其中A和B具有相同的Component,还有一个system能够通过Translation和Rotation计算出LocalToWorld。这里也就能更明确地看出我之前提到的,一个System并非只能接受一个component。

Arhcetypes与ArchetypeChunk

进一步地,可以看到EntityA和EntityB被圈在了一起,这里也就可以引入Unity ECS的内存解决方案,Archetypes以及ArchetypeChunk。

在Unity的ECS中,具有相同component构成的实体会都会属于同一个Archetype,例如上图中,A与B属于一个ArcheType,C属于一个ArcheType。
图片来源为Unity官网
有了ArhceType之后,如果你想要新建一个Entity与之前已有的Entity具有相同的components,那就可以直接基于ArcheType生成。为此Unity还提供了不同的方法来生成Entity,你可以通过ArcheType来生成,也可以通过component生成(此时会根据是否已有当前构成的Archetype来决定是否生成新的ArcheType)。当一个Entity的component变化时,例如增加或删除,都会导致其ArcheType的变化。当有了ArcheType之后,就可以通过ArcheType来进行“优雅”的内存分配了。

ArchetypeChunk就是用来保存Entity的容器。一个Archetype对应了一个ArcheTypeChunk,所有的Entity的数据都会被存储在ArcheTypeChunk中。具体的存储方式为,而一个ArcheTypeChunk会持有Chunks,每一个Chunk保存着n个Entity的数据,当有新的Entity被创建时,会判断当前chunk中是否还有空余的空间,如果没有了就新建一个chunk然后把entity放进去。简单的理解,每一个entity的所有数据一定是在同一个chunk中的,其结果是对于每一个chunk来说,大概率都会有无法被使用的chunk。举个极端一点的例子,如果一个Entity占用3k,一个chunk的空间是16k(当前UnityECS给出的大小),那么5个entity加入后,这个chunk一定有1k是无法被使用的。但这一点损失其实还在可接受的范围内,一方面一个entity一般不至于这么大,最终损失的空间往往不大,另一方面通过ArchetypeChunk的方式已经避免了很多内存碎片。

试想如果没有ArcheType以及ArcheTypeChunk,我们也就无法知道一个chunk能够承载几个entity,假设Entity A,B,C,D分别占用40,100,80,90的空间,ABC被分配在一段连续的地址,然后B被释放了,这时候D被加载到原先B的空间,那么就会产生10的内存碎片,在大量的数据频繁地增删之后,这种碎片将会是很大的问题。但ArchetypeChunk解决了这个问题,每一个Entity都能被合理地放到Chunk里,当一个Entity的component发生改变时,他就会从原有的ArchetypeChunk中被移动到新的与之相对应的ArchetypeChunk中。

还有一点值得注意的是,Unity ECS提供了一种sharedComponent,这种component允许持有该component的entity共享其component的数值,而ECS也将为这些entity提供一个新的chunk。如果这个sharedcomponent的值被修改,或者被删除,都将导致这些entity被移动到别的chunk中或者新建一个新的chunk。因此应该尽量少地使用sharedcomponent,减少不必要内存的分配。

一个Chunk中的数据是如何排列的

知道了以上的概念之后,一个核心问题其实还没有被解答,那就是一个chunk内的数据又是如何被分配?

先不将Unity是如何做的,我们自己思考一下大概能想到两种方案:

  1. 一个Entity的数据顺序排列,例如EntityA(C1A,C2A,C3A),EntityB(C1B,C2B,C3B),那么在内存中其排列方式如下:
    EntityAHead, C1A,C2A,C3A, EntityBHead, C1B,C2B,C3B。
  2. 相同的component顺序排列,例如EntityA(C1A,C2A,C3A),EntityB(C1B,C2B,C3B),那么在内存中其排列方式如下:
    EntityAHead, EntityBHead, C1A,C1B,C2A,C2B,C3AC3B。

如果你认真看了之前的内容,大概也就能猜到,Unity将采用的是第二种方案,此时如果要获取所有C1(Component1),C1A和C2A因为在连续地址,是能够被快速访问到的。当然你也可以说当每个Entity够小时,我们也可以认为第一种方案的所有数据都是连续的,那我只能说 “OK Fine”,其实按照这个逻辑的话,所有数据在地址空间上都是连续的了哈哈哈哈。

那么采用的方法二之后,当我想要获取一个Entity的数据时会变得麻烦吗?不会。我们知道所有Entity的头部时在chunk的最前面的,我们知道head在哪,我们知道每一个component的长度,我们可以很轻易地获取到Entity的每一个component数据,并且因为一个entity一定在一个chunk中,我们都不需要考虑跳转chunk的情况。下图能够进一步帮助理解以上内容:
图片来源为Unity官网

参照上图,每一个ArcheType具有一些列的Chunk,每个chunk都存储着对应当前ArcheType的Entity,并且每个chunk中相同颜色的方块总是连续的,也就是上文说到的相同compont是按顺序存储的。

至此,Unity ECS的内存是如何分配的也就大概讲完了,回顾之前提到的如果数组非常长时插入的问题,在Chunk下已经被解决了。而且Unity的ECS在理解起来也不困难,我们在使用时只需要考虑entity由哪些component构成,至于怎么顺序存储,交给系统自己解决吧!优雅,实在优雅!事实上哪怕我们没有“巨量”对象这种需求,ECS同样是一个不错的游戏框架,毕竟对于一个游戏而言,能提升性能何乐而不为呢。在大部分情况下,ECS比起OOP的方式在计算效率上只会有过之而无不及,毕竟从ECS结构中单独获取一个Entity的数据也并非是一件难事。而且Unity还提供了很多有用的内容,例如在ECS的基础上使用Job system等,具体的内容就不再赘述了,感兴趣的可以自己去找资料看看。

关于ECS和DOP的一些建议

总而言之,ECS也好,DOP也好,其核心就是通过连续的地址空间来增加数据读取的效率。但并不是说“连续的地址空间”一定能优化项目的效率。只有当我们需要的数据是连续的时这种优势才能够被很好的体现。举个例子,一个Person类,其核心方法就是一个person需要根据自身的各种属性例如饥饿度,心情,性格来决定自己接下来的行动。这种时候如果你把person当作entity,把饥饿度,性格等当作component,在计算的时候获取到的主要是“一个Entity”的数据而不是“一类component”,这时ECS的优点其实并没有被很好的发挥。相反继续使用OOP的方式,项目可能更好理解,同时OOP易拓展易维护的特性也能给项目带来好处。合理地将OOP与DOP搭配使用同样是可以的,例如一个person类同样可以拥有类似于component的结构(所有的该类Component被统一管理顺序存储)。或者有需要的话person用oop,Tree用ecs也是可以的哈哈哈哈(当然了个人认为一个项目内的框架还是同一比较好,只是指出如果确实有需要,DOP,OOP以及ECS并不是什么互相矛盾的东西)。

猜你喜欢

转载自blog.csdn.net/qq_33445510/article/details/125872115
ECS