Unity Dots的概念与使用方法

3,715 阅读30分钟

本文仅用于记录和分享学习过程,如有错误望各位指正

关于Dots

什么是Unity Dots

Dots全称Data-Oriented Tech Stack(面向数据的技术栈),是Unity推出的以ECS为核心的高性能解决方案,通过利用SIMD和多线程发挥ECS的优势,主要包含以下三个部分

  • ECS:ECS(实体组件系统)是一种有别于传统OOP(面向对象思想)的编程模式,其编程模式对CPU Catch友好,因此可提升CPU效率
  • JobSystem:JobSystem提供了一种与Unity协作的的多线程代码编写方式,使用多线程可以有更好的性能表现
  • Brust Compiler:Brust提供了一种新的编译方式,其使用了LLVM将IL/.Net字节码优化

为什么要用Dots

  • 面向数据的编程方式更利于逻辑解耦
  • 内存管理不使用托管堆,避免GC
  • 在进行大量数据运算时性能更好
  • 为Unity提供了更方便的多线程编程方式
  • 基于Brust的优化编译效率更高
  • 据Unity称未来Dots将取代现有的工作流

Dots的缺点

  • 需要使用面向数据的编程方式,与OOP截然不同,需要一定的思维变换
  • 大多数的数据为值类型,对于拷贝或移动处理较为麻烦
  • Dots目前仍在早期版本,很多部分不完善且不稳定

什么时候用Dots

Dots适用于大批量数据运算的场景,但目前还并不适合在整个游戏中使用,现阶段可使用Dots解决项目中的性能问题

ECS-实体组件系统

概述

ECS全程Entity Component System(即实体组件系统),是一种有别于传统OOP思想的新的设计框架,提供了一种新的更新游戏数据的模型,最早由守望先锋项目应用,也是Unity Dots的核心部分,主要由Entity、Component、System三个部分组成

Entity

ECS中的 "E" 即Entity(实体),其相当于特定Component数据组合的ID(不包含任何数据/行为),用来索引特定的Component数据组,可以认为一个Entity是由数个Component实例组成的,如果以传统的OOP比喻,Entity就相当于类创建出的实例对象指针,指向真正存储的数据地址

传统的对象既存储数据也包含行为,而ECS将数据与行为拆分开,分别由Component和专门的System处理,减少了大量的对象间层级关系,这不仅利于逻辑解耦,也能避免读取不需要的数据占用缓存

Component

ECS中的 "C" 即Component(组件),其存储ECS系统中的所有数据,但不包含行为/方法,另外由于ECS对紧密排列数据的需求,Component中存储数据的类型仅可是非托管类型(因为托管类型的存储位置不可控)

按照一般约定,每组可能单独使用的属性都会定义为一个Component,例如位置、速度等,除此之外,还可以让Component作为System过滤的Tag使用

Component一般以结构体定义,且仅包含值类型,如下

public struct Velocity : IComponentData
{
    public float3 Value;
}

System

ECS中的 "S" 即System(系统),其定义更新数据状态的行为,将筛选出所有具有一组特定的Component的Entity并根据方法更新指定的Component中的数据

System一般只执行一个行为,只收集与该行为(或可能的多个行为)有关的数据

例如,可定义一个管理移动的System来遍历所有拥有Translation和Speed组件的Entity,并每帧按照速度乘以上一帧的时间间隔更新这些实体的位置,下图说明了System的执行过程:

ECSBlockDiagram.png

这个System会遍历所有具有Translation、Rotation和LocalToWrold组件的Entity,然后将前两个Component的值相乘来更新LocalToWorld的值,如果一个实体只拥有Translation和Rotation,那么它就不会被此System筛选到(因为没有LocalToWorld)

System具有类似Mono的生命周期,会在OnUpdate中每帧遍历和更新数据,如下

public class ECSSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // Local variable captured in ForEach
        float dT = Time.DeltaTime;
​
        Entities
            .WithName("Update_Displacement")
            .ForEach(
                (ref Position position, in Velocity velocity) =>
                {
                    position = new Position()
                    {
                        Value = position.Value + velocity.Value * dT
                    };
                }
            )
            .ScheduleParallel();
    }
}

Archetype

Archetype(原型)是Unity在Dots中对ECS的独特概念定义,用来管理数据在内存中的密集分布

简单来说,特定的Component类型组合即为Archetype,如果以传统的OOP比喻,Archetype就相当于类,作为对象(Entity)的模板,不过一般来说Entity并非由Archetype创建,只是拥有相同Component组的Entity会被归类为同一种Archetype(当然也可以先 指定/创建 Archetype类型并创建Entity),并且在内存中根据Archetype分类,Entity所属的Component数据会像数组一样密集的存储在对应的Archetype所对应的内存中

当一个Entity所持有的Component组发生改变时(不管是添加还是移除Component),其所代表的Archetype就会发生变化,存储位置也会相应的变化,移动到新的Archetype所在的Chunk组中,例如将下图的EntityB的Renderer组件移除,它就将移动到ArchetypeN所拥有的内存空间下,EntityB所属的原型也会变为ArchetypeN

