UnityECS中的“E”和“C”
基本的Unity GameObject
传统的Unity GameObject是很消耗性能的,我们先看一下传统GameObject的结构:
UnityEngine.GameObject
- 属于C#类
- 是
UnityEngine.Component
各种实例的容器- 永远包含一个Transform Component
- 可以作为其他GameObject的子级
UnityEngine.Component
- 属于C#类
- 内部包含
Update()
等一些需要在特定游戏生命周期触发的函数。- 会引用很多managed object(包括GameObjects、其他components还有assets等)
UnityEngine.GameObject
和UnityEngine.Component
都是managed object,不能够在Burst编译器内应用,在大部分情况下也不能应用Jobs。
创建和销毁GameObjects和它们的Components开销很大,因为Managed Object在内存中分散存在,而不是像在Array中整块儿存在,在Unity生命周期中这些GameObjects和它们的Components在内存中不断被访问,这样并不高效,还会导致出现缓存未命中的性能问题。
基本的Entity
为了避免上述情况,我们使用Entity代替GameObjects
Unity.Entities
- 是一串整数ID
- 和一系列components相关联(只能关联一种component type,例如关联一个component,它声明为Foo,这个Entity只能关联一个Foo,再加一个是不行的)
- 没有内置的父子级概念。
Unity.Entities.IComponentData
- C# 结构体
- 也许通过ID引用entities,但ID不是managed object,像float、int都不是managed object
- 大部分情况下,component和Entity内部没有methods,它们都是纯数据。但这也并不是严格限制
- 在UnityECS中,Entities和components会紧密地在arrays里面打包,对内存更好,这样查询(Query)它们的数据会非常高效。
注意:IComponentData是可以应用在class上的,这种component就能够包含managed object了,但这种component就会有上面说的性能问题
基本的Component声明
1 | using Unity.Mathematics; |
World概念解释
World是entities的容器,每个World的Entity都有一个对于当前World唯一的ID,但是如果这个Entity在其他的World中可能会有相同的ID,这一点请注意,不要把ID对应的World搞错了。
在大多数的情况下,一般只需要一个World,不过创建更多的World在需要逻辑分离的时候很有用,Unity的DOTS Netcode网络开发包为每个服务器和每个客户端都创建了不同的World
Unity Multiplayer Networking | Unity Multiplayer Networking (unity3d.com)
为了管控Entities和components,World提供了EntityManager:
CreateEntity()
DestroyEntity()
AddComponent()
RemoveComponent()
GetComponent()
SetComponent()
Entities和components的存储方式
Archetype
每一个World的Entities都会被分成几类Archetype,每个拥有相同components组合的Entity就会归为一类Archetype
Chunk
Chunk是每一类Archetype里存储各个entity和components的块,这些块的大小是均匀一致的
示意图:
这是一个拥有三类Archetype的World
Archetype A B C指的是同时包含有component A、component B、component C的entities,这些entities和components分成了4个chunk
Archetype A B 指的是同时包含有component A、component B的entities,这些entities和components分成了2个chunk
Archetype A C指的是同时包含有component A、component C的entities,这些entities和components分成了3个chunk
使用当前World的EntityManager在一个entity中add或者move component,Unity会隐式转换这个entity对应的Archetype。这些增删component的操作会间接导致Archetype和Chunk的改变,我们称之为“structural change operations”,简单地读写component并不是“structural change operations”
Chunk的结构
这是Archetype A B C的Chunk结构
每一个Chunk内部的entities数量和每个entity的component的数量和Size有关,每个Chunk最大可以有128个entities,超过了这个数量就会创建新的Chunk。
Chunk内部由entity和component各自的数组组成,Chunk内部的排序规则是:第一个空槽必须被填上。也就是说,如果上图中index0的entity被移除,index1和index2就会向前补位,index1的entity就会变成index0,index2的entity就会变成index1。
需要注意的是,虽然entity和component各自的数组有相同的index,但是component各个数组的size可能是不同的
Entity metadata Array
World的Entity manager通过“Entity metadata Array”来让我们通过Entity ID来查找Entity,每个Entity ID都指出了自己所在metadata Array Index,即Entity ID Index,此metadata Array[Entity ID index]
存储了一个指针和一个Index,该指针指向存储了这个Entity的Chunk,而Index则是这个Entity在Chunk中的Index。如果该Chunk在对应的Index没有Entity,那么metadata Array[Entity ID index]
指向这个Chunk的指针就会成为null。
通过Chunk的结构我们知道,一个Entity销毁了以后,剩下的部分Entity在Chunk内部的Index就会更新,也就是说,当前被销毁的Entity在Chunk内部的index会被重复利用。为了不出问题,在metadata Array[Entity ID index]
中还存储了一个Version Number,当Entity被销毁,它在Entity metadata Array中的Version就会改变(增量),所以当一个Entity ID的Version Number和metadata Array中的不一致时,就表示这个Entity被销毁了或者不存在。
Query
Query可以帮助我们高效地按照要查询的component种类来轮询对应的Chunks。
例如,如果我们创建了一个Query,这个Query想要轮询具有component A和component C的Chunks,那么Archetype A B C和Archetype A C都会轮询到。
当然我们也可以指定一个Query,轮询只包含component A和component C的Chunks,并不包含component B,那么只有Archetype A C会轮询到。
JobChunk和JobEntity
当需要用到轮询的操作,Job System就能够登场了。Unity Entities提供了IJobChunk
和IJobEntity
接口,前者用于创建轮询chunk的Query Job(在chunk内部,job System也会处理里面的entities和它们的components),后者用于创建轮询entities的Query Job,属于直接轮询entities,并不经过chunk。
大体上讲IJobChunk
并不方便使用,但是它显式提供了更底层的控制方法。
IJobEntity
更加方便,它依赖Source Generation来减少模板代码(Boilerplate),不过它在某些用例中可能不适用,必须使用IJobChunk
。
Baking
Entities并不能在Unity scene环境中工作,必须创建自己的Subscene,Baking可以在运行时转换一个GameObject,把它变成序列化的Entity,以供在Entities Subscene中使用。
一个GameObejct在Bake成为Entity之前可以通过Baking System进行一些可选的处理。
我们可以通过继承一个Baker
类来将GameObject下的Component转化为Enitity下的component。