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
    评论
Unity ECS(Entity Component System)是一种用于高性能游戏开发的系统,通过使用 ECS 可以优化游戏的性能并提高游戏开发的效率。下面是一个简单入门教程: 1. 创建一个 ECS 项目 首先,在 Unity 中创建一个新项目。在创建项目时,选择“High Definition RP(Preview)”模板,这将为你创建一个使用 ECS 的项目。 2. 创建实体(Entity) 在 ECS 中,实体是一个简单的标识符,用于标识游戏中的对象。在 Unity 中,可以通过使用实体组件(Entity Component)来创建实体。在 Unity 中,可以使用以下代码创建一个实体: ```csharp Entity entity = EntityManager.CreateEntity(); ``` 3. 创建组件(Component) 组件是实体的属性,它们包含实体的数据和行为。在 Unity ECS 中,组件是 C# 类,它们必须继承自 IComponentData 接口。以下是一个示例组件: ```csharp public struct Position : IComponentData { public float3 Value; } ``` 在这个示例中,Position 是一个包含 float3 类型变量 Value 的组件。 4. 添加组件到实体 要将组件添加到实体中,可以使用 EntityManager 的 AddComponent 方法。以下是一个示例代码,将 Position 组件添加到 entity 实体中: ```csharp EntityManager.AddComponentData(entity, new Position { Value = new float3(0, 0, 0) }); ``` 5. 创建系统(System) 系统是用于处理实体和组件的逻辑的代码。在 ECS 中,系统是 C# 类,它们必须继承自 ComponentSystem 类。以下是一个示例系统代码: ```csharp public class MoveSystem : ComponentSystem { protected override void OnUpdate() { Entities.ForEach((ref Position position) => { position.Value += new float3(0, 1, 0); }); } } ``` 在这个示例中,MoveSystem 是一个系统,它会处理所有带有 Position 组件的实体,并将它们的位置向上移动一个单位。 6. 将系统添加到 ECS 要将系统添加到 ECS 中,需要在 Unity 中创建一个 GameObject,并将其命名为“ECS Manager”。然后,将 ECS Manager 组件添加到 GameObject 中,并将 MoveSystem 添加到 ECS Manager 中。 7. 运行游戏 现在,可以启动游戏并查看实体和组件的效果。在这个示例中,应该会看到所有带有 Position 组件的实体都向上移动一个单位。 这里只是一个简单入门教程,Unity ECS 还有很多高级功能和概念需要学习。但是通过上面的步骤,你可以了解到 ECS 的基本概念,并开始使用 ECS 来开发高性能游戏。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值