ArchetypeDiagram.png

可通过Archetype这一概念快速的遍历所有同类的Entity所在的内存块,获取对应的Component数据

关于ECS数据友好

CPU的处理速度非常快,但从内存中读取数据却没那么快,很多时候的卡顿只是因为CPU处理完了数据等待读取下一批数据造成的CPU闲置,为此CPU设计了三级高速缓存(Catch),从缓存区读取数据很快,能够减少CPU闲置时间;若从缓存区没能找到数据(Catch Miss)再从内存中读取并写入缓存中以便下次利用

从这点来看传统的面向对象设计其实对CPU从Catch读取数据并不友好,因为往往并不需要一个对象的全部数据,比如想操控一个GameObject的Position数据却要读取整个GameObject和其继承的MonoBehaviour的数据,大量的不需要的数据被写入CPU Catch,就会造成频繁的Catch Miss;另外,托管类型的存储空间排列分散,寻址到所需的数据也需要一定时间

而在ECS下的System处理数据时只会读取需要的数据,同时Dots独特的存储机制让读取数据也更加快速,Dots将内存块分为Chunk,相同Archetype的Entity所拥有的数据会紧密存储在由Archetype分类的一组Chunk中,Dots会动态的添加或删除Chunk来适应Entity数量,如下图

ArchetypeChunkDiagram.png 每一个特定Component组的数据都占用相同的空间,因此可以在Chunk中紧密排列,让System遍历特定Component组更加快速,同时Catch中缓存的数据也更加有效,不会频繁的Catch Miss

Archetype与Chunk是一对多的关系,因此筛选具有指定Component组的Entity只需搜索所有的Archetype而不是所有Entity,根据Archetype对应的Chunk组遍历可以大大减少搜索数量;另外,Archetype所拥有的内存块中的Entity还可以继续分组来更有效的搜索和处理,具体会在后面说明

JobSystem

概述

JobSystem封装了一种简单、安全、可与Unity交互的编写多线程代码的方式,提供调度、并行处理和保证多线程安全,也可在非ECS的架构中使用

JobSystem与Unity原生的JobSystem集合在一起,让JobSystem编写的代码与Unity共享,从而可以避免创建多于CPU核心数的线程导致CPU的资源竞争

原理

一般来说游戏引擎的逻辑只运行在主线程,因为需要严格确定数据更新顺序,很难创建更多线程来帮助处理,并且多线程适合处理一些较少且较大的任务,而游戏逻辑中的任务小且多(因为需要每帧执行),据此创建出的线程生命周期较短且数量众多,将造成频繁的上下文切换,因此Unity并不支持在传统多线程中使用大部分API,仅提供了协程(一种运行在主线程的异步方法),这其实是对多核CPU核心资源的浪费

上下文切换:在执行过程中保存一个线程的状态后转而去处理另一个线程,在执行完成后根据状态重建第一个线程继续处理逻辑

为解决上述的问题,JobSystem通过创建Job作为多线程操作数据的最小工作单位,Job不是一个线程而更像创建传统线程时所指定的方法,Job可以独立运行也可以依赖其他Job运行,JobSystem会将创建的Job放入Job队列中进行多线程调度

JobSystem会在除主线程所在核心的每个CPU核心上维护一个工作线程(可能会为操作系统或其他应用程序预留一些核心),并从Job队列中调度Job在这些线程上执行,JobSystem会自动调度每个线程上的Job数量以确保没有核心闲置,也会自动管理Job间的依赖关系让Job以正确的顺序执行

Job依赖:当Job A的运行需要另一个Job B运行后的数据,Job A就依赖于Job B,JobSystem会确保Job A不会在Job B运行完成前开始

Safety System

编写多线程代码时可能会出现竞争条件,也就是两个线程同时修改一个数据的情况,竞争条件不会让系统抛出异常,但会引起数据错误的问题,并且很难在调试中复现(因为断点或打印日志都有可能改变线程的时序),在传统的多线程编程中只能通过代码的设计尽量避免

JobSystem为了避免这个问题让每个Job只能获取到原数据的副本,也因此Job内部只能访问blittable类型的数据和NativeContainer容器,并且不应访问静态数据

但只对副本操作就会让操作结果作用域只在Job内部,为此JobSystem提供了名为NativeContainer的托管值类型容器,每个Job可通过对这种容器进行有限制的读写来与主线程共享数据(写入NativeContainer是Job唯一从主线程获取数据的方法),声明如下

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

默认情况下NativeContainer包含名为NativeArray的定长容器,也可以通过引入Unity.Collections命名空间包含其他类型的容器:

  • NativeList:可调整大小的NativeArray
  • NativeHashMap:key-value形式的容器
  • NativeMultiHashMap:一个key可对应多个value的容器
  • NativeQueue:队列形式的容器

所有的NativeContainer容器在声明时都需要通过Allocator枚举参数指定内存分配类型,每种类型适用的生命周期不同

  • Allocator.Temp:分配速度最快,适用于在一帧内的主线程执行逻辑,不能将此类容器传递给Job使用
  • Allocator.TempJob:分配速度稍慢,适用于生命周期最长四帧的逻辑,并具有线程安全性,大多数Job使用的容器是此类型的
  • Allocator.Persistent:分配速度最慢,并且可以在整个游戏生命周期一直存在,适用于持续时间长的Job

