浅入浅出Unity ECS

浅入浅出Unity ECS

前言

上一篇文章简单的比较了一下常见的ECS框架实现,但是不带任何代码和数据的方式实在是有点流氓,所以在这本文就稍微深入的讲解一下Unity ECS的实现思路和大概内容.

注意我这里不会讲解如何去使用它且不会直接使用代码里的命名 - 毕竟这类教程已经相当多了.不过更重要的原因是,在这个阶段Unity ECS的API还处于It works的阶段,并不适合学习 - 所以我也不推荐大家去背那些教程 - 看个思路就好,在接下来的版本中大概能看到更好的API.

Edit. 感觉 Unity 跑偏了。。。可以看看这个系列了解正确的设计思路。

真香

引入问题

一头扎进答案里并不是很好的选择,首先要理清的是要解决的问题和思路.

如果你还不知道ECS是啥,有啥用的话,我建议翻找一些科普或者阅读这篇文章.

从要解决的问题出发,我先帮你列出ECS框架的基本需求:

  • 动态分配Entity
  • 动态添加/删除/更新Component
  • 根据一组Component匹配对应的Entity

然后列出一个好的ECS框架应该尽量实现的特性:

  • 无GC
  • 无虚函数/回调/委托/消息
  • 无cache missing
  • 无data racing
  • 无框架消耗
