ECS的优势:
1. 性能优势:对于C/C++/C#这类针对内存的语言,可以自定义内存布局,来增加CPU缓存命中,从而提升性能。
2.解耦
这里需要强调一下,Componet组件只能存放数据,不能实现任何处理状态相关的函数,而System系统不可以自己去记录维护任何状态。说的通俗点,就是组件放数据,系统来处理。这么做的好处,就是为了尽可能地让数据与逻辑进行解耦。与此同时,一个良好的数据结构设计,也会以增加CPU缓存命中的形式来提升性能表现。但我认为,推动ECS流行的更深层次原因,是它的解耦理念,性能上还只是其次。
C 和 S 是这个框架的核心。一个System 系统,也就是一个功能模块。对于游戏来说,每个模块应该专注于干好一件事,而每件事要么是作用于游戏世界里同类的一组对象的每单个个体的,要么是关心这类对象的某种特定的交互行为。比如碰撞系统,就只关心对象的体积和位置,不关心对象的名字,连接状态,音效、敌对关系等。它也不一定关心游戏世界中的所有对象,比如关心那些不参与碰撞的装饰物。所以对每个子系统来说,筛选出系统关心的对象子集,以及只给系统展示它所关心的数据就是框架的责任了。我们在开发的时候,可以定义一个 System 关心某一个固定 Component 的组合;那么框架就会把游戏世界中满足有这个组合的 Entity 都筛选出来供这个 System 遍历,如果一个 Entity 只具备这组 Component 中的一部分,就不会进入这个筛选集合,也就不被这个 System 所关心了。
ECS,相当于回到了C语言的开发模式,否定了面向对象。System来处理Enity, 就是算法来处理数据结构。
ECS遵循组合优于继承原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件MoveComponent
包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent
组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent
组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。
实体与组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。
Component 只包含属性,System 只包含行为。System 是不保存状态的,Component 才是状态的真正持有者。
一个使用ECS架构开发的游戏基本结构如下图所示:
先有一个World,它是系统和实体的集合,而实体就是一个ID,这个ID对应了组件的集合。组件用来存储游戏状态并且没有任何行为,系统拥有处理实体的行为但是没有状态。
Unity 的ECS:
Entity, ComponentData, System,Archetype, EntityManager, World;
先看图:
ComponentData:
组件数据,开发的时候,一个功能使用到的数据,需要新建一个ComponentData来存放。
Entity:
一个游戏对象的实体,可以是游戏中玩家、BOSS、NPC等,一个Entity里面包含了一个个的ComponentData,每个ComponentData代表这个Entity拥有这个功能。
System:
具体算法逻辑的实现,System算法的数据基于Entity的中的某个ComponentData。游戏引擎每次Update的时候,System都会根据每个Entity里面的它使用的ComponentData, 来更新数据完成计算。
EntityManager:
负责Entity相关类型信息,对象实体的创建与管理。
Archetype:
Entity是由多个ComponentData组成的,每种Entity类型都会对应一个Archetype, 里面描述了这个Entity类型以及相关的布局。Archetype由EntityManager创建出来,创建出来后这个类型的Entity的大小,包含的组件数据,内存布局都确定了。
World:
世界包含多个Entity组成Entity群体,通过世界把这些Entity群体孤立起来。Unity里面可以同时拥有多个不同的世界, 每个世界独立包含EntityManager与Systems, 在世界里面创建出来的一个Entity,只属于这个世界。世界里每个System也只能迭代一个世界里面的Entity实体数据。你可以创建多个World。
总结一下Unity ECS的使用步骤与逻辑关系:
(1) 创建一个ECS的World;
(2) 使用World的EntityManager为不同Entity类型创建出来Archetype;
(3) 基于Archetype, 我们创建出来Entity内存对象, 并初始化Entity里面每个ComponentData数据;
(4)为ECS World编写System算法,World会调用System算法来迭代每个Entity里的相关ComponentData;
作为架构师,设计框架数据结构由为关键。特别对于游戏开发,要处理迭代成百上千个游戏物体。高效的内存模型与布局如何实现? 首先要明白怎么样布局内存才会高效。内存模型布局高效主要从两个方面来考虑:
1:内存对齐。
CPU访问不同地址的内存数据的时候,如果内存地址基于2^n 数据对齐(n根据CPU而定),访问最为高效。例如我们以16字节数据对齐,那么对象内存开始排布的时候, 从地址能整除16的内存位置开始排布。高效的内存布局,对象实例内存地址要对齐,对象实例里面的每个数据成员内存地址也要对齐。Entity的内存分配与内存布局, 在创建Entity的时候,ECS系统就会注意Entity的内存对齐,同时也会注ComponentData在Entity里面排布的内存对齐,如果没有对齐,就会空一些出来。比如16字节对齐,一个ComponentData只有14个字节,实际上排布下一个ComponentData的时候是从16字节开始排布的。
2: CPU通过地址来取到内存数据的时,数据排布一起取的速度会更快。
ECS天然就具有这种优势。所有的逻辑代码迭代都是基ComponentData的,也就是算法迭代访问处理的数据都在一起,在一个ComponentData里面。这样访问数据高效,避免了地址的来回跳转。
3: Entity内存分配器 Chunk的设计
大量的Entity的创建与销毁,大量不同类型的数据对象交叉创建,很容易造成内存碎片。何为内存碎片?给大家举个例子,对象A,对象B(100字节),对象C连续排布,对象B销毁,内存释放,同时又请求分配对象D(80字节),系统在原来对象B的地方把一块内存分配给对象D,还剩了一小块内存(20字节)。而这一小块内存,无法分配给当前系统的任何一个对象,这样,这小快内存就再也无法使用了,造成内存碎片,随着系统不断运行,随着更多大量的创建与释放,内存碎片越来越多,分配/释放效率也越来越低。最终让系统越来越慢。解决这种大量创建与销毁,我们一般采用Cache内存池来做。那Unity ECS架构里面内存池是如何设计的呢?接下来我们来分析ECS里面基于Chunk的内存分配器。不同的Archetype,所对应的Entity的内存不一样,系统可能有N中不同的Archetype,而且Archetype是用户根据组件数据组合创建而来。
综上Unity ECS 是这样设计内存分配缓存池:
(1) 基于固定大小的Chunk来做内存缓存池。每个Chunk大小为16KB(引用来自于UnityECS 文档)
(2) 当Archetype 确定后,Entity的内存大小就确定下来,每个Chunk能分配的当前的Entity数目就能确定下来。基于Archetype创建Entity的时候,首先从内存池里面拿一个Chunk, 然后根据每个Chunk可创建Entity数目,从Chunk里面把Entity分配出去,如果chunk分配完毕,从chunk内存缓存池里面再拿一个Chunk。由于基于Archetype创建,这个Archetype的所有Entity内存大小都是一样的,等释放的时候,我们就可以基于Archetype把Entity缓存起来,重复使用之前的Entity。
对于OS而言,ECS框架基于chunk来分配和释放内存,避免了内存碎片(大小都一样)。对于ArcheType而言,又基于特定ArcheType的Entity来做内存缓存,分配和释放都非常高效。
看完Unity ECS的架构设计与内存布局,我们在其他地方做ECS架构设计(游戏服务器)就有了一个很好的参考,设计万变不离其中。