所有的NativeContainer容器作为托管类型都需要被手动释放,使用NativeContainer.Dispose()函数进行释放,如果不在对应的生命周期内释放会收到系统警告

JobSystem的安全系统会跟踪所有的NativeContainer容器操作,并进行安全检查(只会在编辑器模式下检查),包括:

  1. DisposeSentinel:检测内存泄漏,若未能及时的释放NativeContainer容器,则发出警告
  2. AtomicSafetyHandle:检查对容器的写行为,如果调度时同时存在多个Job可以对一个容器进行写操作则发出警告,可以通过设置依赖关系进行调度,或者对不需要写容器的Job使用[ReadOnly]属性只读标记相应的容器

使用方式

创建Job

通常情况下可通过创建实现接口的结构体创建一个Job,步骤如下

  1. 创建一个结构体,实现IJob接口,实现IJob接口的Execute()方法
  2. 声明该Job将用到的成员变量,必须为blittable或NativeContainer类型,通常都会至少包含一个容器用作数据输出
  3. Execute函数中声明方法体,实现该Job的逻辑

Execute函数会在被调度到时在某个核心的线程上执行一次

[BurstCompile]//可通过使用BurstCompile特性将Job代码用Burst优化编译
struct Job_1 : IJob
{
    [ReadOnly] public int a;
    [ReadOnly] public int b;
    [WriteOnly] public NativeArray<int> res;
​
    public void Execute()
    {
        res[0] = a + b;
    }
}

不要再Job中访问静态数据或分配托管类型,访问静态数据有潜在的风险,可能导致崩溃,而在Job中分配托管内存不仅非常慢,还无法使用Brust编译来提升性能

调度Job

调度Job只能在主线程执行,步骤如下

  1. 实例化Job结构体
  2. 为Job的数据和容器赋值
  3. 调用Schedule()方法

Schedule()方法会返回该JobHandel用于处理Job间的依赖关系,其他Job可以将JobHandle作为Schedule的参数用于表示依赖于该Job,这样JobSystem就能确保那些具有依赖关系的Job的执行顺序,如下

JobHandle handle = myJob_1.Schedule();
myJob_2.Schedule(handle);//myJob_2依赖于myJob_1,不会在myJob_1未结束前执行

调用Schedule方法会将该Job放入调度队列中,但不会立刻开始调度执行,而是通过在JobHandle上执行Complete()在缓存中刷新Job并启动调度执行,并且由于Container的唯一访问性,主线程在调用Complete函数后会等待直到所有Job执行完返还给主线程Container的所有权,如下列代码:

var handle_1 = myJob_1.Schedule();
var handle_2 = myJob_2.Schedule(handle_1);
​
handle_2.Complete();//Job间存在依赖关系时,只需执行依赖链最后的Job,前面的会因为依赖关系自动执行
//主线程等待直到myJob_1和myJob_2调度执行完

