ECS的核心思想是面向数据的技术栈,为什么不再是面向对象的处理方式呢,因为游戏的逻辑大多是需要每帧刷新的,但是对游戏系统的要求则是复杂而又高效,这种机制注定了游戏编程使用面向对象的那种方式不如面向数据高效;
面向数据的设计更多的是一种程序优化方法,其有效利用的是CPU缓存,通过集中数据布局,根据需要的时间对字段进行分离和排序,并考虑数据的转换,来提高CPU缓存的命中;而传统的面向对象的编程,它的设计原理就会导致不良的数据局部性;
它包含三个核心部分;
Entity,实体,用于填充游戏的具体内容;
Component,数据,不再是一个对象,这就是面向对象和面向数据的关键区别;
System,处理具体的游戏逻辑,修改组件的数据,更新实体的状态;
Entity Component System还有一个对应的较容易理解的概念结构,即identity-Entity,data-component,behavior-system;由此可见,ECS框架的核心是数据,系统只关心并处理数据的逻辑;
一个独一无二的组件组合被称作一个原型,如下所示,实体A和实体B属于同一个原型M,因为他们拥有相同的组件,而如果去除B的Renderer组件,则实体B和A将属于同一个原型N;
了解内存块,使我们明白为什么ECS框架能够被认可的一个主要原因,因为数据的存取是运行速度的一个关键因素;
上面我们了解到了原型的概念,为什么要提供一个原型的概念呢,因为我们需要根据原型来进行内存的分配和数据的存储,简单的来说就是同属于一个原型的分在一个大的ArchetypeChunk中,然后这个大块内分配一个一个小块;如果对一个实体的组件进行了添加或者移除,只要改变了这个实体的原型,ECS就会将这个实体放入到相应的Chunk中;
但是ECS并不会在块中给实体进行指定的排序,当创建或改变一个实体时,ECS会将其存储到这个原型的第一个块中;Chunk会保持十分紧凑,当一个实体从圆形中移除的时候,ECS会将块中最后一个块的组件移动到新空出的槽位中;
为了识别系统应该处理哪个实体,就需要用到实体识别,一个实体识别搜索所有已经存在的原型,然后给出满足要求的匹配结果,可以指定以下三种对组件的要求;
All,这个原型必须包含所有的组件类型
Any,这个原型必须包含至少一个组件的原型
None,这个原型必须不包含任何一个组件原型
最终一个entity query提供一个匹配查询要求的内存块的list,可以使用IJobChunk来遍历这些组件;
为了更好地使用多线程,你可以使用C# Job System,ECS提供了SystemBase类,以及Entities.Foreach,和IJobChunk解耦的Schedule()&ScheduleParallel()的方法,用于在主线程之外修改数据;Entities.Foreach可以用很少的代码实现而且简单易用,当然,我们可以使用IJobChunk来完成一些更复杂的工作,这是Entities.Foreach不能做到的;
ECS在主线程上的job排期是按照系统的顺序进行排序的,当工作被排期时,ECS会一直跟踪着这些Job读写了哪些组件;当然,一个Job读取一个组件是和写入相同组件相互独立的,job scheduler使用job dependencies是为了决定哪些工作可以并行运行,哪些必须按序列运行;
ECS通过World和group来组织所有的系统,显然group是一个更小的层次粒度;默认情况下,ECS会创建一个default World和一个预定义的group集合;它会查找所有可用的系统,实例化它们,并将其加入到default World中的预定义simulation group中;
我们可以在同一个group中指定系统的刷新顺序,一个group也是一种系统,所以我们可以将一个group加入到另一个group,然后可以像其它系统一样为这个group来指定顺序(通俗来说,我们可以想象group是可以嵌套的,它的最小单位是一个system,它也可以包含多个system,类似于json内的{});在一个group中的所有系统会在下一个系统或者group之前更新(这一点又可以类比UI渲染,一个画布包含的所有控件的渲染可以看做一个整体,只有当一个画布渲染完成后下一个才可以渲染);如果我们不指定顺序,ECS就会用一种确定的方式来给出一个刷新顺序,但是不依赖于创建顺序的;换句话说,如果我们没有显示指定一个顺序,相同的系统集合在一个group的内部总会以相同的顺序来进行刷新;
实体是通用的对象,它仅包含唯一的ID(identity),它不包含任何数据(data)以及任何行为(behavior),它用于确认哪些数据是属于它的,然后数据是data提供的,行为是system提供的;
一个实体本质上就是一个ID,这句话是对实体最好的概述;最容易的方式是你可以把实体当做一个超级轻量的游戏对象,它甚至都不会有一个name;Entity ID是固定的,所以它可以用于各种引用和存储;
一个EntityManager会在World中管理所有的实体,它将管理一个实体list并让数据和实体的表现联系到一起;尽管实体是没有类型的,但是一组实体将会被关联的components的类型所分类祖师;当我们创建了实体并添加了组件以后,EntityManager将会一直追踪组件和实体的绑定关系;这样一个独一无二的关系被称为Archetype,也就是上面讲述的原型;当我们向实体添加一个组件时,EntityManager创建一个Archetype结构;
在unity editor中我们可以很容易创建一个entity,我们可以在游戏运行时将所有场景和prefab内的GameObject都转换为实体;对于游戏中更多动态的部分,我们可以创建很多系统用于创建爱你多个实体;我们可以使用EntityManager.CreateEntity函数(有许多重载)来一次创建一个实体;
使用EntityManager来创建一个实体,使用EntityManager.CreateEntity中的一个来创建一个实体;我们可以通过下面几种方式一个接一个创建实体
使用ComponenType数组来创建实体
使用EntityArchetype来穿件
为了copy一个实体并包含它当前的数据,使用Instantiate
创建一个没有组件的实体,然后为其添加组件
也可以一次创建大量实体
使用CreateEntity,用同一个原型来填充一个NativeArray
使用Instantiate,使用存在的Entity的拷贝来填充NativeArray
使用CreateChunk,根据指定数量的实体和给定的原型来显式创建chunks;
在一个实体被创建以后,我们可以添加和移除组件,但与此同时,原型影响到的实体将会改变、EntityManager也会将相应的数据移动到一个新的内存块;对一个实体的改变会引发结构的变化,添加和移除组件都会改变SharedComponentData的值;不可以在一个Job中销毁一个实体,因为这可能会引发无效的data,而这个data可能正在使用;所以,我们需要向EntityCommandBuffer中添加改变这些类型的命令,然后当这个job完成以后来执行它;EntityManager提供了从一个单独实体中以及从所有实体中的NativeArray中移除一个组件的方法;
为了进行数据的读写,就必须先要查找到想要的数据,我们可以使用EntityQuery来进行数据的查询,它可以实现以下需求:
进行一个工作去处理选择的实体和组件
获取一个包含了选择实体的NativeArray
获取选择的组件的NativeArray
定义一个查询
一个EntityQuery查询会定义Component类型的集合,ECS中的类型必须满足这些限制条件;
如下所示,定义了一个查询包含两个Component,其中第二个Component用的不是简单的typeof,而是ComponentType.ReadOnly
EntityQuery m_Query = GetEntityQuery(typeof(RotationQuaternion), ComponentType.ReadOnly());
更复杂的查询EntityQueryDesc类
可以使用如下几个限制符号来指定查询机制,All&Any&None,这个在前面已经介绍,不再赘述;
var query = new EntityQueryDesc{
None = new ComponentType[]{ typeof(Frozen) },
All = new ComponentType[]{ typeof(RotationQuaternion) , ComponentType.ReadOnly}
}
EntityQuery m_Query = GetEntityQuery(query);
不要在EntityQueryDesc中使用可选的组件,为了去处理可选的组件,使用ArchetypeChunk.Has
查询选项EntityQueryDescOptions
当创建一个EntityQueryDesc,可以设置选项变量,包含如下集中类型
Default,默认类型,正常查询
IncludePrefab,包括那些拥有特殊prefab标签组件的原型
IncludeDisabled,包括那些拥有特殊disabled标签组件的原型
FilterWriteGroup,考虑所有组件的WriteGroup
合并查询
在系统类以外,还可以使用EntityManager.CreateEntityQuery()创建一个EntityQuery
EntityQuery m_Query = CreateEntityQuery(typeof(RotationQuaternion), ComponentType.ReadOnly());
如果计划重用一个相同的视图,应该缓存EntityQuery实例,而不是每次使用都创建一个新的;
定义过滤器
我们可以定义视图就像定义组件一样,来设定是否必须包含或者必须不包含,可以使用以下过滤器;
Shared component filter,基于共享组件的具体值来过滤实体集合
Change filter,基于一个具体组件值是否改变来过滤实体的集合
执行查询
在一个job内使用EntityQuery来执行一个EntityQuery的查询,或者使用EntityQuery方法来返回实体、组件或者块中的数组;
ToEntityArray,返回选中的实体的数组
ToComponentDataArray
CreateArchetypeChunkArray(),返回包含选中实体的所有chunk;
在jobs中查询示例
在一个系统中去安排一个IJobChunk工作,传递一个EntityQuery对象到工作的ScheduleParallel()或者ScheduleSingle()方法中;下面是一个示例
protected override void OnUpdate(){
var rotationType = GetArchetypeChunkComponentType(false);
var rotationSpeedType = GetArchetypeChunkComponentType(true);
var job = new RotationSpeedJob(){
RotationType = rotationType,
RotationSpeedType = rotationSpeedType
DeltaTime = Time.deltaTime
};
return job.ScheduleParrallel(m_Query, this.Dependency);
}
World包含了一个EntityManager和一个ComponentSystems的集合;在play mode下,默认会创建一个单独的world,并用所有可用的ComponentSystem来填充他;