如果你疑惑为什么会要求这么高的话,请阅读这篇文章.(逃

现在,有了要解决的问题,你可以开始考虑实现思路了.

或者,跟着我理一遍.

Entity

首先,可以排除把Component存放在Entity里的想法,因为Component是动态的,这将导致Entity的大小也变成动态的,则Entity被单独分配,则Component的内存不可能达成连续.

如果Entity不存储任何东西的话,则我们可以确定Entity是作为一个ID存在,而Component则单独存放.我们需要写的就是一个Entity ID的分配器.

Component

根据一组Component匹配Entity的算法,解法有很多种.

首先容易想到的是在Entity中记录拥有哪些Component(可以优化使用一个bitset来标记),然后做比较则可以得到结果,但是这种方法需要遍历所有的Entity,似乎过于naive.(用于Implementation of a component-based entity system in modern C++14)

那么进一步有一种非常暴力的手段,我们可以进行分组,拥有"同一组Component"的Entity分在同一组,并维护这些分组,这样遍历的时候就不会有任何性能损失,代价是为了维护分组降低了添加和删除的速度(用于skypjack/entt).

最后,我还想介绍一下基于HBV的方法 - 这是一种能高速遍历和合并的位数组.和第一种方法类似,我们记录Entity拥有的Components,但是不同的是,这里的记录是在Component上,即对于每一种Component,使用一个HBV标记哪些Entity拥有此Component.然后为了找到拥有"一组Component"的那些Entity,只需要对他们的HBV进行位操作合并,然后进行遍历即可(用于slide-rs/specs).

等等,Component的储存方式还没有考虑到.不过这里很直观,通过一个ID访问一个元素,不就是一个Vector/List吗.刚好也满足了元素紧密排列,多棒鸭.

好,这里打住,相信大家心里已经有一些B数了,接下来进入Unity ECS的世界.


看向Unity

既然前面的实现已经这么完美了,Unity的实现还能有啥好看的呢?

遗憾的是,其实并不完美,考虑两种Component: A,B,此时有三个Entity: [A1,B1],[A2],[A3,B2],那么内存中将是[A1,A2,A3][B1,. ,B2],可以看到B1和B2之间出现了一个空缺,导致了内存不连续.那如果是采用一个中转表[1,-1,2]来将B压缩为[B1,B2]呢,这又多出了一个中转表的内存和维护代价.那还能怎么办呢?

之所以会在这里陷入困境是因为之前的思路有个巨大的缺陷 - 脑子里的"对象"还没有抹去干净,总想着Component要通过Entity来访问.其实并没有必要鸭,比如我只访问一类Component,那我直接遍历这个数组不就好了,这也就是Unity的思路.

在Unity ECS中,并不只是对Entity进行分组,而是连着Entity对应的Component一起进行分组 - 称作Archetype.每一种Component都存放在连续内存里,而这些Component对应的内存又被分割成固定长度被打包在一块固定大小的内存里 - 称作Chunk,满足一类Archetype的多个Chunk又被一个LinkedList连接起来存于满足条件的Archetype.

上面这一大段嘴炮直接看可能有些难以理解,这里用一个小栗子进行解释,考虑两种Component: A,B,此时有五个Entity: [A1,B1],[A2],[A3,B2],[A4],[A5,B3],这时可以分为两组Archetype: [AB]型 和 [A]型,这里我们假设Chunk长度为2(实际为固定大小16kb),则有

  • 对于[AB]型有两个Chunk: [A1,A3,B1,B2] -> [A5, ,B3, ]
  • 对于[A]型有一个Chunk: [A2,A4]

下图为官方的一个图例:

连续的Component组成Chuck再连接成Archetype
那这种结构是否能满足我们要求的特性呢?

接下来就分析一番:

  • Entity: Entity依然是ID,不过为了找到Entity对应的数据,Unity使用了一个EntityData,在这个数据结构里引用了Entity数据所在的Chunk和在Chunk之中对应的ID.所幸一般的遍历中不需要使用这个EntityData.
  • 增删改: 对于增删而言,就是重新找到适合这个Entity的Chunk,并把数据搬移到这个Chunk中.这里有少量消耗.
  • 匹配: 只需要找到对应的Archetype,遍历对应的Chunk即可,消耗基本可以忽略.
  • 无GC: Entity只是ID,以Chunk的方式手动分配,没有任何GC,甚至没有List的重分配,完全脱离了Managed Memory.(于是Burst很愉快)
  • 无虚函数/回调/委托/消息: 当然没有
  • 无Cache Missing:
    • 首先Chunk可以对其到CacheLine,以Chunk为单位把任务分给多个线程,避免了出现伪共享.
    • 再者Chunk内所有的Component的数据都紧密相邻,遍历依次访问,除非往Component里面放入大量无用数据,则基本不会有Cache Missing.
    • 进一步的,数据如此紧密的排列使得我们完全可以使用SIMD加速代码.(于是Burst支持自动向量化)
  • 无Data Racing: Unity这一层是在Job System做的,在容器中进行运行时检查以保障不会出现Data Racing.嘛其实我觉得编译期就可以做到.
  • 无框架消耗: 显而易见,分配Entity和匹配的消耗几乎可以忽略不计.只有增删会有少量的消耗(搬移数据).

总的来说,Unity ECS的结构能够很好的满足对应的要求.

免费的性能?!

是,冗余的数据得以去除,内存的布局得以掌控,线程的并行变得简单而安全.但是

也不是,正如上面提到的"除非往Component里面放入大量无用数据",细节上的数据规划还是需要程序员来思考和处理,正因如此,使用ECS架构进行编程在目前也是一项具有挑战性的尝试,其中的惯用法和误区还需要社区的讨论和积累.

那么代码呢?

基于以下原因

  • 在过度期间,Unity ECS支持Hybrid模式(和GameObject混用),所以会有很多额外代码
  • 其内存结构并不容易直接用类型系统很好的表达 - 例如每个Archetype的Chunk都是不同的Tuple of Array,所以Unity直接弃疗了,直面内存
  • 这个模块其实正在非常早期的开发中(版本号不到0.1),命名也喜欢xjb改

所以这里不对应到源码上去了,感兴趣自己看吧(逃).

移动Entity到新得Chunk的代码(片段)

当然除了核心之外还需要一些辅助的概念让Unity ECS真正可用,下面我就简单的过一遍这些概念.

更多Component

为了增加Unity ECS的可用性,其中定义了两种特殊的Component:

  • SharedComponent
  • SystemStateComponent

其中SharedComponent是一种以值为类型的单例Component,不会出现两个值相等的SharedComponent,也就是说有SharedComponent 'a'和'b'的被视为两种Entity,也也就是说使用同一个Mesh的被视为一种Entity.

具体实现方式是在Archetype中记录了Component的类型和SharedComponent的并依此分组.需要注意的是,因为SharedComponent影响分组,所以最好不要滥用以免导致Entity过于分散.

进一步的,SharedComponent是以Managed的方式管理的,也就是其可以包含Managed Data,所以有如下代码:

然后筛选出使用同一个模型的DrawInstance

然后这个SystemStateComponent的特殊之处则在于,当它存在的时候,保证Entity不会被删除.意在不引入回调的前提下,让一个System有能力监控Entity的变动.举例来说,假设有一个网络同步System S,其监控一个Component A的同步,则我只需要定义一个SystemStateComponent SA,当Entity[有A,无SA]时,表示A刚添加,此时添加SA,等到Entity[无A,有SA]时,表示A被删除(尝试销毁Entity时也会删除A).


当然,这两种Component还能组合起来:SystemStateSharedComponent.(实际名字更长2333)

更多并行

为了增加Unity ECS的并行性,Unity定义了Barrier和EntityCommandBuffer.你可以多线程往CB里面填充操作Entity的指令,然后在Barrier处执行.(当然种类单一的操作也可以自己手动记录在NativeArray里,这样能获得更好的性能)

更进一步的操作还有在另一个世界中创建Entity,并且在某一时刻搬运到主世界(当前Mega City的做法).

更多动态

为了增加Unity ECS的普适性,Unity定义了DynamicBuffer.你可以为Entity添加DynamicBuffer来存放动态长度的数据.比如寻路节点.

更多控制

在Unity ECS中,Job之间使用Fence来保证执行顺序,而System之间则用[UpdateAfter],[UpdateBefore],[UpdateInGroup(typeof(flag))]来制定顺序.


到你了

以上便是Unity ECS之旅,接下来就是Unity和你的表演时间了,使用Unity ECS写出更加壮观/省电的游戏和更加优雅的代码吧!

编辑于 2019-05-18

文章被以下专栏收录