若只执行一个Job实际相当于没有并行(主线程->子线程->主线程,尽量将Job同时Complete让子任务并行(主线程->{子线程1,子线程2,......}->主线程);此外,还可以不使用Schedule()而使用Run()让Job立即运行在主线程,可用于快速调试逻辑

如果Job不需要访问Container,则可以使用JobHandle.ScheduleBatchedJobs()静态方法直接刷新调度Job队列,此方法不会让主线程进入等待,但调用此方法对性能有影响

下面是调度Job的示例:

struct Job_1 : IJob
{
    [ReadOnly] public int a;
    [ReadOnly] public int b;
    [WriteOnly] public NativeArray<int> res;
​
    //此Job将两个值相加
    public void Execute()
    {
        res[0] = a + b;
    }
}
​
struct Job_2 : IJob
{
    //默认为ReadWrite
    public NativeArray<int> res;
​
    public void Execute()
    {
        res[0]++;
    }
}
​
protected override void OnUpdate()
{
    //创建长度为1的容器储存结果
    NativeArray<int> array = new NativeArray<int>(1, Allocator.TempJob);
​
    //设置Job_1的数据并执行Schedule获取Handle
    var handle1 = new Job_1
    {
        a = 1,
        b = 2,
        res = array
    }.Schedule();
    //设置Job_2的数据并执行Schedule获取Handle
    //Job_2依赖于Job_1
    var handle2 = new Job_2
    {
        res = array
    }.Schedule(handle1);
    //等待Job_1和Job_2完成
    handle1.Complete();
    //从容器中取得结果
    int res = array[0];
    //释放为容器分配的内存
    array.Dispose();
}

ParallelFor Job

若需要在一个Job中对大量同类数据执行相同操作,可以使用ParallelFor Job,为此需要让Job结构体接口IJobParallelFor,并且结构体的内部结构会有一些变化

struct TestJob_3 : IJobParallelFor
{
    [ReadOnly] public NativeArray<int> a;
    [ReadOnly] public NativeArray<int> b;
    [WriteOnly] public NativeArray<int> res;
​
    public void Execute(int index)
    {
        res[index] = a[index] + b[index];
    }
}

ParallelFor Job会使用Nativtive Container作为输入数据,对于容器中的每一项输入数据都会执行一次Execute方法,迭代固定的次数,并且Execute方法会增加index参数,表明执行的数据源的当前索引位置来从容器中访问对应数据

在调度ParallelFor Job时,JobSystem会将所有需要执行的Execute方法分成数个Batch,在每个CPU核心的工作线程上自动调度适量的Job,并将Batch传递给Job来多线程执行Execute方法,其过程如下图:

jobsystem_parallelfor_job_batches.svg

在使用Schedule调度时需要指定需要操作的作为数据源的Container的长度,用来拆分需要的数据并指定Execute方法执行的次数;还需要指定batchsize,将根据size划分为不小于指定数量的Batch,

在使用Schedule调度时需要指定待操作的数据量arrayLength、batchsize以及可能需要的依赖

arrayLength指定了Execute执行的次数,一般会指定储存结果容器的长度

Unity 会自动将工作拆分为不小于所提供 batchSize 的块,并根据工作线程数、数组长度和批次大小安排适当数量的作业,对于执行较为繁重的工作应该使用较小的batchsize值 调度ParallelFor Job的流程如下:

NativeArray<float> a = new NativeArray<float>(2, Allocator.TempJob);
​
NativeArray<float> b = new NativeArray<float>(2, Allocator.TempJob);
​
NativeArray<float> result = new NativeArray<float>(2, Allocator.TempJob);
​
a[0] = 1.1;
b[0] = 2.2;
a[1] = 3.3;
b[1] = 4.4;
​
MyParallelJob jobData = new MyParallelJob();
jobData.a = a;  
jobData.b = b;
jobData.result = result;
​
// 调度作业,为结果数组中的每个索引执行一个 Execute 方法,且每个处理批次只处理一项
JobHandle handle = jobData.Schedule(result.Length, 1);
​
// 等待作业完成
handle.Complete();
​
// 释放数组分配的内存
a.Dispose();
b.Dispose();
result.Dispose();

ECS的常用API与使用方法

一些Dots独有的概念

全部概念请参照文档

World

World是管理Entity和System的基本单位,游戏中可以有多个World,每个World中的Entity只能在当前World使用(但可以将Entity转移到其他World),并且System也只能访问到当前World的Entity

Unity会在游戏启动时生成默认World,可通过World.DefaultGameObjectInjectionWorld静态方法访问,之后Unity会实例化所有的System并添加到默认World中

每个World都维护一个EntityManager和一组System,并提供创建、访问和删除System的方法,通常可以使用GetOrCreateSystem泛型方法获取或创建System

更多内容详见World

EntityManager

EntityManager管理当前World的所有Entity,可通过World.EntityManager方法访问,并提供 创建/获取/修改/销毁 Entity的方法,详见EntityManager

EntityManager的大多数方法都会导致Chunk中的结构变化,也就是Chunk中的Entity布局发生变化的情况,以下原因会导致这种变化

  • 创建/删除Entity
  • 向Entity中添加/删除Component
  • 更改ShardComponent的值

结构变化将导致Sync points(同步点)的产生,应尽量避免这种情况

Sync points(同步点)

如果内存中产生了结构变化,例如一个Entity被移动到了其他Chunk,这个Entity所在的内存位置就可能被置空或被其他Entity填充,此时如果一个并行的Job也在对这个Entity进行操作就可能发生错误(读取不到Entity或者读取到了错误的Entity),因此Dots引入了同步点的操作

同步点是程序运行中的一个点,当同步点因为某些原因产生时,主线程会阻塞,等待所有Job执行完毕以防止可能的错误,同步点会导致性能的下降,应尽量减少同步点的产生

Dots提供了EntityCommandBuffer(实体命令缓冲区,以下简称ECB)机制来解决这类问题,ECB会将产生结构变化的命令放入队列中,在之后的某个时刻同意执行,这会将多个同步点减少到单个,同时,也可为无法访问EntityManager的Job提供操作Entity的方法

即使全程在主线程进行结构性变化的修改,使用ECB也同样比EntityManager要快,因此尽量使用ECB;另外,在多线程代码中使用ECB时,需要将其转换成可并行的形式,并在使用方法时传入Entity的Index确保ECB能以正确的顺序回放

每个World都提供数个负责维护ECB的System,其更新时机不同(具体参照ECB System),以Begin和End作为前缀,分别在每帧开始和每帧结束做命令回放,应尽量使用以提供的这些System创建ECB;使用案例如下:

struct Lifetime : IComponentData
{
    public byte Value;
}
​
partial class LifetimeSystem : SystemBase
{
    EndSimulationEntityCommandBufferSystem m_EndSimulationEcbSystem;
    protected override void OnCreate()
    {
        base.OnCreate();
        // 获取ECB System并缓存下来已备后续使用
        m_EndSimulationEcbSystem = World
            .GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }
​
    protected override void OnUpdate()
    {
        // 创建一个ECB并转换为多线程模式,在多线程代码中需要使用这种转换
        var ecb = m_EndSimulationEcbSystem.CreateCommandBuffer().AsParallelWriter();
        Entities
            .ForEach((Entity entity, int entityInQueryIndex, ref Lifetime lifetime) =>
        {
            // lifetime.value归零时销毁Entity
            if (lifetime.Value == 0)
            {
                // 将entityInQueryIndex传给ECB,确保正确的回放顺序
                ecb.DestroyEntity(entityInQueryIndex, entity);
            }
            else
            {
                lifetime.Value -= 1;
            }
        }).ScheduleParallel();
​
        // 当在Job中使用ECB时,必须将该Job的Handle添加进ECB System的依赖项
        m_EndSimulationEcbSystem.AddJobHandleForProducer(this.Dependency);
    }
}

Entities

创建Entity

可以使用EntityManager创建Entity(包括ECB的形式)或将现有的GameObject(包括预制体和场景中的物体)转换为Entity

创建Entity的方式有以下几种:

  • 在创建Entity时指定ComponentType
  • 通过ComponentType创建Archetype或指定Archetype创建Entity
  • 创建空的Entity,在之后添加Component
  • 根据现有的Entity复制一个Entity,组件值也将一起复制
  • 指定 数量或容器 创建或复制 多个Entity

使用EntityManager创建Entity的代码如下

//创建时指定Component
manager.CreateEntity(typeof(Translation), typeof(Rotation));
​
//创建空的Entity,之后添加Component
Entity instance = manager.CreateEntity();
manager.AddComponent(instance, typeof(Rotation));
​
//指定Archetype创建Entity
EntityArchetype archetype = manager.CreateArchetype(typeof(Translation), typeof(Rotation));
manager.CreateEntity(archetype);
​
//复制一个Entity,包括组件的值
manager.Instantiate(instance);
​
//指定数量或容器创建多个Entity
NativeArray<Entity> entities = new NativeArray<Entity>(10, Allocator.Temp);
manager.CreateEntity(archetype, entities);
manager.CreateEntity(archetype, 10);
​
//指定数量或容器复制多个Entity
manager.Instantiate(instance, entities);
manager.Instantiate(instance, 10, Allocator.Temp);

使用ECB创建Entity:

ecb.CreateEntity(archetype);
ecb.AddComponent(instance, typeof(Translation));
ecb.Instantiate(instance);

此外,还可以通过转换GameObject的方式创建Entity

image-20220107102748138.png

如图所示,在Gameobject上挂载ConvertToEntity脚本,运行时会自动将该物体转换为实体并添加对应的Component,例如Transform组件会转换为Translation、Rotation和Scale组件添加给转换后的Entity,挂载于物体上的自定义Component同样添加给Entity,ConvertToEntity也将应用于所有子物体

ConvertToEntity脚本只有一个Enum选项,图中的选项ConvertAndDestory表示将Gameobject转换为Entity并销毁该Gameobject,另一个选项ConvertAndInjectObject在转换后不会销毁Gameobject,Gameobject与Entity将同时存在,并且此方式不会转换子物体

此外,还可以通过在Mono脚本中实现IConvertGameObjectToEntity接口来自定义转换,IConvertGameObjectToEntityConvert函数将应用于受ConvertToEntity影响的物体,可以在此函数中为转换后的Entity添加自定义Component,如下:

public class SpawnerAuthoring_FromEntity : MonoBehaviour, IDeclareReferencedPrefabs, IConvertGameObjectToEntity
{
    public GameObject Prefab;
    public int CountX;
    public int CountY;
​
    // 若需要将Prefab转换为Entity,则需要将Prefab添加进IDeclareReferencedPrefabs的队列中
    public void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs)
    {
        referencedPrefabs.Add(Prefab);
    }
​
    // 自定义转换步骤
    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        // 创建一个Component数据
        var spawnerData = new Spawner_FromEntity
        {
            // 使用GameObjectConversionSystem将Prefab转为Entity
            // Component可持有对Entity的引用
            Prefab = conversionSystem.GetPrimaryEntity(Prefab),
            CountX = CountX,
            CountY = CountY
        };
        // 为转换后的Entity添加Component
        dstManager.AddComponentData(entity, spawnerData);
    }
}

