ECS的简单入门(三):Component

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被销毁的逻辑如下:

  1. 根据要被销毁的EntityID找到所有相关的Component
  2. 删除这些找到的Component
  3. 回收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的内存大小。黄色线的位置即 当前数量 / 容量 的进度。

 

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值