Component概念
ECS中的Component和我们以往MonoBehaviour的Component不同,ECS的Component中,只用于存放数据,并不会有行为。操纵数据的行为我们交给了System来处理。
在ECS中,当一个Struct实现了以下接口之一,就可以称之为Component
- IComponentData
- IBufferElementData
- ISharedComponentData
- ISystemStateComponentData
- ISharedSystemStateComponentData
IComponentData
IComponentData是用于一般用途的组件接口,我们可以看下Unity自带的用于设置Entity位置信息的Translation Component的内容:
using System;
using Unity.Entities;
using Unity.Mathematics;
namespace Unity.Transforms
{
public struct Translation : IComponentData
{
public float3 Value;
}
}
可以看见里面只是纪录了一个float3的数据,用于表示坐标的x,y,z信息,简单明了。因此若我们想改变一个Entity的位置信息,只需要添加Translation Component,并且修改其Value值,并不需要像GameObject那样有一个冗长的Transform组件来处理了。
IComponentData 结构体中不能包含引用类型的对象,因此不会产生GC,从而提高性能。
Unity.Mathematics
我们可以看见Translation中,用到的是float3,而不是Vector3。float3是Unity提供的数学库Unity.Mathematics中的类型。使用它的好处是可以直接映射到硬件的SIMD寄存器,可以提高我们的游戏性能,这是 Burst 相关知识,在这就不详细介绍了。我们只需知道在我们自己编写Component的时候,需要使用Unity.Mathematics中的类型。
ISharedComponentData
shared component是一种比较特殊的component。前面我们讲到不同的component组合为一个archetype,相同archetype的entity会被连续的分配在一个chunk中。然而若entity中含有shared component,那么会根据shared component中的值来进行区分entity,将值不相同的entity分配在不同的chunk中,但是不会导致archetype的改变。
举个例子,假设我们有两个component,C1,C2,其中C2为shared component,我们的entity都包含C1和C2两个component,且值全相同。此时只有一个archetype,也就是C1,C2的组合,这些entity都会被连续的分配在一个chunk中(除非这个chunk装满了)。若我们改变C1的值,在内存分配上并不会产生什么变化,但是如果我们改变shared component也就是C2的值,那么这些entity就会被分配到一个个新的chunk中,C2值相同的entity会在同一个chunk中。
因此我们不能滥用shared component,因为可能导致我们chunk的利用率变低,cache miss概率变高,从而性能降低。同样的,我们自己编写shared component的时候也要避免一些不是必须的字段,降低值的多样性。
同时shared component中我们可以使用引用类型的对象。Hybrid.rendering package中的Rendering.RenderMesh就是shared component,如下
[System.Serializable]
public struct RenderMesh : ISharedComponentData, IEquatable<RenderMesh>
{
public Mesh mesh;
public Material material;
public int subMesh;
[LayerField] public int layer;
public ShadowCastingMode castShadows;
public bool receiveShadows;
public bool Equals(RenderMesh other)
{
return
mesh == other.mesh &&
material == other.material &&
subMesh == other.subMesh &&
layer == other.layer &&
castShadows == other.castShadows &&
receiveShadows == other.receiveShadows;
}
public override int GetHashCode()
{
int hash = 0;
if (!ReferenceEquals(mesh, null)) hash ^= mesh.GetHashCode();
if (!ReferenceEquals(material, null)) hash ^= material.GetHashCode();
hash ^= subMesh.GetHashCode();
hash ^= layer.GetHashCode();
hash ^= castShadows.GetHashCode();
hash ^= receiveShadows.GetHashCode();
return hash;
}
}
注:编写shared component除了需要实现ISharedComponentData接口,同时还要实现IEquatable<T>接口和重写GetHashCode方法。
使用RenderMesh,EntityManager会把组件相同但是mesh或者material等不同entity就会被分在不同的类别中,这样可以提高我们的渲染效率(将相同值的物体放在一起渲染是最有效的)
IBufferElementData
我们可以使用IBufferElementData接口来实现一个dynamic buffer component,它可以给Entity关联上类似Array的数据。
个人觉得直接看着代码再理解会比先看一堆枯燥的介绍更好理解,所以先直接贴一段代码:
public class DynamicBufferTest : MonoBehaviour
{
EntityManager entityManager;
void Start()
{
entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
Entity e = entityManager.CreateEntity(typeof(TestBufferElement));
DynamicBuffer<TestBufferElement> dynamicBuffer = entityManager.GetBuffer<TestBufferElement>(e);
dynamicBuffer.Add(5);
dynamicBuffer.Add(15);
dynamicBuffer.Add(2);
// 如果没有自定义的隐式转换,就需要使用下面方法添加
// dynamicBuffer.Add(new TestBufferElement(){Value = 2});
foreach (var buffer in dynamicBuffer)
{
Debug.Log("buffer:" + buffer.Value);
}
dynamicBuffer.RemoveAt(0);
Debug.Log("dynamicBuffer[0]:" + dynamicBuffer[0].Value);
}
}
[InternalBufferCapacity(8)]
public struct TestBufferElement : IBufferElementData
{
public int Value;
//自定义隐式转换,方便使用(不是必要的)
public static implicit operator int(TestBufferElement e)
{
return e.Value;
}
public static implicit operator TestBufferElement(int e)
{
return new TestBufferElement { Value = e };
}
}
运行起来,利用Entity Debugger来看下我们生成的Entity
秒懂有没有,可以说表面上就是List的功能,DynamicBuffer<DynamicBufferComponent> 使用起来就和List<T>没啥区别。
首先,我们先来看看DynamicBufferComponent的定义,继承IBufferElementData即可。同时我们还自定义了隐式转换来方便使用。在声明DynamicBufferComponent的时候我们可以给其添加InternalBufferCapacity属性,它可以定义我们存储在Chunk中DynamicBuffer的元素长度。如果我们添加的元素超过了这个长度,那么DynamicBuffer将不会存储在Chunk中,而是被整个提取到堆中。ECS会帮我们自动管理在外部的Buffer内存,当DynamicBufferComponent被溢出的时候会释放这块内存。
接着就是为我们的Entity添加上DynamicBufferComponent了,从代码中我们可以看见,可以使用Archetype在CreateEntity的时候就添加上,然后再使用EntityManager.GetBuffer() 方法来获取到DynamicBuffer。除此以外我们还可以使用EntityManager.AddBuffer()方法来添加以及获取,类似EntityManager.AddComponentData()方法:
Entity e = entityManager.CreateEntity();
DynamicBuffer<TestBufferElement> dynamicBuffer = entityManager.AddBuffer<TestBufferElement>(e);
注:多个带有DynamicBufferComponent的Entity,只要他们的Archetype相同,那么即使DynamicBufferComponent的元素长度不一样,那么也是存放在同一个Chunk中。
需要注意的是每个structural change都会导致我们所有的DynamicBuffer的引用无效(有关structural change的内容可以看第五篇)。
Entity e = entityManager.CreateEntity();
Entity e1 = entityManager.CreateEntity();
DynamicBuffer<TestBufferElement> dynamicBuffer = entityManager.AddBuffer<TestBufferElement>(e);
DynamicBuffer<TestBufferElement> dynamicBuffer1 = entityManager.AddBuffer<TestBufferElement>(e1);
dynamicBuffer.Add(5);
例如这段代码,由于第四行操作属于structural change,因此第三行获取到的DynamicBuffer的引用就被无效化了。因此第五行执行的时候就会报错
InvalidOperationException: The NativeArray has been deallocated, it is not allowed to access it
ISystemStateComponentData,ISystemStateSharedComponentData
试想一种情况,我们有个Component,有多个System会操作它,System1修改了Component的值,System2在Component值改变的时候需要做一些操作,那么System2如何才能知道这个值被修改了呢?
可能我们会想到之前提到过的ChangeFilter设置,但由于这是针对整个Chunk的Changed标识,内部其实可能很多值都是未改变的,若都执行相应的改变操作代价会很大。
在以往的MonoBehaviour中,我们可能会这样做,建一个相同的变量,名为Previous,初始化的时候值和当前的值相同。在Update方法中检测当前值是否和Previous值相等,若不相等则说明当前值发生了变化。
SystemStateComponent的设计理念就和这个类似。除了可以判断Component值的改变外,我们还可以用于判断Component是否是新增的,或者是销毁的。
在ECS库中的Parent.cs就可以很好的当个例子,我们来看下
public struct Parent : IComponentData
{
public Entity Value;
}
public struct PreviousParent : ISystemStateComponentData
{
public Entity Value;
}
Parent即为普通的Component,而PreviousParent就是我们的SystemStateComponent(继承ISystemStateComponentData),然后在ParentSystem.cs中,我们来看看具体如何来实现
判断Component是新增的
如果一个Entity拥有Parent但是却没有PreviousParent,那么就可以证明,这个Parent是新增的,然后执行相应的操作并添加PreviousParent
m_NewParentsGroup = GetEntityQuery(new EntityQueryDesc
{
All = new ComponentType[]
{
ComponentType.ReadOnly<Parent>(),
......
},
None = new ComponentType[]
{
typeof(PreviousParent)
},
Options = EntityQueryOptions.FilterWriteGroup
});
例如我们在开发的时候,想把EntityA放到EntityB下,我们会写一个System,给EntityA添加Parent组件,其值为EntityB,这样就可以实现了。但其实内部很多操作例如修改Translation的值等都是ParentSystem通过上面的方法找到新增Parent的EntityA来为我们操作的。
判断Component被删除
和新增的相反,若一个Entity拥有PreviousParent但是却没有Parent,说明Parent被删除了,然后执行相应的操作并删除PreviousParent
m_RemovedParentsGroup = GetEntityQuery(new EntityQueryDesc
{
All = new ComponentType[]
{
typeof(PreviousParent)
},
None = new ComponentType[]
{
typeof(Parent)
},
Options = EntityQueryOptions.FilterWriteGroup
});
判断Component的值被修改
对比两个Component的值即可,当然同时利用Chunk.DidChange方法可以更好的提高效率,来看一下GatherChangedParents Job
struct GatherChangedParents : IJobChunk
{
......
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
if (chunk.DidChange(ParentType, LastSystemVersion))
{
......
for (int j = 0; j < chunk.Count; j++)
{
if (chunkParents[j].Value != chunkPreviousParents[j].Value)
......
当Entity被销毁
其实Entity被销毁就和前面的被删除差不多。正常情况下一个Entity被销毁的逻辑如下:
- 根据要被销毁的EntityID找到所有相关的Component
- 删除这些找到的Component
- 回收EntityID以供后续新的Entity使用
但是Entity上的SystemStateComponent不会在前面的操作中被删除,同时EntityID也不会被里面回收,直到这些SystemStateComponent被删除。此时就会走到前面被删除的流程(无Component但还有SystemStateComponent)。
SystemStateSharedComponent
继承ISystemStateSharedComponentData的Component为SystemStateSharedComponent,其关系类似于SharedComponent和Component,有关引用的数据我们要使用SystemStateSharedComponent。
示例
了解了Entity和Component后,我们就来上手试试看,简单的在场景中创建一个Cube吧。
根据前面了解的知识,我们一个Cube就是一个Entity,创建Entity则需要利用EntityManager来实现,而EntityManager可以从默认的World中获得。
要渲染一个Cube在场景中,那就需要Mesh和Material信息,MeshRender组件可以为我们实现渲染(同时还必须添加LocalToWorld组件)。要控制Cube的坐标信息可以利用Translation组件。我们可以将这些组件事先定义成一个Archetype。添加好组件后,设置其属性即可。实现代码如下:
public class CubeCreatorByECS : MonoBehaviour
{
public Mesh cubeMesh;
public Material cubeMaterial;
EntityManager m_entityManager;
EntityArchetype m_entityArchetype;
void Start()
{
m_entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
m_entityArchetype = m_entityManager.CreateArchetype(typeof(Translation), typeof(LocalToWorld), typeof(RenderMesh));
Entity entity = m_entityManager.CreateEntity(m_entityArchetype);
m_entityManager.SetComponentData(entity, new Translation {Value = new float3(0, 0, 1)});
m_entityManager.SetSharedComponentData(entity, new RenderMesh {mesh = cubeMesh, material = cubeMaterial});
}
}
将脚本挂载到场景中,关联上mesh和material后运行即可。这里为了方便测试,学习了System后,我们可以把创建相关的代码全部放到System的OnCreate方法当中,摒弃MonoBehavior。
运行后我们可以在场景中看见我们利用ECS创建的小方块了。同时在Hierarchy窗口中是没有GameObject指向这个Cube的,想要查看这个Cube的信息,我们利用Entity Debugger,如图:
这里简单的介绍下Chunk Info的面板,它根据Archetype来分类,我们点击每个Chunk,左边的Entity列表都会更新为当前选中的Chunk内的所有Entity。
右上角的数字(图中的1)代表当前Archetype所占用的Chunk数量,若Entity超过Chunk容量,或者Archetype中带有Share Component且有值不同,那么这个值也就是Chunk的数量都会增加。
右下角的数字(图中的95)代表当前Chunk的容量,即可以包含多少个该Archetype的Entity,由于Chunk的大小固定为16KB,所以也可以大致算出该Chunk下一个Entity的内存大小。黄色线的位置即 当前数量 / 容量 的进度。