上面的代码展示了将Prefab转换为Entity的一种方法,这种方法通常用于自定义转换的流程中,转换为Entity后就可以使用EntityManager或ECB的Instance方法复制出一个新的Entity,下面展示另一种在Mono脚本中常用的转换方法:

GameObjectConversionSettings settings = new GameObjectConversionSettings(World.DefaultGameObjectInjectionWorld, GameObjectConversionUtility.ConversionFlags.AssignName);
bulletEntity = GameObjectConversionUtility.ConvertGameObjectHierarchy(bulletPrefab, settings);
var tmpEntity_1 = manager.Instance(bulletEntity);
var tmpEntity_2 = ecb.Instance(bulletEntity);

查询Entity

可以使用EntityQuery查询包含指定Component的Archetype,创建EntityQuery的方式如下

EntityQuery query = GetEntityQuery(typeof(Translation), ComponentType.ReadOnly<Rotation>());
//使用EntityQuery获取Entity填充容器
NativeArray<Entity> entityArray = query.ToEntityArray(Allocator.Temp);
//使用EntityQuery获取Component填充容器
NativeArray<Translation> translations = query.ToComponentDataArray<Translation>(Allocator.Temp);
//返回指定Entity所在的Chunk数组
NativeArray<ArchetypeChunk> archetypeChunks = query.CreateArchetypeChunkArray(Allocator.Temp);

尽量使用ComponentType.ReadOnly来表示该类型只读,因为对数据读取的访问限制较少,应用于Job时会更有效率

对于复杂的查询,可以声明EntityQueryDesc作为EntityQuery的参数,其提供更为灵活的过滤器选项

  • All:Archetype中必须包含所有此项所指定的Component
  • Any:Archetype中必须至少包含一种此项所指定的Component
  • None:Archetype中不可包含任何此项所指定的Component

使用EntityQueryDesc作为查询选项的示例如下:

//查询包含Translation与Rotation的,包含Scale或LocalToWorld的,不包含MovementSpeed组件的Archetype
EntityQueryDesc desc = new EntityQueryDesc
{
    All = new ComponentType[] { typeof(Translation), typeof(Rotation) },
    Any = new ComponentType[] { typeof(Scale), typeof(LocalToWorld) },
    None = new ComponentType[] { typeof(MovementSpeed) }
};
GetEntityQuery(desc);
//可使用多个Desc构建Query
var query = GetEntityQuery(new EntityQueryDesc[]{desc_1, desc_2});

在System类外部时,可使用EntityManager构建EntityQuery:

var query = manager.CreateEntityQuery(typeof(Translation), ComponentType.ReadOnly<Rotation>());

Component

创建通用Component

可通过声明一个实现IComponentData的struct来创建一个普通的Component,注意尽量避免声明托管类型的数据,这将导致无法使用ECS特有的内存管理机制

//可通过GenerateAuthoringComponent特性将Component作为组件挂载于游戏物体上
[GenerateAuthoringComponent]
public struct TestComponent : IComponentData
{
    public int value;
}

此外,可通过使用GenerateAuthoringComponent特性允许Component以组件的形式挂载于GameObject上,如下图:

image-20220108191747846.png

挂载于GameObject上的Component在该物体转换为Entity时会一并附加到转换后的Entity上,其在面板上设定的数值也会保存,注意:每个脚本只能有一个由GenerateAuthoringComponent特性修饰的Component

SharedComponent

SharedComponent是Dots对Component的独特概念,可通过struct实现ISharedComponentData声明一个SharedComponent

SharedComponent不同于普通的Component每个Entity都持有一份数据,如它的名字一样这种组件由一组Entity共享数据,也因此其并非存储在Chunk上,而是存储在其他位置(所以SharedComponent可以并且也应该用于存储托管类型数据),但SharedComponent的数据会影响Entity在Chunk上的位置

挂载于Entity上的SharedComponent也同一般的Component一样由其类型组合确定一个唯一的Archetype,并由Archetype管理存储属于该Archetype的Entity的一组Chunk,增加/删除SharedComponent同样会导致Entity所属的Archetype发生变化;但如果Entity挂载了SharedComponent,Entity还会基于SharedComponent的数据进一步分组,并由此分组将Archetype下的一组Chunk分组,不同分组的Entity存储于不同的Chunk组中

例如EntityA的SharedComponent的值为1,EntityB的值为2,那么EntityA于EntityB就将存储于不同的Chunk中,即使EntityA所在的Chunk还有剩余空间(因此使用大量的SharedComponent可能导致内存利用率不高

普通的Component存储每个Entity独特的数据,例如世界坐标,而如果多个Entity都持有相同的数据时,就可以使用SharedComponent

可以如下的形式声明SharedComponent:

public struct TestSharedComponent : ISharedComponentData
{
    public Material material;
}

你可以在构建一个EntityQuery时使用AddSharedComponentFilter函数筛选具有指定数值SharedComponent的一组Entity(因为它们本身就存储于同一组Chunk中,所以检索效率并不差),如下:

EntityQuery entityQuery = GetEntityQuery(typeof(Translation), ComponentType.ReadOnly<Rotation>());
entityQuery.AddSharedComponentFilter(new TestSharedComponent { material = null });

注意:SharedComponent的值应该是很少更改的,若一个Entity的SharedComponent值发生改变,这个Entity就会转移到另一个数值所分组的Chunk(若没有该Chunk则新建),因此频繁的更改SharedComponent的值会导致大量的同步点产生

DynamicBufferComponent

DynamicBufferComponent(动态缓冲区组件)是一个特殊的Component,其允许挂载的Entity容纳可变数量的元素。通过实现IBufferElementData接口来定义一个DynamicBufferComponent,并在其中声明需要容纳的元素类型,如下:

[InternalBufferCapacity(10)]
public struct TestBufferComponent : IBufferElementData
{
    public int value;
}

可使用InternalBufferCapacity特性指定该缓冲区的容量,在存储的元素数量未超出该容量时缓冲区所存储的数据与其他Component一同存储在Chuck中,超出容量后会在Chunk外部开辟一块内存并将数据转移

DynamicBufferComponent也视为一个通常的Component,与其他挂载于Entity的Component共同组成该Entity所属的Archetype,但为Entity添加缓冲区与增加/删除元素需要使用一组特殊的API

可使用以下方法为Entity添加DynamicBufferComponent:

//创建时指定Archetype
manager.CreateEntity(typeof(TestBufferComponent));
//为已有的Entity添加DynamicBufferComponent
manager.AddBuffer<TestBufferComponent>(instance);
//使用ECB添加DynamicBufferComponent,由于这类操作会造成同步点,使用ECB的效率更高
ecb.AddBuffer<TestBufferComponent>(instance);

获取缓冲区的API会返回一个DynamicBuffer<T>的对象,对此对象的操作类似List<T>,此对象为引用类型,所有操作会映射到存储位置,如下:

//使用EntityManager获取缓冲区
DynamicBuffer<TestBufferComponent> buffer = manager.GetBuffer<TestBufferComponent>(instance);
//使用ECB获取缓冲区
var ecb_buffer = ecb.SetBuffer<TestBufferComponent>(instance);
buffer.Add(new TestBufferComponent { value = 1 });
buffer.RemoveAt(0);
buffer.Insert(0, new TestBufferComponent { value = 2 });
ecb_buffer.Add(new TestBufferComponent { value = 3 });
//还可通过Entities.Foreach获取缓冲区
Entities.ForEach((DynamicBuffer<TestBufferComponent> buffer) =>
{
    for (int i = 0; i < buffer.Length; i++)
    {
        buffer[i] = new TestBufferComponent { value = buffer[i].value + i };
    }
}).Schedule();

还可将获得的缓冲区重新解释为对应存储类型的泛型容器,对重新解释后的容器的操作更为便捷,并且引用与原始的数据,所有修改会同步到原始数据中,如下:

DynamicBuffer<int> intBuffer = buffer.Reinterpret<int>();
intBuffer.Add(1);

由于动态缓冲区引用于Chunk内存,因此所有涉及结构更改的操作都会使动态缓冲区的引用失效,此时应重新获取动态缓冲区,如下:

var entity1 = EntityManager.CreateEntity();
var entity2 = EntityManager.CreateEntity();
​
DynamicBuffer<MyBufferElement> buffer1
    = EntityManager.AddBuffer<MyBufferElement>(entity1);
// 此操作引起结构性变化
DynamicBuffer<MyBufferElement> buffer2
    = EntityManager.AddBuffer<MyBufferElement>(entity1);
// 导致此操作失效
buffer1.Add(17);

其他类型的Component

Dots还定义了其他独特的Component类型,如下:

  • SystemStateComponent:ECS在删除Entity时不会删除SystemStateComponent于回收EntityID,用于跟踪system内部资源,并根据需要创建和销毁这些资源从而无需依赖回调
  • ChunkComponent:根据ChunkComponent的数值进行Chunk分组(于SharedComponent一致),单个Chunk具有唯一值

目前还未接触到到上述组件的实际应用场景,之后会继续完善这些条目,详情可参照文档

System

可通过实现抽象类SystemBase来实现一个标准的System,SystemBase拥有类似MonoBehaviour的生命周期回调函数

  • OnCreat:在创建系统时调用,类似于Start
  • OnStartRunning:在第一个OnUpdate帧前执行。每次系统的恢复都会执行,类似OnEnable
  • OnUpdate:每帧执行,类似Update
  • OnStopRunning:在系统停止更新时执行,每次取消系统启用时或查询不到其所需要的数据时调用,在OnDestory前也会调用
  • OnDestory:销毁系统时调用

其中除了OnUpdate是必须实现的回调方法,其他生命周期回调都是可选实现的,一个标准的System代码如下:

public class TestSystem : SystemBase
{
    protected override void OnUpdate() { }
}

一个System最主要的功能就是迭代其所需的Entity并更新Component数据,Dots提供了几种迭代Entity的方式

Entities.Foreach

Entitie.Foreach是在System中最常见的的迭代方式,由SystemBase提供,其依靠Lambda表达式执行,根据Lambda参数类型构建EntityQuery并由此获取到所需的Entity,如下所示:

protected override void OnUpdate()
{
    // 获取同时具有Translation和Velocity组件的Entity,并根据速度每帧更新位置
    Entities
        .ForEach((ref Translation translation, in Velocity velocity) =>
        {
            translation.Value += velocity.Value;
        }).Schedule();
}

Lambda表达式的参数必须由refin关键字修饰,使用ref关键字修饰的Component具有读写权限,使用in关键字修饰的只有读权限,尽量对无需修改数据的Component给予只读权限能够更有效的执行Job

上文使用Schedule运行,这实际上构建了一个Job来进行多线程运行,Foreach提供了三种可选的执行方式:

  • Run:立即在主线程执行
  • Schedule:构建一个Job并开始多线程调度
  • ScheduleParallel:根据Chunk数量构建Job并开始调度,每个Job处理一个Chunk

可通过添加数个With语句来增加Lambda表达式构建的EntityQuery条件,于EntityQueryDesc相似:

  • WithAll: 除了Lambda表达式中指定的Component类型,搜索的Entity必须包含WithAll包含的类型
  • WithAny: 搜索的Entity必须包含WithAny中至少一种Component
  • WithNone: 搜索的Entity必须不可包含WithNone中的所有Component种类
  • WithChangeFilter: 仅搜索自上次更新以来可能被更改了Component数据的Entity(只要被可写访问就被标记为已更改,无论是否确实更改了数据)
  • WithSharedComponentFilter: 搜索具有指定SharedComponent数值的Entity
  • WithStoreEntityQueryInField: :将Entitie.Foreach所构建的EntityQuery缓存到本地,这个步骤会在创建系统时(而不是执行Foreach时)执行,因此可以通过提前缓存的EntityQuery获取一些信息,例如查询即将迭代的Entity数量

With语句的使用如下:

EntityQuery query = default(EntityQuery);
int num = query.CalculateEntityCount();
Entities
    .WithAll<LocalToWorld>()
    .WithAny<LocalToParent, Scale>()
    .WithNone<RenderBuffer>()
    .WithChangeFilter<Translation>()
    .WithSharedComponentFilter<TestSharedComponent>(new TestSharedComponent { material = null })
    .WithStoreEntityQueryInField(ref query)//必须使用ref关键字修饰
    .ForEach((ref Translation tran, in Rotation rota) =>
    {
​
    }).Schedule();

此外,Entities.Foreach还提供了另外的一些With语句用来设置某些选项:

  • WithName:将根据Foreach生成的Job设置为指定的名字,方便在Debug时找到此Job

  • WithoutBurst:Foreach生成的Job默认使用Burst,可使用此项关闭Burst编译来使用一些Burst不支持编译的代码(例如访问托管资源)

  • WithBurst(FloatMode, FloatPrecision, bool) :设置Burst编译的选项

    • floatMode:设置浮点数优化模式,默认使用Strict,详见文档
    • floatPrecision:设置浮点数精度,详见文档
    • synchronousCompilation:是否立即编译函数而不是调度时编译

下图展示了Entities.Foreach支持的功能以及使用Burst编译所不支持的功能:

image-20220109140814944.png

除了Component之外还可将一些特殊的参数用于Lambda表达式,代表当前处理的Entity的一些属性,这些参数不用refin参数修饰

  • Entity entity:当前处理的Entity
  • int entityInQueryIndex:当前处理的Entity在搜索出的所有Entity中的索引值,一般用于ECB添加命令
  • int nativeThreadIndex:当前执行的线程的索引值,当使用Run()运行在主线程时始终为0

使用这些参数的场合,除了Entity不用指定特定的名称,其他的参数必须指定为上文的名称

Job.WithCode

SystemBase提供了Job.WithCode方法来简便的创建和调度单个Job,Job.WithCode使用单个无参数于返回值的方法构建一个Job(一般使用Lambda表达式),并使用Run()方法在主线程执行或Schedule()方法作为一个Job开始调度

当使用Run()方法在主线程执行时,可以将数据存储在本地,若作为Job执行,则于一般Job相同,只能使用NativeContainer或blittable类型来缓存数据,当然,也可以向NativeContainer存储数据作为返回值

一个标准的Job.WithCode如下:

public class RandomSumJob : SystemBase
{
    private uint seed = 1;
​
    protected override void OnUpdate()
    {
        Random randomGen = new Random(seed++);
        NativeArray<float> randomNumbers
            = new NativeArray<float>(500, Allocator.TempJob);
​
        Job.WithCode(() =>
        {
            for (int i = 0; i < randomNumbers.Length; i++)
            {
                randomNumbers[i] = randomGen.NextFloat();
            }
        }).Schedule();
​
        // To get data out of a job, you must use a NativeArray
        // even if there is only one value
        // 想要在job外获取data,你必须使用一个本地化数组
        // 即使他只有一个值
        NativeArray<float> result
            = new NativeArray<float>(1, Allocator.TempJob);
​
        Job.WithCode(() =>
        {
            for (int i = 0; i < randomNumbers.Length; i++)
            {
                result[0] += randomNumbers[i];
            }
        }).Schedule();
​
        // This completes the scheduled jobs to get the result immediately, but for
        // better efficiency you should schedule jobs early in the frame with one
        // system and get the results late in the frame with a different system.
        // 这个语句会立即完成调度来获取结果,但是为了更好的效率,你应该在这一帧的早些时候使用一个system调度这个job,并且在这一帧的晚些时候使用另一个system获取结果
        this.CompleteDependency();
        UnityEngine.Debug.Log("The sum of "
            + randomNumbers.Length + " numbers is " + result[0]);
​
        randomNumbers.Dispose();
        result.Dispose();
    }
}

SystemBase默认使用其Dependency属性管理当前System中多线程代码的依赖关系,SystemBase会按照Entities.ForeachJob.WithCode出现的顺序将构建出的Job添加进Dependency中(如果它们使用多线程形式运行)