ECS 系统

系统

系统是每帧在主线程上运行一次的代码单元。系统被组织成系统组的层次结构,您可以使用这些系统组来组织系统更新的顺序。有关 ECS 中系统基础知识的更多信息,请参阅系统概念。

标题 描述
使用SystemBase创建系统 有关如何使用SystemBase创建系统的信息。
迭代数据 描述了可以迭代系统中数据的各种方法。
系统更新顺序 有关系统更新顺序以及如何使用系统组控制更新顺序的信息。
使用作业在多个线程上调度数据 有关如何在系统中使用作业的信息。
使用EntityQuery查询实体数据 有关使用EntityQuery查询实体数据的信息。
使用EntityCommandBuffer调度数据更改 使用命令缓冲区来延迟对数据的更改。
查找任意数据 有关如何查找任意实体数据的信息。
写入组 使用写入组覆盖系统的数据。
版本号 使用版本号来检测潜在的变化。

使用 SystemBase 创建系统

要创建托管系统,请实现抽象类 SystemBase

您必须使用 OnUpdate 系统事件回调,来添加您的系统必须在每一帧执行的工作。ComponentSystemBase 命名空间中的所有其他回调方法都是可选的。

所有系统事件都在主线程上运行。最佳做法是使用 OnUpdate 方法来安排作业来执行大部分工作。要从系统安排作业,您可以使用以下机制之一:

  • Entities.ForEach:遍历组件数据。
  • Job.WithCode:将 lambda 表达式作为单个后台作业执行。
  • IJobEntity:迭代多个系统中的组件数据。
  • IJobEntityBatch:按原型块迭代数据。

以下示例说明了使用 Entities.ForEach 来实现一个系统,该系统根据一个组件的值更新另一个组件:

public struct Position : IComponentData
{
    public float3 Value;
}

public struct Velocity : IComponentData
{
    public float3 Value;
}

[RequireMatchingQueriesForUpdate]
public partial class ECSSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // `ForEach` 中捕获的局部变量
        float dT = SystemAPI.Time.DeltaTime;

        Entities
            .WithName("Update_Displacement")
            .ForEach(
                (ref Position position, in Velocity velocity) =>
                {
                    position = new Position()
                    {
                        Value = position.Value + velocity.Value * dT
                    };
                }
            )
            .ScheduleParallel();
    }
}

回调方法顺序

SystemBase 中有几个回调方法,Unity 在系统创建过程中的不同点调用,您可以使用它们来安排系统必须在每一帧执行的工作:

  • OnCreate:创建系统时调用。
  • OnStartRunning:在第一次调用 OnUpdate 之前以及系统恢复运行时调用。
  • OnUpdate:只要系统有工作要做,就会在每一帧调用。有关确定系统何时有工作要做的因素的更多信息,请参阅 ShouldRunSystem
  • OnStopRunning:在 OnDestroy 之前调用。每当系统停止运行时也会调用,如果没有实体与系统的 EntityQuery 匹配,或者如果系统的 Enabled 属性设置为 false,就会发生这种情况。
  • OnDestroy:系统被销毁时调用。

下图说明了系统的事件顺序:
回调方法顺序
父系统组的 OnUpdate 方法触发其组中所有系统的 OnUpdate 方法。有关系统如何更新的更多信息,请参阅系统的更新顺序。

迭代数据

迭代数据是创建系统时需要执行的最常见任务之一。系统通常处理一组实体,从一个或多个组件读取数据,执行计算,然后将结果写入另一个组件。

迭代实体和组件的最有效方法是在按顺序处理组件的作业中。这利用了所有可用内核和数据局部性的处理能力来避免 CPU 缓存未命中。

本节介绍如何通过以下方式迭代实体数据:

标题 描述
使用 Entities.ForEach 迭代数据 如何使用 SystemBase.Entities.ForEach 逐个实体地处理组件数据。
使用 IJobEntity 迭代数据 如何使用 IJobEntity 编写一次并创建多个计划。
遍历成批数据 如何使用 IJobEntityBatch 遍历包含匹配实体的原型块。
手动迭代数据 如何手动迭代实体或原型块。

额外资源

您还可以使用 EntityQuery 类来构建数据视图,其中仅包含给定算法或过程所需的特定数据。上面列表中的许多迭代方法显式或内部使用 EntityQuery。有关详细信息,请参阅使用EntityQuery查询实体数据。

使用 Entities.ForEach 迭代数据

如果您使用 SystemBase 类来创建您的系统,则可以使用 Entities.ForEach 构造来定义和执行针对实体及其组件的算法。在编译时,Unity 将每个 ForEach() 调用转换为生成的作业。

您向 Entities.ForEach 传递一个 lambda 表达式,Unity 会根据 lambda 参数类型生成一个实体查询。当生成的作业运行时,Unity 会为每个与查询匹配的实体调用一次 lambda。 ForEachLambdaJobDescription 表示此生成的作业。

定义一个 lambda 表达式

当您定义 Entities.ForEach lambda 表达式时,您可以声明 SystemBase 类在执行该方法时用于传递有关当前实体的信息的参数。

典型的 lambda 表达式如下所示:

Entities.ForEach(
    (
        Entity entity,
        int entityInQueryIndex,
        `ref` ObjectPosition translation,
        in Movement move
    ) => { /* .. */}
)

您最多可以将八个参数传递给 Entities.ForEach lambda 表达式。如果需要传递更多参数,可以定义自定义委托。有关详细信息,请参阅本文档中有关自定义委托的部分。

使用标准委托时,必须按以下顺序对参数进行分组:

  1. 按值传递的参数(无参数修饰符)
  2. 可写参数(ref参数修饰符)
  3. 只读参数(in参数修饰符)

您必须在所有组件上使用 refin 参数修改关键字。如果你不这样做,Unity 传递给你的方法的组件结构是一个副本而不是引用。这意味着它会为只读参数占用额外的内存,并且当函数返回后复制的结构超出范围时,您对组件所做的任何更改都会被静默抛出。

如果 lambda 表达式不遵循此顺序,并且您还没有创建合适的委托,则编译器会提供类似于以下内容的错误:

error CS1593: Delegate ‘Invalid_ForEach_Signature_See_ForEach_Documentation_For_Rules_And_Restrictions’ does not take N arguments

此错误消息将参数数量作为问题,即使问题是参数顺序也是如此。

自定义代理

如果要在 ForEach lambda 表达式中使用八个以上的参数,则必须声明自己的委托类型和 ForEach 重载。这允许您使用无限数量的参数,并以您想要的任何顺序放置 refinvalue 参数。

您还可以在参数列表中的任意位置声明三个命名参数 entityentityInQueryIndexnativeThreadIndex。不要对这些参数使用 refin 修饰符。

以下示例显示 12 个参数,并在 lambda 表达式中使用entity参数:

static class BringYourOwnDelegate
{
    // 声明采用 12 个参数的委托。 T0 用于 Entity 参数
    public delegate void CustomForEachDelegate<T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11>
        (T0 t0, in T1 t1, in T2 t2, in T3 t3, in T4 t4, in T5 t5,
         in T6 t6, in T7 t7, in T8 t8, in T9 t9, in T10 t10, in T11 t11);

    // 声明函数重载
    public static TDescription ForEach<TDescription, T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11>
        (this TDescription description, CustomForEachDelegate<T0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11> codeToRun)
        where TDescription : struct, Unity.Entities.CodeGeneratedJobForEach.ISupportForEachWithUniversalDelegate =>
        LambdaForEachDescriptionConstructionMethods.ThrowCodeGenException<TDescription>();
}

// 使用自定义委托和重载的系统
[RequireMatchingQueriesForUpdate]
public partial class MayParamsSystem : SystemBase
{
    protected override void OnUpdate()
    {
        Entities.ForEach(
                (Entity entity0,
                    in Data1 d1,
                    in Data2 d2,
                    in Data3 d3,
                    in Data4 d4,
                    in Data5 d5,
                    in Data6 d6,
                    in Data7 d7,
                    in Data8 d8,
                    in Data9 d9,
                    in Data10 d10,
                    in Data11 d11
                    ) => {/* .. */})
            .Run();
    }
}

组件参数

要访问与实体关联的组件,您必须将该组件类型的参数传递给 lambda 表达式。编译器会自动将传递给 lambda 表达式的所有组件作为必需组件添加到实体查询中。

要更新组件值,您必须使用参数列表中的 ref 关键字将其传递给 lambda 表达式。如果没有 ref 关键字,Unity 将对组件的临时副本进行任何修改。

要声明传递给 lambda 表达式的只读组件,请使用参数列表中的 in 关键字。

当您使用 ref 时,Unity 会将当前块中的组件标记为已更改,即使 lambda 表达式实际上并未修改它们。为了提高效率,您应该始终使用 in 关键字将您的 lambda 表达式未修改的组件声明为只读。

以下示例将 Source 组件参数作为只读传递给作业,并将 Destination 组件参数作为可写传递:


Entities.ForEach(
    (ref Destination outputData,
        in Source inputData) =>
    {
        outputData.Value = inputData.Value;
    })
    .ScheduleParallel();

您不能将块组件传递给 Entities.ForEach lambda 表达式。

对于动态缓冲区,使用 DynamicBuffer<T> 而不是缓冲区中存储的组件类型:

[RequireMatchingQueriesForUpdate]
public partial class BufferSum : SystemBase
{
    private EntityQuery query;

    // 安排两个作业之间的依赖关系
    protected override void OnUpdate()
    {
        // 这里可以访问query变量,
        // 因为我们在下面的entities.ForEach中使用了WithStoreEntityQueryInField(query)
        int entitiesInQuery = query.CalculateEntityCount();

        // 创建一个原生数组来保存中间和
        // (每个实体一个元素)
        NativeArray<int> intermediateSums
            = new NativeArray<int>(entitiesInQuery, Allocator.TempJob);

        // 安排第一个作业以添加所有缓冲区元素
        Entities
            .ForEach((int entityInQueryIndex, in DynamicBuffer<IntBufferData> buffer) =>
        {
            for (int i = 0; i < buffer.Length; i++)
            {
                intermediateSums[entityInQueryIndex] += buffer[i].Value;
            }
        })
            .WithStoreEntityQueryInField(ref query)
            .WithName("IntermediateSums")
            .ScheduleParallel(); // 为每个实体块并行执行

        // 安排第二个工作,依赖于第一个
        Job.WithCode(() =>
        {
            int result = 0;
            for (int i = 0; i < intermediateSums.Length; i++)
            {
                result += intermediateSums[i];
            }
            //不兼容burst:
            Debug.Log("Final sum is " + result);
        })
            .WithDisposeOnCompletion(intermediateSums)
            .WithoutBurst()
            .WithName("FinalSum")
            .Schedule(); // 在单个后台线程上执行
    }
}

命名参数

您还可以将以下命名的参数传递给 Entities.ForEach lambda 表达式,Unity 根据作业正在处理的实体为其分配值。

参数 函数
Entity entity 当前实体的实体实例。只要类型是 Entity,您就可以将参数命名为任何名称。
int entityInQueryIndex 实体在查询选择的所有实体列表中的索引。当您有一个需要为每个实体填充唯一值的native array时,请使用实体索引值。您可以使用 entityInQueryIndex 作为该数组中的索引。您应该使用 entityInQueryIndex 作为 sortKey 将命令添加到并发实体命令缓冲区。
int nativeThreadIndex 执行 lambda 表达式当前迭代的线程的唯一索引。当您使用 Run() 执行 lambda 表达式时,nativeThreadIndex 始终为零。不要使用 nativeThreadIndex 作为并发实体命令缓冲区的 sortKey;使用 entityInQueryIndex 代替。
EntityCommands commands 只要类型是 EntityCommands,您就可以将此参数命名为任何名称。仅将此参数与 WithDeferredPlaybackSystem() 或 WithImmediatePlayback() 结合使用。 EntityCommands 类型包含几个方法,这些方法反映了 EntityCommandBuffer 类型中的对应方法。如果您在 Entities.ForEach() 中使用 EntityCommands 实例,编译器会在适当的地方创建额外的代码来处理实体命令缓冲区的创建、调度、播放和处置,在这些代码上调用 EntityCommands 方法的对应方法。

执行 Entities.ForEach lambda 表达式

您可以通过以下方式执行作业 lambda 表达式:

  • 使用 Schedule() 和 ScheduleParallel() 来安排作业
  • 使用 Run() 立即在主线程上执行作业。
    以下示例说明了使用 Entities.ForEach 读取 Velocity 组件并写入 ObjectPosition 组件的 SystemBase 实现:
    [RequireMatchingQueriesForUpdate]
    partial class ApplyVelocitySystem : SystemBase
    {
        protected override void OnUpdate()
        {
            Entities.ForEach((ref ObjectPosition translation,
                in Velocity velocity) =>
                {
                    translation.Value += velocity.Value;
                })
                .Schedule();
        }
    }

选择实体

Entities.ForEach 有自己的机制来定义用于选择要处理的实体的实体查询。该查询会自动包含您用作 lambda 表达式参数的任何组件。

您还可以使用 WithAllWithAnyWithNone 子句进一步细化 Entities.ForEach 选择的实体。有关查询选项的完整列表,请参阅 SystemBase.Entities

以下示例使用这些子句根据这些参数选择实体:

  • 该实体具有组件:DestinationSourceLocalToWorld
  • 实体至少具有以下组件之一:ObjectRotationObjectPositionObjectUniformScale
  • 该实体没有 ObjectNonUniformScale 组件。
    Entities.WithAll<LocalToWorld>()
        .WithAny<ObjectRotation, ObjectPosition, ObjectUniformScale>()
        .WithNone<ObjectNonUniformScale>()
        .ForEach((ref Destination outputData, in Source inputData) =>
        {
            /* do some work */
        })
        .Schedule();
    在此示例中,在 lambda 表达式中仅访问 DestinationSource 组件,因为它们是参数列表中的唯一组件。

访问 EntityQuery 对象

Entities.ForEach 使用 OnCreate 创建一个 EntityQuery,您可以随时使用它的副本,甚至在调用 Entities.ForEach 之前。

要访问此实体查询,请使用带有 ref 参数修饰符的 WithStoreEntityQueryInField(ref query)。此方法将对查询的引用分配给您提供的字段。但是,此 EntityQuery 没有 Entities.ForEach 调用设置的任何过滤器。

以下示例说明如何访问为 Entities.ForEach 构造隐式创建的 EntityQuery 对象。它使用 EntityQuery 对象来调用 CalculateEntityCount() 方法,并使用此计数创建一个具有足够空间的native array,来为查询选择的每个实体存储一个值:


private EntityQuery query;
protected override void OnUpdate()
{
    int dataCount = query.CalculateEntityCount();
    NativeArray<float> dataSquared
        = new NativeArray<float>(dataCount, Allocator.Temp);
    Entities
        .WithStoreEntityQueryInField(ref query)
        .ForEach((int entityInQueryIndex, in Data data) =>
        {
            dataSquared[entityInQueryIndex] = data.Value * data.Value;
        })
        .ScheduleParallel();

    Job
        .WithCode(() =>
    {
        //使用 dataSquared 数组...
        var v = dataSquared[dataSquared.Length - 1];
    })
        .WithDisposeOnCompletion(dataSquared)
        .Schedule();
}

访问可选组件

Entities.ForEach lambda 表达式不支持使用 WithAny<T,U> 查询和访问可选组件。

如果要读取或写入可选组件,请将 Entities.ForEach 构造拆分为可选组件的每个组合的多个作业。例如,如果您有两个可选组件,则需要三个 ForEach 结构:一个包含第一个可选组件,一个包含第二个可选组件,一个包含两个组件。另一种选择是按块使用 IJobChunkiterate。有关详细信息,请参阅按批次迭代数据。

更改过滤

您可以使用 WithChangeFilter<T> 来启用更改过滤,只有在当前 SystemBase 实例上次运行后实体中的另一个组件发生更改时,它才会处理组件。更改过滤器中的组件类型必须在 lambda 表达式参数列表中,或者是 WithAll<T> 语句的一部分。例如:

Entities
    .WithChangeFilter<Source>()
    .ForEach((ref Destination outputData,
        in Source inputData) =>
        {
            /* Do work */
        })
    .ScheduleParallel();

实体查询最多支持对两种组件类型进行更改过滤。

Unity 在原型块级别应用更改过滤。如果任何代码访问具有写访问权限的块中的组件,那么 Unity 会将该原型块中的组件类型标记为已更改,即使代码没有更改任何数据。

共享组件过滤

Unity 将具有共享组件的实体与其他具有相同共享组件值的实体分组。要选择具有特定共享组件值的实体组,请使用 WithSharedComponentFilter 方法。

以下示例选择了全部按 Cohort:ISharedComponentData 分组的实体。此示例中的 lambda 表达式根据实体的Cohort设置 DisplayColor:IComponentData 组件:

[RequireMatchingQueriesForUpdate]
public partial class ColorCycleJob : SystemBase
{
    protected unsafe override void OnUpdate()
    {

        EntityManager.GetAllUniqueSharedComponents<Cohort>(out var cohorts, Allocator.Temp);
        for (int i=0; i<cohorts.Length; i++)
        {
            var cohort = cohorts[i];
            DisplayColor newColor = ColorTable.GetNextColor(cohort.Value);
            Entities.WithSharedComponentFilter(cohort)
                .ForEach((ref DisplayColor color) => { color = newColor; })
                .ScheduleParallel();
        }
    }
}

该示例使用 EntityManager 获取所有唯一同类Cohort值的队列数组。然后它为每个队列安排一个 lambda 作业,并将新颜色作为捕获变量传递给 lambda 表达式。

捕获变量

您可以捕获 Entities.ForEach lambda 表达式的局部变量。当您调用其中一种 Schedule 方法而不是 Run 来使用作业来执行 lambda 表达式时,对捕获的变量以及如何使用它们有一些限制:

  • 您只能捕获 NativeContainer 和 blittable 类型。
  • 作业只能写入作为NativeContainer的捕获变量。要返回单个值,请创建一个包含一个元素的native array。

如果您要读取NativeContainer,但不写入它,请始终使用 WithReadOnly(variable) 指定只读访问权限。有关为捕获的变量设置属性的详细信息,请参阅 SystemBase.EntitiesEntities.ForEach 将这些作为方法提供,因为 C# 语言不允许局部变量的属性。

要在 Entities.ForEach 运行后处理捕获的NativeContainer或包含NativeContainer的类型,请使用 WithDisposeOnCompletion(variable)。如果您在 Run() 中调用它,它会在 lambda 表达式运行后立即处理这些类型。如果您在 Schedule()ScheduleParallel() 中调用它,它会安排它们稍后与作业一起处理,并返回 JobHandle。

当您使用 Run() 执行该方法时,您可以写入不是NativeContainer的捕获变量。但是,您仍应尽可能使用 blittable 类型,以便可以使用 Burst 编译该方法。

支持的功能

除了使用 Run() 在主线程上执行 lambda 表达式。您还可以使用 Schedule() 将其作为单个作业执行,或使用 ScheduleParallel() 将其作为并行作业执行。这些不同的执行方法对您访问数据的方式有不同的限制。此外,Burst 编译器使用 C# 语言的一个受限子集,因此如果要在该子集之外使用 C# 功能,则需要指定 WithoutBurst()。这包括访问托管类型。

下表显示了 Entities.ForEach 中支持哪些用于 SystemBase 中的不同的调度方法:

支持的功能 Run Schedule ScheduleParallel
捕获本地值类型
捕获本地引用类型 Only WithoutBurst and not in ISystem
写入捕获的变量
在系统类上使用字段 Only WithoutBurst
引用类型的方法 Only WithoutBurst and not in ISystem
共享组件 Only WithoutBurst and not in ISystem
托管组件 Only WithoutBurst and not in ISystem
结构变化 Only WithStructuralChanges and not in ISystem
SystemBase.GetComponent
SystemBase.SetComponent
GetComponentDataFromEntity Only as ReadOnly
HasComponent
WithDisposeOnCompletion
WithScheduleGranularity
WithDeferredPlaybackSystem
WithImmediatePlayback
HasBuffer
SystemBase.GetStorageInfoFromEntity
SystemBase.Exists

WithStructuralChanges() 会禁用突发。如果您想获得高性能 Entities.ForEach,请不要使用此选项。如果要使用此选项,请使用 EntityCommandBuffer

Entities.ForEach 构造使用 Roslyn 源代码生成器将您为构造编写的代码转换为正确的 ECS 代码。这种翻译意味着您可以表达算法的意图,而无需包含复杂的样板代码。但是,这意味着一些常见的代码编写方式是不允许的。

不支持以下功能:

  • .With 调用中的动态代码
  • 使用ref修饰 SharedComponent 参数
  • 嵌套 Entities.ForEach lambda 表达式
  • 调用存储在变量、字段或方法中的委托
  • 具有 lambda 参数类型的 SetComponent
  • 具有可写 lambda 参数的 GetComponent
  • lambdas 中的泛型参数
  • 在具有泛型参数的系统中

依赖关系

默认情况下,系统使用其 Dependency 属性来管理其与 ECS 相关的依赖项。默认情况下,系统按照它们在 OnUpdate() 函数中出现的顺序将使用 Entities.ForEachJob.WithCode 创建的每个作业添加到依赖作业句柄。您还可以将 JobHandle 传递给您的 Schedule 方法以手动管理作业依赖性,然后返回生成的依赖性。有关详细信息,请参阅依赖项文档。

有关作业依赖性的更多一般信息,请参阅作业依赖性。

使用 IJobEntity 迭代数据

当您在多个系统中使用不同的调用进行数据转换时,要遍历 ComponentData,您可以使用 IJobEntity,它类似于 Entities.ForEach

它创建一个 IJobEntityBatch 作业,因此您只需考虑要转换的数据。

IJobEntity 和 Entities.ForEach 的比较

IJobEntity 相对于 Entities.ForEach 的优势在于您可以编写一次代码并在多个系统中重复使用它,而不是仅一次。

这是一个 Entities.ForEach 示例:

[RequireMatchingQueriesForUpdate]
public partial class BoidForEachSystem : SystemBase
{
    EntityQuery m_BoidQuery;
    EntityQuery m_ObstacleQuery;
    EntityQuery m_TargetQuery;
    protected override void OnUpdate()
    {
        // 计算各个查询中的实体数量。
        var boidCount = m_BoidQuery.CalculateEntityCount();
        var obstacleCount = m_ObstacleQuery.CalculateEntityCount();
        var targetCount = m_TargetQuery.CalculateEntityCount();

        // 分配数组,以存储与相应的查询匹配的实体数量相等的数据。
        var cellSeparation = CollectionHelper.CreateNativeArray<float3, RewindableAllocator>(boidCount, ref World.UpdateAllocator);
        var copyTargetPositions = CollectionHelper.CreateNativeArray<float3, RewindableAllocator>(targetCount, ref World.UpdateAllocator);
        var copyObstaclePositions = CollectionHelper.CreateNativeArray<float3, RewindableAllocator>(obstacleCount, ref World.UpdateAllocator);

        // 安排各个数组的作业与各个查询一起存储。
        Entities
            .WithSharedComponentFilter(new BoidSetting{num=1})
            .ForEach((int entityInQueryIndex, in LocalToWorld localToWorld) =>
            {
                cellSeparation[entityInQueryIndex] = localToWorld.Position;
            })
            .ScheduleParallel();

        Entities
            .WithAll<BoidTarget>()
            .WithStoreEntityQueryInField(ref m_TargetQuery)
            .ForEach((int entityInQueryIndex, in LocalToWorld localToWorld) =>
            {
                copyTargetPositions[entityInQueryIndex] = localToWorld.Position;
            })
            .ScheduleParallel();

        Entities
            .WithAll<BoidObstacle>()
            .WithStoreEntityQueryInField(ref m_ObstacleQuery)
            .ForEach((int entityInQueryIndex, in LocalToWorld localToWorld) =>
            {
                copyObstaclePositions[entityInQueryIndex] = localToWorld.Position;
            })
            .ScheduleParallel();
    }
}

可以改写如下:

[RequireMatchingQueriesForUpdate]
public partial class BoidJobEntitySystem : SystemBase
{
    EntityQuery m_BoidQuery;
    EntityQuery m_ObstacleQuery;
    EntityQuery m_TargetQuery;

    protected override void OnUpdate()
    {
        // 计算各个查询中的实体数量。
        var boidCount = m_BoidQuery.CalculateEntityCount();
        var obstacleCount = m_ObstacleQuery.CalculateEntityCount();
        var targetCount = m_TargetQuery.CalculateEntityCount();

        // 分配数组,以存储与相应的查询匹配的实体数量相等的数据。
        var cellSeparation = CollectionHelper.CreateNativeArray<float3, RewindableAllocator>(boidCount, ref World.UpdateAllocator);
        var copyTargetPositions = CollectionHelper.CreateNativeArray<float3, RewindableAllocator>(targetCount, ref World.UpdateAllocator);
        var copyObstaclePositions = CollectionHelper.CreateNativeArray<float3, RewindableAllocator>(obstacleCount, ref World.UpdateAllocator);

        // 为各自的数组安排作业,以与各自的查询一起存储。
        new CopyPositionsJob { copyPositions = cellSeparation}.ScheduleParallel(m_BoidQuery);
        new CopyPositionsJob { copyPositions = copyTargetPositions}.ScheduleParallel(m_TargetQuery);
        new CopyPositionsJob { copyPositions = copyObstaclePositions}.ScheduleParallel(m_ObstacleQuery);
    }

    protected override void OnCreate()
    {
        // 获取相应的查询,其中包括前面描述的“CopyPositionsJob”所需的组件。
        m_BoidQuery = GetEntityQuery(typeof(LocalToWorld));
        m_BoidQuery.SetSharedComponentFilter(new BoidSetting{num=1});
        m_ObstacleQuery = GetEntityQuery(typeof(LocalToWorld), typeof(BoidObstacle));
        m_TargetQuery = GetEntityQuery(typeof(LocalToWorld), typeof(BoidTarget));;
    }
}

创建 IJobEntity 作业

要创建 IJobEntity 作业,请编写一个使用 IJobEntity 接口的结构,并实现您自己的自定义 Execute 方法。

使用 partial 关键字是因为源代码生成创建了一个结构,该结构在 project/Temp/GeneratedCode/..... 中找到的单独文件中实现 IJobEntityBatch

以下示例每帧向每个 SampleComponent 值加1。

public struct SampleComponent : IComponentData { public float Value; }
public partial struct ASampleJob : IJobEntity
{
    // 每个SampleComponent值+1
    void Execute(ref SampleComponent sample)
    {
        sample.Value += 1f;
    }
}

public partial class ASample : SystemBase
{
    protected override void OnUpdate()
    {
        // 调度作业
        new ASampleJob().ScheduleParallel();
    }
}

指定查询

您可以通过以下方式指定 IJobEntity 的查询:

  • 手动创建查询,以指定不同的调用要求。
  • 让已实施的 IJobEntity 为您完成,基于其给定的执行参数,以及使用属性 [WithAll(params Type)][WithAny(params Type)][WithNone(params Type)][ WithChangeFilter(params Type)][WithEntityQueryOptions((params EntityQueryOptions)]

以下示例显示了这两个选项:

partial struct QueryJob : IJobEntity
{
    // 迭代所有 SampleComponents 并增加它们的值
    public void Execute(ref SampleComponent sample)
    {
        sample.Value += 1;
    }
}

[RequireMatchingQueriesForUpdate]
public partial class QuerySystem : SystemBase
{
    // 与 QueryJob 匹配的查询,为 `BoidTarget` 指定
    EntityQuery query_boidtarget;

    // 与 QueryJob 匹配的查询,为 `BoidObstacle` 指定
    EntityQuery query_boidobstacle;
    protected override void OnCreate()
    {
        // 包含在“QueryJob”中找到的所有执行参数的查询 - 以及其他用户指定的组件“BoidTarget”。
        query_boidtarget = GetEntityQuery(ComponentType.ReadWrite<SampleComponent>(),ComponentType.ReadOnly<BoidTarget>());

        // 包含在“QueryJob”中找到的所有执行参数的查询 - 以及其他用户指定的组件“BoidObstacle”。
        query_boidobstacle = GetEntityQuery(ComponentType.ReadWrite<SampleComponent>(),ComponentType.ReadOnly<BoidObstacle>());
    }

    protected override void OnUpdate()
    {
        // Uses the BoidTarget query
        new QueryJob().ScheduleParallel(query_boidtarget);

        // Uses the BoidObstacle query
        new QueryJob().ScheduleParallel(query_boidobstacle);

        // Uses query created automatically that matches parameters found in `QueryJob`.
        new QueryJob().ScheduleParallel();
    }
}

属性

因为 IJobEntity 类似于作业,所以您可以使用对作业起作用的所有属性:

  • Unity.Burst.BurstCompile
  • Unity.Collections.DeallocateOnJobCompletion
  • Unity.Collections.NativeDisableParallelForRestriction
  • Unity.Burst.BurstDiscard
  • Unity.Collections.LowLevel.Unsafe.NativeSetThreadIndex
  • Unity.Burst.NoAlia

IJobEntity 还具有您可以使用的其他属性:

属性 描述
Unity.Entities.WithAll(params Type[]) 在作业结构上设置。缩小查询范围,使实体必须匹配提供的所有类型。
Unity.Entities.WithAny(params Type[]) 在作业结构上设置。缩小查询范围,使实体必须匹配所提供的任何类型。
Unity.Entities.WithNone(params Type[]) 在作业结构上设置。缩小查询范围,使实体不必匹配所提供的任何类型。
Unity.Entities.WithChangeFilter(params Type[]) 在作业结构上设置或附加到执行中的参数。缩小查询范围,以便实体必须在给定组件的原型块中进行更改。
Unity.Entities.WithEntityQueryOptions(params EntityQueryOptions[]) 在作业结构上设置。更改查询范围以使用描述的 EntityQueryOptions。
Unity.Entities.EntityInQueryIndex 在 Execute 中设置 int 参数以获取查询中的当前索引,用于当前实体迭代。这与 Entities.ForEach 中的 entityInQueryIndex 相同。

以下是 EntityInQueryIndex 的示例:

[BurstCompile]
partial struct CopyPositionsJob : IJobEntity
{
    public NativeArray<float3> copyPositions;

    // 遍历所有 `LocalToWorld` 并将它们的位置存储在 `copyPositions` 中。
    public void Execute([EntityInQueryIndex] int entityInQueryIndex, in LocalToWorld localToWorld)
    {
        copyPositions[entityInQueryIndex] = localToWorld.Position;
    }
}

[RequireMatchingQueriesForUpdate]
public partial class EntityInQuerySystem : SystemBase
{
    // 此查询应匹配 `CopyPositionsJob` 参数
    EntityQuery query;
    protected override void OnCreate()
    {
        // 获取匹配 `CopyPositionsJob` 参数的查询
        query = GetEntityQuery(ComponentType.ReadOnly<LocalToWorld>());
    }

    protected override void OnUpdate()
    {
        // 获取一个native array,该数组的大小等于查询找到的实体数量。
        var positions = new NativeArray<float3>(query.CalculateEntityCount(), World.UpdateAllocator.ToAllocator);

        // 在并行线程上为此数组安排作业。
        new CopyPositionsJob{copyPositions = positions}.ScheduleParallel();

        // 处理作业找到的位置数组。
        positions.Dispose(Dependency);
    }
}

执行参数

以下是您可以在 IJobEntity 中使用的所有受支持 Execute 参数的列表:

参数 描述
IComponentData 标记为 ref 用于读写访问,或标记为对 ComponentData 的只读访问。
ICleanupComponentData 标记为 ref 用于读写访问,或标记为对 ComponentData 的只读访问。
ISharedComponent 标记为只读访问 SharedComponentData。如果这是托管的,你不能突发编译或安排它。使用 .Run 代替。
Managed components 使用值副本进行读写访问,或使用 in 标记对托管组件进行只读访问。例如,UnityEngine.Transform。将托管组件标记为 ref 是错误的,您不能对其进行突发编译或调度。使用 .Run 代替。
Entity 获取当前实体。这只是一个值副本,所以不要用 refin 标记。
DynamicBuffer<T> 获取动态缓冲区。用 ref 标记为读写访问,用 in 标记为只读访问。
IAspect 获取方面。方面充当参考,因此您无法分配它们。但是,您可以使用 ref 和 value-copy 将其标记为可读写,使用 in 其标记为只读访问。
int 支持三种整数:
使用属性 [Unity.Entities.ChunkIndexInQuery] 标记 int 以获取查询中的当前原型块索引。
使用属性 [Unity.Entities.EntityIndexInChunk] 标记 int 以获取当前原型块中的当前实体索引。您可以添加 EntityIndexInChunkChunkIndexInQuery 以获得每个实体的唯一标识符。
使用属性 [Unity.Entities.EntityInQueryIndex] 标记 int 以获取查询的打包索引。这对性能有影响,使用 EntityQuery.CalculateBaseEntityIndexArray[Async]

使用 IJobEntityBatch 遍历成批数据

在系统内实现 IJobEntityBatch 或 IJobEntityBatchWithIndex 以在实体批次中迭代数据。

当您在系统的 OnUpdate 函数中计划 IJobEntityBatch 作业时,系统会使用您传递给计划函数的实体查询来识别应该传递给该作业的块。该作业会为这些块中的每批实体调用一次您的 Execute 函数。默认情况下,批处理大小是一个完整的块,但您可以在调度作业时将批处理大小设置为块的一部分。无论批次大小如何,给定批次中的实体始终存储在同一块中。在作业的执行函数中,您可以逐个实体地迭代每个批次中的数据。

当您需要批次集中所有实体的索引值时,请使用 IJobEntityBatchWithIndex。否则,IJobEntityBatch 效率更高,因为它不需要计算这些索引。

要实施批处理作业:

  1. 使用 EntityQuery 查询数据以确定要处理的实体。

  2. 使用 IJobEntityBatch 或 IJobEntityBatchWithIndex 定义作业结构。

  3. 声明您的作业访问的数据。在作业结构中,包括用于标识作业必须直接访问的组件类型的 ComponentTypeHandle 对象的字段。此外,指定作业是读取还是写入这些组件。您还可以包含标识您要查找的实体数据的字段,这些实体不属于查询的一部分,以及用于非实体数据的字段。

  4. 编写作业结构的执行函数来转换您的数据。获取作业读取或写入的组件的 NativeArray 实例,然后迭代当前批处理以执行所需的工作。

  5. 在系统 OnUpdate 函数中安排作业,将标识要处理的实体的实体查询传递给调度函数。

与使用 Entities.ForEach 相比,使用 IJobEntityBatchIJobEntityBatchWithIndex 进行迭代更复杂并且需要更多的代码设置,并且只应在必要或更有效时使用。

有关详细信息,ECS 示例存储库包含一个简单的 HelloCube 示例,该示例演示了如何使用 IJobEntityBatch。

使用 EntityQuery 查询数据

EntityQuery 定义了 EntityArchetype 必须包含的组件类型集,系统才能处理其关联的块和实体。原型可以有额外的组件,但它必须至少有查询定义的组件。您还可以排除包含特定类型组件的原型。

将选择您的作业应处理的实体的查询传递给您用于安排作业的计划方法。

有关定义查询的信息,请参阅使用 EntityQuery 查询数据。

不要在 EntityQuery 中包含完全可选的组件。要处理可选组件,请使用 IJobEntityBatch.Execute 中的 ArchetypeChunk.Has 方法来确定当前 ArchetypeChunk 是否具有可选组件。因为同一批次中的所有实体都具有相同的组件,所以您只需要检查每个批次是否存在可选组件一次,而不是每个实体一次。

定义作业结构

作业结构由执行要执行的工作的执行函数和声明执行函数使用的数据的字段组成。

典型的 IJobEntityBatch 作业结构如下所示:

public struct UpdateTranslationFromVelocityJob : IJobEntityBatch
{
    public ComponentTypeHandle<VelocityVector> velocityTypeHandle;
    public ComponentTypeHandle<ObjectPosition> translationTypeHandle;
    public float DeltaTime;

    [BurstCompile]
    public void Execute(ArchetypeChunk batchInChunk, int batchIndex)
    {
        NativeArray<VelocityVector> velocityVectors =
            batchInChunk.GetNativeArray(velocityTypeHandle);
        NativeArray<ObjectPosition> translations =
            batchInChunk.GetNativeArray(translationTypeHandle);

        for(int i = 0; i < batchInChunk.Count; i++)
        {
            float3 translation = translations[i].Value;
            float3 velocity = velocityVectors[i].Value;
            float3 newTranslation = translation + velocity * DeltaTime;

            translations[i] = new ObjectPosition() { Value = newTranslation };
        }
    }
}

此示例访问实体的两个组件 VelocityVector 和 Translation 的数据,并根据自上次更新以来经过的时间计算新的平移。

IJobEntityBatch 与 IJobEntityBatchWithIndex

IJobEntityBatchIJobEntityBatchWithIndex 之间的唯一区别是 IJobEntityBatchWithIndex 在对批处理调用 Execute 函数时传递一个 indexOfFirstEntityInQuery 参数。该参数为当前batch中第一个实体在实体查询选中的所有实体列表中的索引。

当您需要每个实体的单独索引时,请使用 IJobEntityBatchWithIndex。例如,如果您为每个实体计算一个唯一的结果,您可以使用此索引将每个结果写入native array的不同元素。如果您不使用 indexOfFirstEntityInQuery 值,请改用 IJobEntityBatch,以避免计算索引值的开销。

当您向 [EntityCommandBuffer.ParallelWriter] 添加命令时,您可以使用 batchIndex 参数作为命令缓冲区函数的 sortKey 参数。您不需要仅使用 IJobEntityBatchWithIndex 来为每个实体获取唯一的排序键。两种作业类型都可用的 batchIndex 参数可用于此目的。

声明您的工作访问的数据

作业结构中的字段声明可用于 Execute 函数的数据。这些领域分为四大类:

  • ComponentTypeHandle 字段 —— 组件句柄字段允许您的 Execute 函数访问存储在当前块中的实体组件和缓冲区。请参阅访问实体组件和缓冲区数据。

  • ComponentLookup、BufferLookup 字段 —— 这些“来自实体的数据”字段允许您的 Execute 函数查找任何实体的数据,无论它存储在何处。 (这种类型的随机访问是访问数据效率最低的方式,只应在必要时使用。)请参阅查找其他实体的数据。

  • 其他字段 —— 您可以根据需要为您的结构声明其他字段。您可以在每次安排作业时设置此类字段的值。请参阅访问其他数据。

  • 输出字段 —— 除了更新作业中的可写实体组件或缓冲区外,您还可以写入为作业结构声明的NativeContainer字段。此类字段必须是原生容器,例如 NativeArray;您不能使用其他数据类型。

访问实体组件和缓冲区数据

访问存储在查询中实体之一的组件中的数据是三个步骤的过程:

首先,您必须在作业结构上定义一个 ComponentTypeHandle 字段,将 T 设置为组件的数据类型。例如:

public ComponentTypeHandle<ObjectPosition> translationTypeHandle;

接下来,您在作业的 Execute 方法中使用此句柄字段来访问包含该类型组件数据的数组(作为 NativeArray)。该数组包含批次中每个实体的一个元素:

NativeArray<ObjectPosition> translations = batchInChunk.GetNativeArray(translationTypeHandle);

最后,当您安排作业时(在系统的 OnUpdate 方法中,您使用 ComponentSystemBase.GetComponentTypeHandle 函数为类型句柄字段分配一个值:

// "this" is your `SystemBase` subclass
updateFromVelocityJob.translationTypeHandle = this.GetComponentTypeHandle<ObjectPosition>(false);

每次安排作业时,始终设置作业的组件句柄字段。不要缓存类型句柄并在以后使用它。

批次中的每个组件数据数组都是对齐的,以便给定索引对应于所有数组中的相同实体。换句话说,如果您的作业使用一个实体的两个组件,请在两个数据数组中使用相同的数组索引来访问同一实体的数据。

您可以使用 ComponentTypeHandle 变量来访问您未包含在 EntityQuery 中的组件类型。但是,您必须检查以确保当前批次包含该组件,然后再尝试访问它。使用 Has 函数检查当前批次是否包含特定组件类型:

ComponentTypeHandle 字段是 ECS 作业安全系统的一部分,可防止在读取和写入作业中的数据时出现竞争条件。始终设置 GetComponentTypeHandle 函数的 isReadOnly 参数以准确反映组件在作业中的访问方式。

查找其他实体的数据

通过 EntityQuery 和 IJobEntityBatch 作业(或 Entities.ForEach)访问组件数据几乎总是访问数据的最有效方式。但是,通常情况下您需要以随机访问方式查找数据,例如,当一个实体依赖于另一个实体中的数据时。要执行这种类型的数据查找,您必须通过作业结构将不同类型的句柄传递给您的作业:

ComponentLookup – 访问具有该组件类型的任何实体的组件

BufferLookup – 访问具有该缓冲区类型的任何实体的缓冲区

这些类型为组件和缓冲区提供类似数组的接口,由 Entity 对象索引。除了由于随机数据访问而相对低效之外,以这种方式查找数据还会增加您遇到工作安全系统建立的保障措施的机会。例如,如果您尝试根据另一个实体的变换设置一个实体的变换,作业安全系统无法判断这是否安全,因为您可以通过 ComponentLookup 对象访问所有变换。您可能正在写入您正在读取的相同数据,从而造成竞争条件。

要使用 ComponentLookup 和 BufferLookup,请在作业结构上声明一个类型为 ComponentLookup 或 BufferLookup 的字段,并在调度作业之前设置该字段的值。

有关详细信息,请参阅查找数据。

访问其他数据

如果在执行作业时需要其他信息,可以在作业结构上定义一个字段,然后在 Execute 方法中访问该字段。您只能在安排作业时设置该值,并且该值对于所有批次都保持不变。

例如,如果您正在更新移动对象,您很可能需要传入自上次更新以来经过的时间。为此,您可以定义一个名为 DeltaTime 的字段,在 OnUpdate 中设置它的值并在作业执行函数中使用该值。在为新帧安排作业之前,您将在每一帧计算并为 DeltaTime 字段分配一个新值。

编写执行函数

编写作业结构的执行函数,将数据从输入状态转换为所需的输出状态。

IJobEntityBatch.Execute 方法的签名是:

void Execute(ArchetypeChunk batchInChunk, int batchIndex)

对于 IJobEntityBatchWithIndex.Execute,签名是:

void Execute(ArchetypeChunk batchInChunk, int batchIndex, int indexOfFirstEntityInQuery)
batchInChunk 参数

batchInChunk 参数提供包含此作业迭代的实体和组件的 ArchetypeChunk 实例。因为一个块只能包含一个原型,所以一个块中的所有实体都具有相同的组件集。默认情况下,此对象将所有实体包含在一个块中;但是,如果您使用 ScheduleParallel 安排作业,则可以指定一个批次仅包含块中实体数的一小部分。

使用 batchInChunk 参数获取访问组件数据所需的 NativeArray 实例。 (您还必须声明一个具有相应组件类型句柄的字段——并在安排作业时设置该字段。)

batchIndex 参数

batchIndex 参数是当前批次在为当前作业创建的所有批次列表中的索引。作业中的批次不一定按索引顺序处理。

您可以在以下情况下使用 batchIndex 值:您有一个NativeContainer,每个批次有一个元素,您希望将在执行函数中计算的值写入其中。使用 batchIndex 作为此容器的数组索引。

如果您使用并行写入实体命令缓冲区,请将 batchIndex 参数作为 sortKey 参数传递给命令缓冲区函数。

indexOfFirstEntityInQuery 参数

IJobEntityBatchWithIndex Execute 函数有一个名为 indexofFirstEntityInQuery 的附加参数。如果您将查询选择的实体描绘成一个列表,则 indexOfFirstEntityInQuery 将是当前批次中第一个实体的该列表的索引。作业中的批次不一定按索引顺序处理。

可选组件

如果您的实体查询中有 Any 过滤器或完全可选的组件根本没有出现在查询中,您可以使用 ArchetypeChunk.Has 函数在使用之前测试当前块是否包含这些组件之一:

// If entity has Rotation and LocalToWorld components,
// slerp to align to the velocity vector
if (batchInChunk.Has<Rotation>(rotationTypeHandle) &&
    batchInChunk.Has<LocalToWorld>(l2wTypeHandle))
{
    NativeArray<Rotation> rotations
        = batchInChunk.GetNativeArray(rotationTypeHandle);
    NativeArray<LocalToWorld> transforms
        = batchInChunk.GetNativeArray(l2wTypeHandle);

    // By putting the loop inside the check for the
    // optional components, we can check once per batch
    // rather than once per entity.
    for (int i = 0; i < batchInChunk.Count; i++)
    {
        float3 direction = math.normalize(velocityVectors[i].Value);
        float3 up = transforms[i].Up;
        quaternion rotation = rotations[i].Value;

        quaternion look = quaternion.LookRotation(direction, up);
        quaternion newRotation = math.slerp(rotation, look, DeltaTime);

        rotations[i] = new Rotation() { Value = newRotation };
    }
}

安排工作

要运行 IJobEntityBatch 作业,您必须创建作业结构的实例,设置结构字段,然后安排作业。当您在 SystemBase 实现的 OnUpdate 函数中执行此操作时,系统会安排作业在每一帧运行。

[RequireMatchingQueriesForUpdate]
public partial class UpdateTranslationFromVelocitySystem : SystemBase
{
    EntityQuery query;

    protected override void OnCreate()
    {
        // Set up the query
        var description = new EntityQueryDesc()
        {
            All = new ComponentType[]
                   {ComponentType.ReadWrite<ObjectPosition>(),
                    ComponentType.ReadOnly<VelocityVector>()}
        };
        query = this.GetEntityQuery(description);
    }

    protected override void OnUpdate()
    {
        // Instantiate the job struct
        var updateFromVelocityJob
            = new UpdateTranslationFromVelocityJob();

        // Set the job component type handles
        // "this" is your `SystemBase` subclass
        updateFromVelocityJob.translationTypeHandle
            = this.GetComponentTypeHandle<ObjectPosition>(false);
        updateFromVelocityJob.velocityTypeHandle
            = this.GetComponentTypeHandle<VelocityVector>(true);

        // Set other data need in job, such as time
        updateFromVelocityJob.DeltaTime = World.Time.DeltaTime;

        // Schedule the job
        this.Dependency
            = updateFromVelocityJob.ScheduleParallel(query, this.Dependency);
    }
}

当您调用 GetComponentTypeHandle 函数来设置组件类型变量时,请确保将作业读取但不写入的组件的 isReadOnly 参数设置为 true。正确设置这些参数会对 ECS 框架安排作业的效率产生重大影响。这些访问模式设置必须与其在结构定义和 EntityQuery 中的等效设置相匹配。

不要在系统类变量中缓存 GetComponentTypeHandle 的返回值。您必须在每次系统运行时调用该函数,并将更新后的值传递给作业。

调度选项

您可以在安排作业时通过选择适当的功能来控制作业的执行方式:

  • 运行——立即在当前(主)线程上执行作业。 Run 还会完成当前作业所依赖的任何计划作业。批量大小始终为 1(整个块)。

  • Schedule——安排作业在当前作业所依赖的任何计划作业之后在工作线程上运行。为实体查询选择的每个块调用一次作业执行函数。块按顺序处理。批量大小始终为 1。

  • ScheduleParallel——与 Schedule 类似,不同之处在于您可以指定批处理大小,并且这些批处理是并行处理的(假设工作线程可用)而不是顺序处理。

设置批量大小

要设置批量大小,请使用 ScheduleParallel 方法来安排作业并将 batchesPerChunk 参数设置为正整数。使用值 1 将批处理大小设置为完整块。

用于调度作业的查询选择的每个块都分为 batchesPerChunk 指定的批次数。来自同一块的每个批次包含大致相同数量的实体;然而,来自不同块的批次可能包含非常不同数量的实体。最大批处理大小为 1,这意味着每个块中的所有实体都在对 Execute 函数的一次调用中一起处理。来自不同块的实体永远不能包含在同一批中。

通常,使用 batchesPerChunk 设置为 1 来在对 Execute 的单个调用中处理块中的所有实体是最有效的。然而,情况并非总是如此。例如,如果您的 Execute 函数执行的实体数量较少且算法成本较高,则可以通过使用较小的实体批次从并行处理中获得额外的好处。

跳过实体不变的块

如果您只需要在组件值更改时更新实体,则可以将该组件类型添加到为作业选择实体和块的 EntityQuery 的更改过滤器中。例如,如果您有一个系统读取两个组件并且只需要在前两个组件中的一个发生更改时更新第三个组件,则可以按如下方式使用 EntityQuery:

EntityQuery query;

protected override void OnCreate()
{
    query = GetEntityQuery(
        new ComponentType[]
        {
            ComponentType.ReadOnly<InputA>(),
            ComponentType.ReadOnly<InputB>(),
            ComponentType.ReadWrite<Output>()
        }
    );

    query.SetChangedVersionFilter(
            new ComponentType[]
            {
                typeof(InputA),
                typeof(InputB)
            }
        );
}

EntityQuery 更改过滤器最多支持两个组件。如果您想检查更多或者您没有使用 EntityQuery,您可以手动进行检查。要进行此检查,请使用 ArchetypeChunk.DidChange 函数将组件的块更改版本与系统的 LastSystemVersion 进行比较。如果此函数返回 false,则您可以完全跳过当前块,因为自上次系统运行以来该类型的组件均未更改。

您必须使用结构字段将 LastSystemVersion 从系统传递到作业中,如下所示:

struct UpdateOnChangeJob : IJobEntityBatch
{
    public ComponentTypeHandle<InputA> InputATypeHandle;
    public ComponentTypeHandle<InputB> InputBTypeHandle;
    [ReadOnly] public ComponentTypeHandle<Output> OutputTypeHandle;
    public uint LastSystemVersion;

    [BurstCompile]
    public void Execute(ArchetypeChunk batchInChunk, int batchIndex)
    {
        var inputAChanged = batchInChunk.DidChange(InputATypeHandle, LastSystemVersion);
        var inputBChanged = batchInChunk.DidChange(InputBTypeHandle, LastSystemVersion);

        // If neither component changed, skip the current batch
        if (!(inputAChanged || inputBChanged))
            return;

        var inputAs = batchInChunk.GetNativeArray(InputATypeHandle);
        var inputBs = batchInChunk.GetNativeArray(InputBTypeHandle);
        var outputs = batchInChunk.GetNativeArray(OutputTypeHandle);

        for (var i = 0; i < outputs.Length; i++)
        {
            outputs[i] = new Output { Value = inputAs[i].Value + inputBs[i].Value };
        }
    }
}

与所有作业结构字段一样,您必须在安排作业之前分配其值:

[RequireMatchingQueriesForUpdate]
public partial class UpdateDataOnChangeSystem : `SystemBase` {

    EntityQuery query;

    protected override void OnUpdate()
    {
        var job = new UpdateOnChangeJob();

        job.LastSystemVersion = this.LastSystemVersion;

        job.InputATypeHandle = GetComponentTypeHandle<InputA>(true);
        job.InputBTypeHandle = GetComponentTypeHandle<InputB>(true);
        job.OutputTypeHandle = GetComponentTypeHandle<Output>(false);

        this.Dependency = job.ScheduleParallel(query, this.Dependency);
    }

    protected override void OnCreate()
    {
        query = GetEntityQuery(
            new ComponentType[]
            {
                ComponentType.ReadOnly<InputA>(),
                ComponentType.ReadOnly<InputB>(),
                ComponentType.ReadWrite<Output>()
            }
        );
    }
}

为了提高效率,更改版本适用于整个块而不是单个实体。如果另一个能够写入该类型组件的作业访问块,则 ECS 会增加该组件的更改版本,并且 DidChange 函数返回 true。即使声明对组件的写访问权限的作业实际上并未更改组件值,ECS 也会增加更改版本。 (这是在读取组件数据而不更新它时应始终只读的原因之一。)

手动迭代数据

如果您需要以一种不适合迭代 EntityQuery 中所有块的简化模型的方式管理块,您可以在native array中显式手动请求所有原型块,并使用 IJobParallelFor 等作业处理它们.下面是一个例子:

public class RotationSpeedSystem : SystemBase
{
   [BurstCompile]
   struct RotationSpeedJob : IJobParallelFor
   {
       [DeallocateOnJobCompletion] public NativeArray<ArchetypeChunk> Chunks;
       public ArchetypeChunkComponentType<RotationQuaternion> RotationType;
       [ReadOnly] public ArchetypeChunkComponentType<RotationSpeed> RotationSpeedType;
       public float DeltaTime;

       public void Execute(int chunkIndex)
       {
           var chunk = Chunks[chunkIndex];
           var chunkRotation = chunk.GetNativeArray(RotationType);
           var chunkSpeed = chunk.GetNativeArray(RotationSpeedType);
           var instanceCount = chunk.Count;

           for (int i = 0; i < instanceCount; i++)
           {
               var rotation = chunkRotation[i];
               var speed = chunkSpeed[i];
               rotation.Value = math.mul(math.normalize(rotation.Value), quaternion.AxisAngle(math.up(), speed.RadiansPerSecond * DeltaTime));
               chunkRotation[i] = rotation;
           }
       }
   }

   EntityQuery m_Query;   

   protected override void OnCreate()
   {
       var queryDesc = new EntityQueryDesc
       {
           All = new ComponentType[]{ typeof(RotationQuaternion), ComponentType.ReadOnly<RotationSpeed>() }
       };

       m_Query = GetEntityQuery(queryDesc);
   }

   protected override void OnUpdate()
   {
       var rotationType = GetArchetypeChunkComponentType<RotationQuaternion>();
       var rotationSpeedType = GetArchetypeChunkComponentType<RotationSpeed>(true);
       var chunks = m_Query.ToArchetypeChunkArray(Allocator.TempJob);

       var rotationsSpeedJob = new RotationSpeedJob
       {
           Chunks = chunks,
           RotationType = rotationType,
           RotationSpeedType = rotationSpeedType,
           DeltaTime = Time.deltaTime
       };
       this.Dependency rotationsSpeedJob.Schedule(chunks.Length,32, this.Dependency);
   }
}

如何手动迭代数据

您可以使用 EntityManager 类手动遍历实体或原型块,但这效率不高。您应该只使用这些迭代方法来测试或调试您的代码,或者在您拥有一组受控实体的孤立世界中。

例如,以下代码片段遍历活动世界中的所有实体:

var entityManager = World.Active.EntityManager;
var allEntities = entityManager.GetAllEntities();
foreach (var entity in allEntities)
{
   //...
}
allEntities.Dispose();

此代码段遍历活动世界中的所有块:

var entityManager = World.Active.EntityManager;
var allChunks = entityManager.GetAllChunks();
foreach (var chunk in allChunks)
{
   //...
}
allChunks.Dispose();

系统更新顺序

要指定系统的更新顺序,您可以使用 ComponentSystemGroup 类。要将系统置于组中,请在系统的类声明中使用 UpdateInGroup 属性。然后,您可以使用 UpdateBeforeUpdateAfter 属性来指定系统必须更新的顺序。

有一组默认系统组,您可以使用它们在框架的正确阶段更新系统。您可以将一个组嵌套在另一个组中,以便您组中的所有系统都在正确的阶段更新,并根据其组内的顺序进行更新。

组件系统组

ComponentSystemGroup 类表示 Unity 必须按特定顺序一起更新的相关组件系统的列表。 ComponentSystemGroup 继承自 ComponentSystemBase,因此您可以相对于其他系统对其进行排序,并且它具有 OnUpdate() 方法。这也意味着您可以将一个 ComponentSystemGroup 嵌套在另一个 ComponentSystemGroup 中,并形成层次结构。

默认情况下,当您在 ComponentSystemGroup 中调用 Update() 方法时,它会在其已排序的成员系统列表中的每个系统上调用 Update()。如果任何成员系统是系统组,它们将递归更新自己的成员。生成的系统排序遵循树的深度优先遍历。

系统排序属性

您可以在系统上使用以下属性来确定其更新顺序:

属性 描述
UpdateInGroup 指定此系统应属于的 ComponentSystemGroup。如果不设置该属性,Unity 会自动将其添加到默认世界的 SimulationSystemGroup 中。有关详细信息,请参阅默认系统组部分。
UpdateBefore UpdateAfter 排序系统相对于其他系统。为这些属性指定的系统类型必须是同一组的成员。 Unity 在包含两个系统的适当的最深组中处理跨组边界的排序。例如,如果 CarSystemCarGroup 中,而 TruckSystemTruckGroup 中,并且 CarGroupTruckGroup 都是 VehicleGroup 的成员,那么 CarGroupTruckGroup 的顺序隐式决定了 CarSystemTruckSystem 的相对顺序。您无需明确订购系统。
DisableAutoCreation 阻止 Unity 在默认世界初始化期间创建系统。您必须明确地创建和更新系统。但是,您可以将带有此标记的系统添加到 ComponentSystemGroup 的更新列表中,它会像该列表中的其他系统一样自动更新。

如果您将 DisableAutoCreation 属性添加到组件系统或系统组,Unity 不会创建它或将其添加到默认系统组。要手动创建系统,请使用 World.GetOrCreateSystem<MySystem>() 并从主线程调用 MySystem.Update() 来更新它。您可以使用它在 Unity 播放器循环中的其他位置插入系统,例如,如果您有一个应该在帧中稍后或更早运行的系统。

默认系统组

默认世界包含 ComponentSystemGroup 实例的层次结构。 Unity 播放器循环中有三个根级系统组:

  • InitializationSystemGroup:在播放器循环的初始化阶段结束时更新。
  • SimulationSystemGroup:在播放器循环的更新阶段结束时更新。
  • PresentationSystemGroup:在播放器循环的 PreLateUpdate 阶段结束时更新。

默认系统组也有一些预定义的成员系统:

InitializationSystemGroup:

  • BeginInitializationEntityCommandBufferSystem
  • CopyInitialTransformFromGameObjectSystem
  • SubSceneLiveConversionSystem
  • SubSceneStreamingSystem
  • EndInitializationEntityCommandBufferSystem

SimulationSystemGroup:

  • BeginSimulationEntityCommandBufferSystem
  • TransformSystemGroup
    • ParentSystem
    • CopyTransformFromGameObjectSystem
    • TRSToLocalToWorldSystem
    • TRSToLocalToParentSystem
    • LocalToParentSystem
    • CopyTransformToGameObjectSystem
  • LateSimulationSystemGroup
  • EndSimulationEntityCommandBufferSystem

PresentationSystemGroup:

  • BeginPresentationEntityCommandBufferSystem
  • CreateMissingRenderBoundsFromMeshRenderer
  • RenderingSystemBootstrap
  • RenderBoundsUpdateSystem
  • RenderMeshSystem
  • LODGroupSystemV1
  • LodRequirementsUpdateSystem
  • EndPresentationEntityCommandBufferSystem

请注意,此列表的具体内容可能会发生变化。

多个世界

您可以创建多个世界,也可以在多个世界中实例化相同的组件系统类。您还可以从更新顺序中的不同点以不同的速率更新每个实例。

您无法手动更新给定世界中的每个系统,但您可以控制在哪个世界中创建哪些系统,以及将它们添加到哪些现有系统组中。

例如,您可以创建一个实例化 SystemXSystemY 的自定义世界,并将 SystemX 添加到默认世界的 SimulationSystemGroup,并将 SystemY 添加到默认世界的 PresentationSystemGroup。这些系统可以像往常一样相对于它们的同级组对自己进行排序,Unity 会更新它们以及相应的组。

您还可以使用 ICustomBootstrap 接口来管理多个世界中的系统:

public interface ICustomBootstrap
{
    // 返回应由默认引导程序处理的系统。
    // 如果返回 null,则根本不会创建默认世界。
    // 空列表创建默认世界和入口点
    List<Type> Initialize(List<Type> systems);
}

当您实现此接口时,它会在默认世界初始化之前将组件系统类型的完整列表传递给 Initialize() 方法。自定义引导程序可以遍历此列表并在您定义的世界中创建系统。您可以从 Initialize() 方法返回系统列表,Unity 创建它们作为默认世界初始化的一部分。

例如,这是自定义 MyCustomBootstrap.Initialize() 实现的典型过程:

  1. 创建任何其他世界及其顶级 ComponentSystemGroups
  2. 对于系统类型列表中的每个类型:
    1. 向上搜索 ComponentSystemGroup 层次结构以找到此系统类型的顶级组。
    2. 如果它是在步骤 1 中创建的组之一,则在该世界中创建系统并使用 group.AddSystemToUpdateList() 将其添加到层次结构中。
    3. 如果不是,则将此类型附加到列表以返回到 DefaultWorldInitialization
  3. 在新的顶级组上调用 group.SortSystemUpdateList()
    1. 可选择将它们添加到默认世界组之一
  4. 将未处理系统的列表返回给 DefaultWorldInitialization

ECS 框架通过反射找到您的 ICustomBootstrap 实现。

使用作业在多个线程上调度数据

实体包和 Unity 的 DOTS 架构广泛使用 C# 作业系统。只要有可能,您应该在系统代码中使用作业。

SystemBase 类提供 Entities.ForEachJob.WithCode 以将应用程序的逻辑实现为多线程代码。在更复杂的情况下,您可以使用 IJobEntityBatchSchedule()ScheduleParallel() 方法在主线程之外转换数据。 Entities.ForEach 使用起来最简单,通常需要较少的代码来实现。

ECS 按照您的系统所在的顺序在主线程上安排作业。当您安排作业时,ECS 会跟踪哪些作业读取和写入哪些组件。读取组件的作业依赖于写入同一组件的任何先前计划的作业,反之亦然。作业调度程序使用作业依赖关系来确定哪些作业可以并行运行,哪些作业必须按顺序运行。

例如,以下系统更新位置:

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;

public class MovementSpeedSystem : SystemBase
{
    // OnUpdate runs on the main thread.
    protected override void OnUpdate()
    {
        Entities
            .ForEach((ref Translation position, in MovementSpeed speed) =>
                {
                    float3 displacement = speed.Value * dt;
                    position = new Translation(){
                            Value = position.Value + displacement
                        };
                })
            .ScheduleParallel();
    }
}

工作扩展

Unity C# 作业系统允许您在多个线程上运行代码。该系统提供调度、并行处理和多线程安全。作业系统是一个核心 Unity 模块,它提供通用接口和类来创建和运行作业,无论您是否使用 Entities 包。

这些接口包括:

  • IJob:创建一个在任何线程或内核上运行的作业,由作业系统调度程序确定。
  • IJobParallelFor:创建一个可以在多个线程上并行运行的作业,以处理 NativeContainer 的元素。
  • IJobExtensions:提供运行 IJob 作业的扩展方法。
  • IJobParallelForExtensions:提供运行 IJobParallelFor 作业的扩展方法。
  • JobHandle:访问计划作业的句柄。您还可以使用 JobHandle 实例来指定作业之间的依赖关系。

有关作业系统的概述,请参阅 Unity 用户手册中的 C# 作业系统。

Jobs 包扩展了作业系统以支持 ECS。它包含:

  • IJobParallelForDeferExtensions
  • IJobFilter
  • JobParallelIndexListExtensions
  • Job​Struct​Produce<T>

通用职位

在 C# 中,您可以使用继承和接口使一段代码适用于一系列类型。例如:

// 该方法不仅限于一种输入,而是任何实现 IBlendable 的类型。
void foo(IBlendable a) {...}

在 Burst 编译器使用的高性能 C# (HPC#) 中,您不能使用托管类型或虚拟方法调用,因此泛型是使一段代码在一系列类型上运行的选项:

// 他的方法可以对任何 IBlendable 结构进行操作(并且可以调用IBlendable 方法),但不需要托管对象或虚拟方法调用。
void foo<T>(T a) where T : struct, IBlendable {...}

您必须在 HPC# 中编写作业,因此对于在一系列类型上运行的作业,它必须是通用的:

[BurstCompile()]
public struct BlendJob<T> : IJob
    where T : struct, IBlendable
{
    public NativeReference<T> blendable;

    public void Execute() 
    {
        var val = blendable.Value;
        val.Blend();
        blendable.Value = val;
    }
}

从 Burst 编译代码中调度通用作业

要从 Burst 编译代码安排通用作业,您需要作业具体专业化的反射数据。不幸的是,Unity 不会为所有具体的特化自动生成此反射,因此在某些情况下您必须手动注册它们:

// 此程序集属性允许同一程序集中的突发编译代码为 MyJob 安排具体的专业化 <int, float>。
[assembly: RegisterGenericJobType(typeof(MyJob<int, float>))]

如果您尝试安排未在程序集中注册具体专业化的作业,则 Unity 会抛出异常。

注册类型的程序集无关紧要。例如,如果一个作业类型只在程序集 Foo 中注册,您也可以在程序集 Bar 中安排它。

如果您多次重复注册相同的具体专业化,则不会将其视为错误。

具体作业类型的自动注册

当您直接实例化通用作业的具体特化时,Unity 会自动在程序集中注册该特化:

// Registers specialization <int, float> for MyJob in the assembly.
var job = new MyJob<int, float>();

但是,在间接实例化具体特化时,Unity 不会自动注册它:

void makeJob<T>()
{
    new MyJob<T, float>().Schedule();   
}

void foo()
{
    makeJob<int>();    // does NOT register MyJob<int, float>
}

但是,如果您将通用作业作为返回类型或输出参数包含在签名中,则 Unity 会自动注册它:

MyJob<T, float> makeJob<T>()
{
    var j = new MyJob<T, float>()
    j.Schedule();   
    return j;
}

void foo()
{
    makeJob<int>();    // registers MyJob<int, float>
}

您可以通过多级通用方法调用使用此间接注册作品:

MyJob<T, float> makeJob<T>()
{
    var j = new MyJob<T, float>()
    j.Schedule();   
    return j;
}

void foo<T>()
{
    makeJob<T>();    
}

void bar()
{
    foo<int>();       // registers MyJob<int, float>
}

您还可以将通用作业嵌套在另一个类或结构中:

struct BlendJobWrapper<T> where T : struct, IBlendable
{
    public T blendable;

    [BurstCompile()]
    public struct BlendJob : IJob
    {
        public T blendable;

        public void Execute() {...}
    }

    public JobHandle Schedule(JobHandle dep = new JobHandle())
    {
        return new BlendJob { blendable = blendable }.Schedule(dep);
    }
}

在前面的示例中,如果 BlendJobWrapper<foo> 是自动或手动注册的,那么 BlendJob<foo> 也会有效注册。仅围绕一个通用作业的包装器类型并不能解决任何问题,但是当您同时使用多个通用作业时,这些包装器类型允许更优雅的作业创建和调度。

作业化分拣

NativeSortExtension 类具有排序方法,包括使用作业进行排序的方法:

public unsafe static JobHandle Sort<T, U>(T* array, int length, U comp, JobHandle deps)
    where T : unmanaged
    where U : IComparer<T>
{
    if (length == 0)
        return inputDeps;

    var segmentSortJob = new SegmentSort<T, U> { Data = array, Comp = comp, Length = length, SegmentWidth = 1024 };
    var segmentSortMergeJob = new SegmentSortMerge<T, U> { Data = array, Comp = comp, Length = length, SegmentWidth = 1024 };

    var segmentCount = (length + 1023) / 1024;
    var workerSegmentCount = segmentCount / math.max(1, JobsUtility.MaxJobThreadCount);
    var handle = segmentSortJob.Schedule(segmentCount, workerSegmentCount, deps);
    return segmentSortMergeJob.Schedule(segmentSortJobHandle);
}

在此示例中,排序分为两个作业:第一个作业将数组拆分为多个子部分,然后分别对它们进行并行排序。第二个作业等待第一个,然后将这些排序的子部分合并为最终的排序结果。

但是,此方法不会自动注册两个通用作业 SegmentSort 和 SegmentSortMerge 的具体特化,因为这两种类型都未用作方法的返回类型或输出参数。

一种解决方案是将两个作业都放入参数中:

public unsafe static JobHandle Sort<T, U>(T* array, int length, U comp, JobHandle deps
        out SegmentSort<T, U> segmentSortJob, out SegmentSortMerge<T, U> segmentSortMergeJob)
    where T : unmanaged
    where U : IComparer<T>
{
    if (length == 0)
        return inputDeps;

    segmentSortJob = new SegmentSort<T, U> { Data = array, Comp = comp, Length = length, SegmentWidth = 1024 };
    segmentSortMergeJob = new SegmentSortMerge<T, U> { Data = array, Comp = comp, Length = length, SegmentWidth = 1024 };

    var segmentCount = (length + 1023) / 1024;
    var workerSegmentCount = segmentCount / math.max(1, JobsUtility.MaxJobThreadCount);
    var handle = segmentSortJob.Schedule(segmentCount, workerSegmentCount, deps);
    return segmentSortMergeJob.Schedule(segmentSortJobHandle);
}

然而,这解决了注册问题,但是您随后必须传递参数以获得您可能不想要的两个作业结构。

更好的解决方案是将两种作业类型包装在一个包装器类型中:

unsafe struct SortJob<T, U> :
    where T : unamanged
    where U : IComparer<T>
{
    public T* data;
    public U comparer;
    public int length;

    unsafe struct SegmentSort : IJobParallelFor
    {
        [NativeDisableUnsafePtrRestriction]
        public T* data;
        public U comp;
        public int length;
        public int segmentWidth;

        public void Execute(int index) {...}
    }

    unsafe struct SegmentSortMerge : IJob
    {
        [NativeDisableUnsafePtrRestriction]
        public T* data;
        public U comp;
        public int length;
        public int segmentWidth;

        public void Execute() {...}
    }

    public JobHandle Schedule(JobHandle dep = new JobHandle())
    {
        if (length == 0)
            return inputDeps;

        var segmentSortJob = new SegmentSort<T, U> { Data = array, Comp = comp, Length = length, SegmentWidth = 1024 };
        var segmentSortMergeJob = new SegmentSortMerge<T, U> { Data = array, Comp = comp, Length = length, SegmentWidth = 1024 };

        var segmentCount = (length + 1023) / 1024;
        var workerSegmentCount = segmentCount / math.max(1, JobsUtility.MaxJobThreadCount);
        var handle = segmentSortJob.Schedule(segmentCount, workerSegmentCount, deps);
        return segmentSortMergeJob.Schedule(segmentSortJobHandle);
    }
}

在这种安排中,您可以创建 SortJob 的实例并调用其 Schedule() 方法,而不是调用 Sort() 方法。通过对 SortJob 进行具体实例化,您还可以自动注册所需的 SegmentSort 和 SegmentSortMerge 具体特化。

这种嵌套通用作业的模式启用了一个方便的 API,可以将相关的通用作业集安排在一起。

使用 Job.WithCode 安排后台作业

SystemBase 类中的 Job.WithCode 构造将方法作为单个后台作业运行。您还可以在主线程上运行 Job.WithCode 并利用 Burst 编译来加快执行速度。

使用 Job.WithCode

以下示例使用一个 Job.WithCode lambda 表达式用随机数填充native array,并使用另一个作业将这些数字加在一起:

public partial 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
        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.
        this.CompleteDependency();
        UnityEngine.Debug.Log("The sum of "
            + randomNumbers.Length + " numbers is " + result[0]);

        randomNumbers.Dispose();
        result.Dispose();
    }
}

要运行并行作业,请实施 IJobFor。您可以使用 ScheduleParallel() 在系统的 OnUpdate() 函数中安排并行作业。

捕获变量

您不能将参数传递给 Job.WithCode lambda 表达式或返回值。相反,您必须在系统的 OnUpdate() 函数中捕获局部变量。

如果您使用 Schedule() 来安排您的作业在 Unity 的作业系统中运行,则还有其他限制:

  • 您必须将捕获的变量声明为 NativeArray、NativeContainer或 blittable 类型。
  • 要返回数据,您必须将返回值写入捕获的native array,即使数据是单个值也是如此。但是,如果您使用 Run() 来执行作业,则可以写入任何捕获的变量。
    Job.WithCode 有一组方法将只读和安全属性应用于捕获的NativeContainer的变量。例如,您可以使用 WithReadOnly 将对变量的访问限制为只读。您还可以使用 WithDisposeOnCompletion 在作业完成后自动释放容器。有关详细信息,请参阅 Job.WithCode 文档的捕获变量部分。

执行 Job.WithCode lambda 表达式

要执行 Job.WithCode lambda 表达式,您可以使用以下命令:

  • Schedule():将方法作为单个非并行作业执行。安排作业在后台线程上运行代码并更好地利用所有可用的 CPU 资源。您可以显式地将 JobHandle 传递给 Schedule(),或者,如果您不传递任何依赖项,系统会假定当前系统的 Dependency 属性表示作业的依赖项。或者,如果作业没有依赖项,您可以传入一个新的 JobHandle。
  • Run():在主线程上执行方法。您可以 Burst 编译 Job.WithCode,因此如果您使用 Run() 来执行代码,即使它在主线程上运行也会更快。当你调用 Run() 时,Unity 会自动完成 Job.WithCode 构造的所有依赖。

依赖关系

默认情况下,系统使用其 Dependency 属性来管理其依赖项。系统按照它们在 OnUpdate() 方法中出现的顺序将您创建的每个 Entities.ForEach 和 Job.WithCode 作业添加到依赖作业句柄。

要手动管理作业依赖性,请将 JobHandle 传递给 Schedule 方法,然后返回生成的依赖性。有关详细信息,请参阅依赖项 API 文档。

有关作业依赖性的一般信息,请参阅有关作业依赖性的文档。

作业依赖

Unity根据系统读写的ECS组件分析各个系统的数据依赖关系。如果在帧中较早更新的系统读取较晚系统写入的数据,或写入较晚系统读取的数据,则第二个系统依赖于第一个系统。为了防止竞争条件,作业调度程序确保系统依赖的所有作业在运行该系统的作业之前已经完成。

作业依赖更新顺序

系统的 Dependency 属性是一个 JobHandle,表示系统的 ECS 相关依赖项。在 OnUpdate() 之前,Dependency 属性反映系统对先前作业的传入依赖项。默认情况下,系统会根据您在系统中安排作业时每个作业读取和写入的组件来更新依赖属性。

覆盖默认顺序

要覆盖此默认行为,请使用 Entities.ForEach 和 Job.WithCode 的重载版本,它们将作业依赖项作为参数并将更新的依赖项作为 JobHandle 返回。当您使用这些构造的显式版本时,ECS 不会自动将作业句柄与系统的 Dependency 属性组合在一起。您必须在需要时手动组合它们。

Dependency 属性不跟踪作业可能对通过 NativeArray 或其他类似容器传递的数据的依赖关系。如果您在一个作业中编写 NativeArray,并在另一个作业中读取该数组,则必须手动将第一个作业的 JobHandle 添加为第二个作业的依赖项。您可以使用 JobHandle.CombineDependencies 来执行此操作。

Entities.ForEach 的作业依赖顺序

当您调用 Entities.ForEach.Run() 时,作业计划程序会在开始 ForEach 迭代之前完成系统依赖的所有计划作业。如果您还使用 WithStructuralChanges() 作为构造的一部分,则作业调度程序将完成所有正在运行和已调度的作业。结构更改还会使对组件数据的任何直接引用无效。有关详细信息,请参阅有关结构更改的文档。

更多资源

  • JobHandle 和依赖项
  • Unity的工作系统

使用 EntityQuery 查询数据

EntityQuery 查找具有一组指定组件类型的原型。然后它将原型的块收集到一个系统可以处理的数组中。

例如,如果查询匹配组件类型 A 和 B,则查询会收集具有这两种组件类型的所有原型的块,而不管这些原型可能具有的任何其他组件类型。因此,具有组件类型 A、B 和 C 的原型将匹配查询。

您可以使用 EntityQuery 执行以下操作:

运行作业以处理选定的实体和组件
获取包含所有选定实体的 NativeArray
按组件类型获取所选组件的 NativeArray
EntityQuery 返回的实体和组件数组是并行的。这意味着相同的索引值始终适用于任何数组中的相同实体。

创建实体查询

要创建实体查询,您可以将组件类型传递给 EntityQueryBuilder 帮助器类型。以下示例定义了一个 EntityQuery,它查找所有同时具有 ObjectRotation 和 ObjectRotationSpeed 组件的实体:

EntityQuery query = new EntityQueryBuilder(Allocator.Temp)
    .WithAllRW<ObjectRotation>()
    .WithAll<ObjectRotationSpeed>()
    .Build(this);

查询使用 EntityQueryBuilder.WithAllRW 来显示系统写入 ObjectRotation。如果可能,您应该始终指定只读访问权限,因为对数据的读取访问权限的限制较少。这有助于作业调度程序更有效地执行作业。

指定系统选择的原型

查询将仅匹配包含您指定组件的原型。可以使用三种不同的 EntityQueryBuilder 方法指定组件:

  • WithAll():这些组件是必需的。为了匹配查询,原型必须包含查询的所有必需组件。
  • WithAny():这些组件是可选的。为了匹配查询,原型必须至少包含一个查询的可选组件。
  • WithNone():排除这些组件。为了匹配查询,原型不得包含任何查询的排除组件。
    例如,以下查询包括包含 ObjectRotation 和 ObjectRotationSpeed 组件的原型,但不包括包含 Static 组件的任何原型:
    EntityQuery query = new EntityQueryBuilder(Allocator.Temp)
        .WithAllRW<ObjectRotation>()
        .WithAll<ObjectRotationSpeed>()
        .WithNone<Static>()
        .Build(this);

要处理可选组件,请使用 ArchetypeChunk.Has 方法来确定块是否包含可选组件。这是因为同一块中的所有实体都具有相同的组件,因此您只需检查每个块是否存在可选组件一次:而不是每个实体一次。

您可以使用 EntityQueryBuilder.WithOptions() 来查找专门的原型。例如:

  • IncludePrefab:包括包含 Prefab 标签组件的原型。
  • IncludeDisabledEntities:包括包含 Disabled 标签组件的原型。
  • FilterWriteGroup:仅包含 WriteGroup 中明确包含在查询中的组件的实体。排除具有来自同一 WriteGroup 的任何其他组件的实体。
    有关选项的完整列表,请参阅 EntityQueryOptions。

按写入组过滤

在以下示例中,LuigiComponent 和 MarioComponent 是基于 CharacterComponent 组件的同一 WriteGroup 中的组件。此查询使用需要 CharacterComponent 和 MarioComponent 的 FilterWriteGroup 选项:

public struct CharacterComponent : IComponentData { }

[WriteGroup(typeof(CharacterComponent))]
public struct LuigiComponent : IComponentData { }

[WriteGroup(typeof(CharacterComponent))]
public struct MarioComponent : IComponentData { }

[RequireMatchingQueriesForUpdate]
public partial class ECSSystem : SystemBase
{
    protected override void OnCreate()
    {
        var query = new EntityQueryBuilder(Allocator.Temp)
            .WithAllRW<CharacterComponent>()
            .WithAll<MarioComponent>()
            .WithOptions(EntityQueryOptions.FilterWriteGroup)
            .Build(this);
    }

    protected override void OnUpdate()
    {
        throw new NotImplementedException();
    }
}

此查询排除任何同时具有 LuigiComponent 和 MarioComponent 的实体,因为 LuigiComponent 未明确包含在查询中。

这比 None 字段更有效,因为您不需要更改其他系统使用的查询,只要它们也使用写组。

您可以使用写入组来扩展现有系统。例如,如果您在另一个系统中将 CharacterComponent 和 LuigiComponent 定义为不受您控制的库的一部分,则可以将 MarioComponent 与 LuigiComponent 放在同一写入组中,以更改 CharacterComponent 的更新方式。然后,对于您添加到 MarioComponent 的任何实体,系统都会更新 CharacterComponent,但原始系统不会更新它。对于没有 MarioComponent 的实体,原始系统像以前一样更新 CharacterComponent。有关详细信息,请参阅有关写入组的文档。

定义过滤器

要进一步对实体进行排序,您可以使用过滤器根据以下内容排除实体:

共享组件过滤器:根据共享组件的特定值过滤实体集。
更改过滤器:根据特定组件类型的值是否已更改来过滤实体集。
在您对查询对象调用 ResetFilter 之前,您设置的过滤器一直有效。

要忽略查询的活动块过滤器,请使用名称以 IgnoreFilter 结尾的 EntityQuery 方法。这些方法通常比过滤等效方法更有效。例如,请参阅 IsEmpty 与 IsEmptyIgnoreFilter。

使用共享组件过滤器

要使用共享组件筛选器,请在 EntityQuery 中包含共享组件以及任何其他需要的组件,然后调用 SetSharedComponentFilter 方法。然后传入包含要选择的值的相同 ISharedComponent 类型的结构。所有值都必须匹配。您最多可以向过滤器添加两个不同的共享组件。

您可以随时更改筛选器,但如果您更改筛选器,它不会更改您从组 ToComponentDataArray 或 ToEntityArray 方法接收的任何现有实体或组件数组。您必须重新创建这些数组。

以下示例定义了一个名为 SharedGrouping 的共享组件和一个仅处理组字段设置为 1 的实体的系统。


struct SharedGrouping : ISharedComponentData
{
    public int Group;
}

[RequireMatchingQueriesForUpdate]
partial class ImpulseSystem : SystemBase
{
    EntityQuery query;

    protected override void OnCreate()
    {
        query = new EntityQueryBuilder(Allocator.Temp)
            .WithAllRW<ObjectPosition>()
            .WithAll<Displacement, SharedGrouping>()
            .Build(this);
    }

    protected override void OnUpdate()
    {
        // Only iterate over entities that have the SharedGrouping data set to 1
        query.SetSharedComponentFilter(new SharedGrouping { Group = 1 });

        var positions = query.ToComponentDataArray<ObjectPosition>(Allocator.Temp);
        var displacements = query.ToComponentDataArray<Displacement>(Allocator.Temp);

        for (int i = 0; i < positions.Length; i++)
            positions[i] = new ObjectPosition
            {
                Value = positions[i].Value + displacements[i].Value
            };
    }
}

使用更改过滤器

如果您只需要在组件值更改时更新实体,请使用 SetChangedVersionFilter 方法将该组件添加到 EntityQuery 过滤器。例如,以下 EntityQuery 仅包含来自另一个系统已写入 Translation 组件的块的实体:


EntityQuery query;

protected override void OnCreate()
{
    query = new EntityQueryBuilder(Allocator.Temp)
        .WithAllRW<LocalToWorld>()
        .WithAll<ObjectPosition>()
        .Build(this);
    query.SetChangedVersionFilter(typeof(ObjectPosition));

}

为了提高效率,更改过滤器适用于整个原型块,而不是单个实体。更改过滤器还只检查声明对组件进行写访问的系统是否已运行,而不检查它是否更改了任何数据。例如,如果可以写入该组件类型的另一个作业访问该块,则更改过滤器将包括该块中的所有实体。这就是为什么您应该始终声明对不需要修改的组件的只读访问权限。

按启用组件过滤

启用组件允许在运行时启用和禁用单个实体上的组件。禁用实体上的组件不会将该实体移动到新的原型中,但出于 EntityQuery 匹配的目的,实体将被视为没有组件。具体来说:

如果一个实体禁用了组件 T,它将不会匹配需要组件 T 的查询(使用 WithAll())。
如果实体禁用了组件 T,它将匹配排除组件 T 的查询(使用 WithNone())。
大多数 EntityQuery 操作(例如 ToEntityArray 和 CalculateEntityCount)会自动过滤掉其可启用组件会导致它们与查询不匹配的实体。要禁用此过滤,请使用这些操作的 IgnoreFilter 变体,或在查询创建时传递 EntityQueryOptions.IgnoreComponentEnabledState。

有关更多详细信息,请参阅启用的组件文档。

合并查询

要有效地将多个查询合并为一个,您可以创建一个包含多个查询描述的查询。生成的查询与匹配任何提供的查询描述的原型相匹配。本质上,组合查询匹配查询描述的并集。以下示例选择包含 ObjectRotation 组件或 ObjectRotationSpeed 组件(或两者)的任何原型:

EntityQuery query = new EntityQueryBuilder(Allocator.Temp)
    .WithAllRW<ObjectRotation>()
    // Start a new query description
    .AddAdditionalQuery()
    .WithAllRW<ObjectRotationSpeed>()
    .Build(this);

执行查询

通常,您在安排使用它的作业时执行实体查询。您还可以调用返回实体、组件或原型块数组的 EntityQuery 方法之一:

ToEntityArray:返回所选实体的数组。
ToComponentDataArray:返回所选实体的 T 类型组件的数组。
CreateArchetypeChunkArray:返回包含所选实体的所有块。因为查询对原型、共享组件值和更改过滤器进行操作,这些对于块中的所有实体都是相同的,所以存储在返回的块集中的实体集与 ToEntityArray 返回的实体集相同。
上述方法的异步版本也可用,它安排一个作业来收集请求的数据。其中一些变体必须返回 NativeList 而不是 NativeArray 才能支持可启用的组件。请参阅 ToEntityListAsync、ToComponentDataListAsync 和 CreateArchetypeChunkArrayAsync。

编辑器中的查询

在编辑器中,以下图标代表查询: 。当您使用特定的实体窗口和检查器时,您会看到这一点。您还可以使用“查询”窗口查看与所选查询匹配的组件和实体。

使用 EntityCommandBuffer 安排数据更改

要对实体数据更改进行排队而不是立即执行更改,您可以使用 EntityCommandBuffer 结构,它创建一个线程安全的命令缓冲区。如果您想在作业完成时推迟任何结构更改,这将很有用。

EntityCommandBuffer 方法

您可以使用 EntityCommandBuffer 中的方法来记录命令,这些方法反映了 EntityManager 的一些方法,例如:

  • CreateEntity(EntityArchetype):创建具有指定原型的新实体。
  • DestroyEntity(Entity):销毁实体。
  • SetComponent(Entity, T):为实体上类型 T 的组件设置值。
  • AddComponent(Entity):将类型 T 的组件添加到实体。
  • RemoveComponent(EntityQuery):从匹配查询的所有实体中移除类型 T 的组件。
    Unity 仅在调用主线程上的 Playback 方法时才将更改记录在 EntityCommandBuffer 中。如果您尝试在播放后记录任何对命令缓冲区的进一步更改,Unity 会抛出异常。

EntityCommandBuffer 有一个作业安全句柄,类似于NativeContainer。如果您尝试对使用命令缓冲区的未完成计划作业执行以下任何操作,安全检查会抛出异常:

  • 通过其 AddComponent、Playback、Dispose 或其他方法访问 EntityCommandBuffer。
  • 安排另一个访问相同 EntityCommandBuffer 的作业,除非新作业依赖于已安排的作业。

在单线程作业中使用 EntityCommandBuffer

Unity 无法在作业中执行结构更改,因此您可以使用实体的命令缓冲区来推迟结构更改,直到 Unity 完成作业。例如:

// ... in a system update

// You don't specify a size because the buffer will grow as needed.
EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

// The ECB is captured by the `ForEach` job.
// Until completed, the job owns the ECB's job safety handle.
Entities
    .ForEach((Entity e, in FooComp foo) =>
    {
        if (foo.Value > 0)
        {
            // Record a command that will later add
            // BarComp to the entity.
            ecb.AddComponent<BarComp>(e);
        }
    }).Schedule();

this.Dependency.Complete();

// Now that the job is completed, you can enact the changes.
// Note that Playback can only be called on the main thread.
ecb.Playback(this.EntityManager);

// You are responsible for disposing of any ECB you create.
ecb.Dispose();

在并行作业中使用 EntityCommandBuffer

如果要在并行作业中使用实体命令缓冲区,请使用 EntityCommandBuffer.ParallelWriter,它以线程安全的方式并发记录到命令缓冲区:

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

// Methods of this writer record commands to 
// the EntityCommandBuffer in a thread-safe way.
EntityCommandBuffer.ParallelWriter parallelEcb = ecb.AsParallelWriter();

note
只有记录需要线程安全才能并发。回放在主线程上始终是单线程的。

确定性回放

因为命令的记录是跨线程拆分的,所以记录命令的顺序取决于作业调度,因此是不确定的。

确定性并不总是必不可少的,但产生确定性结果的代码更容易调试。还有一些网络场景需要在不同机器上获得一致的结果。但是,确定性会对性能产生影响,因此您可能需要在某些项目中接受不确定性。

您无法避免不确定的录制顺序,但可以通过以下方式确定命令的播放顺序:

  1. 每个命令记录一个“排序键”int 作为第一个参数传递给每个命令方法。您必须调用 lambda 参数 entityInQueryIndex,否则 Entities.ForEach 将无法识别 int。
  2. 在播放时,在执行命令之前按命令的排序键对命令进行排序。
    只要记录的排序键独立于调度,排序就可以确定播放顺序。

在并行作业中,每个实体所需的排序键是一个数字,它与作业查询中的该实体具有固定且唯一的关联。

并行作业中提供的 entityInQueryIndex 值满足这些条件。在与作业查询匹配的原型块列表中,实体具有以下索引:

  • 第一个块的第一个实体有 entityInQueryIndex 0
  • 第一个块的第二个实体有 entityInQueryIndex 1
  • 第二个块的第一个实体有一个 entityInQueryIndex,它是第一个块的计数
  • 第三个块的第一个实体有一个 entityInQueryIndex,它是前两个块的计数之和

entityInQueryIndex 始终遵循此模式。

以下示例代码显示了并行作业中使用的实体命令缓冲区:

// ... in a system update

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

// We need to write to the ECB concurrently across threads.
EntityCommandBuffer.ParallelWriter ecbParallel = ecb.AsParallelWriter();

// The entityInQueryIndex is unique for each entity and will be
// consistent for each particular entity regardless of scheduling.
Entities
    .ForEach((Entity e, int entityInQueryIndex, in FooComp foo) => {
        if (foo.Value > 0)
        {
            // The first arg is the 'sort key' recorded with the command.
            ecbParallel.AddComponent<BarComp>(entityInQueryIndex, e);
        }
    }).Schedule();

// Playback is single-threaded as normal.
this.Dependency.Complete();

// To ensure deterministic playback order,
// the commands are first sorted by their sort keys.
ecb.Playback(this.EntityManager);

ecb.Dispose();

重用 EntityCommandBuffer 实例

最好的做法是为每个作业提供自己的命令缓冲区。这是因为与在单个命令缓冲区中记录相同的命令相比,将一组命令记录到多个命令缓冲区的开销很小。

但是,您可以在非并行作业中重用相同的 EntityCommandBuffer,只要这些作业在调度中不重叠即可。如果您在并行作业中重复使用 EntityCommandBuffer 实例,这可能会导致播放中命令的意外排序顺序,除非每个作业的排序键位于不同的范围内。

多重回放

如果多次调用 Playback 方法,它会抛出异常。为避免这种情况,请使用 PlaybackPolicy.MultiPlayback 选项创建一个 EntityCommandBuffer 实例:

// ... in a system update

EntityCommandBuffer ecb =
        new EntityCommandBuffer(Allocator.TempJob, PlaybackPolicy.MultiPlayback);

// ... record commands

ecb.Playback(this.EntityManager);

// Additional playbacks are OK because this ECB is MultiPlayback.
ecb.Playback(this.EntityManager);

ecb.Dispose();

如果您想重复生成一组实体,则多重播放很有用。为此,使用 EntityCommandBuffer 创建并配置一组新实体,然后重复播放以重新生成另一组匹配的实体。

在主线程上使用 EntityCommandBuffer

您可以在主线程上记录命令缓冲区更改。这在以下情况下很有用:

  • 延迟您的更改。
  • 多次回放一组更改。
  • 在一个统一的地方回放很多变化。这比将更改散布在框架的不同部分更有效。

每个结构更改操作都会触发一个同步点,这意味着该操作必须等待部分或所有计划作业完成。如果将结构更改组合到命令缓冲区中,则帧的同步点会更少。

使用 EntityCommandBufferSystem 自动播放和处理命令缓冲区

您可以使用 EntityCommandBufferSystem 回放和处理命令缓冲区,而不是自己手动执行。去做这个:

  1. 获取要进行播放的 EntityCommandBuffer 系统的实例。
  2. 通过系统创建一个 EntityCommandBuffer 实例。
  3. 安排一个将命令写入 EntityCommandBuffer 的作业。
  4. 注册系统完成的预定作业。

例如:

// ... in a system

// Assume an EntityCommandBufferSystem exists named FooECBSystem.
EntityCommandBufferSystem sys =
        this.World.GetExistingSystemManaged<FooECBSystem>();

// Create a command buffer that will be played back
// and disposed by MyECBSystem.
EntityCommandBuffer ecb = sys.CreateCommandBuffer();

// A `ForEach` with no argument to Schedule implicitly
// assigns its returned JobHandle to this.Dependency
Entities
    .ForEach((Entity e, in FooComp foo) => {
        // ... record to the ECB
    }).Schedule();

// Register the job so that it gets completed by the ECB system.
sys.AddJobHandleForProducer(this.Dependency);

不要手动回放和处置 EntityCommandBufferSystem 创建的 EntityCommandBuffer。 EntityCommandBufferSystem 为您完成这两件事。

在每次更新中,一个 EntityCommandBufferSystem:

  1. 完成所有已注册的作业,以确保他们已完成录制)。
  2. 以创建它们的相同顺序播放通过系统创建的所有实体命令缓冲区。
  3. 处理 EntityCommandBuffer。

默认 EntityCommandBufferSystem 系统

默认世界具有以下默认 EntityCommandBufferSystem 系统:

  • BeginInitializationEntityCommandBufferSystem
  • EndInitializationEntityCommandBufferSystem
  • BeginSimulationEntityCommandBufferSystem
  • EndSimulationEntityCommandBufferSystem
  • BeginPresentationEntityCommandBufferSystem

因为在 Unity 将渲染数据交给渲染器之后,帧中不会发生结构变化,所以没有 EndPresentationEntityCommandBufferSystem 系统。您可以改用 BeginInitializationEntityCommandBufferSystem:一帧的结尾是下一帧的开始。

这些更新在标准系统组的开头和结尾。有关详细信息,请参阅有关系统更新顺序的文档。

对于大多数用例,默认系统应该足够了,但如果需要,您可以创建自己的 EntityCommandBufferSystem:

// You should specify where exactly in the frame
// that the ECB system should update.
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(FooSystem))]
public class MyECBSystem : EntityCommandBufferSystem {
    // This class is intentionally empty. There is generally no
    // reason to put any code in an EntityCommandBufferSystem.
}

延迟实体

EntityCommandBuffer 方法 CreateEntity 和 Instantiate 记录创建实体的命令。这些方法只记录命令,不创建实体。因此,它们返回带有负索引的实体值,代表尚不存在的占位符实体。这些占位符实体值仅在同一 ECB 的记录命令中有意义。

// ... in a system

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

Entity placeholderEntity = ecb.CreateEntity();

// Valid to use placeholderEntity in later commands of same ECB.
ecb.AddComponent<FooComp>(placeholderEntity);

// The real entity is created, and
// FooComp is added to the real entity.
ecb.Playback(this.EntityManager);

// Exception! The placeholderEntity has no meaning outside
// the ECB which created it, even after playback.
this.EntityManager.AddComponent<BarComp>(placeholderEntity);

ecb.Dispose();

AddComponent、SetComponent 或 SetBuffer 命令中记录的值可能具有 Entity 字段。在回放中,Unity 将这些组件或缓冲区中的任何占位符实体值重新映射到相应的实际实体。

// ... in a system

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

// For all entities with a FooComp component...
Entities
    .WithAll<FooComp>()
    .ForEach((Entity e) =>
    {
        // In playback, an actual entity will be created
        // that corresponds to this placeholder entity.
        Entity placeholderEntity = ecb.CreateEntity();

        // (Assume BarComp has an Entity field called TargetEnt.)
        BarComp bar = new BarComp { TargetEnt = placeholderEntity };

        // In playback, TargetEnt will be assigned the
        // actual Entity that corresponds to placeholderEntity.
        ecb.AddComponent(e, bar);
    }).Run();

// After playback, each entity with FooComp now has a
// BarComp component whose TargetEnt references a new entity.
ecb.Playback(this.EntityManager);

ecb.Dispose();

Entities.ForEach 方法中使用命令缓冲区

要在 Entities.ForEach 方法中使用命令缓冲区,请将 EntityCommandBuffer 参数传递给 lambda 表达式本身。仅支持一小部分 EntityCommandBuffer 方法,它们具有 [SupportedInEntitiesForEach] 属性:

  • Entity Instantiate(实体实体)
  • void DestroyEntity(实体实体)
  • void AddComponent(Entity e, T component) where T : unmanaged, IComponentData
  • void SetComponent(Entity e, T component) where T : unmanaged, IComponentData
  • void RemoveComponent(实体 e)
    例如,以下代码执行此操作:
  1. 它检查每个实体以查看其 HealthLevel 是否为 0。
  2. 如果为真,它会记录一条销毁实体的命令。
  3. 它还指定 EndSimulationEntityCommandBufferSystem 应该回放命令。
    public struct HealthLevel : IComponentData
    {
        public int Value;
    }
    
    Entities
        .WithDeferredPlaybackSystem<EndSimulationEntityCommandBufferSystem>
        .ForEach(
            (Entity entity, EntityCommandBuffer buffer, HealthLevel healthLevel) => 
            {
                if (healthLevel == 0)
                {
                    buffer.DestroyEntity(entity);
                }
            }
        ).ScheduleParallel();
    当您在 ForEach() 函数中使用这些方法中的任何一种时,编译器会在运行时生成创建、填充、回放和处理 EntityCommandBuffer 实例或 EntityCommandBuffer.ParallelWriter 实例所需的代码(如果调用了 ScheduleParallel()) .

ForEach() 之外调用这些方法会导致异常。

Entities.ForEach 中回放 EntityCommandBuffer

要将 EntityCommandBuffer 参数传递给 ForEach() 函数,您还必须调用以下方法之一来指定何时回放命令:

  • 延迟回放:调用 WithDeferredPlaybackSystem(),其中 T 标识回放命令的实体命令缓冲区系统。它必须是从 EntityCommandBufferSystem 派生的类型。
  • 立即回放:在 ForEach() 函数完成所有迭代后立即调用 WithImmediatePlayback() 执行实体命令。您只能将 WithImmediatePlayback() 与 Run() 一起使用。

编译器自动生成代码来创建和处理任何 EntityCommandBuffer 实例。

查找任意数据

访问和修改数据的最有效方法是使用具有实体查询和作业的系统。这以最高效的方式利用 CPU 资源,内存缓存未命中率最低。理想情况下,您应该使用最有效、最快的路径来执行大量数据转换。但是,有时您可能需要在程序的任意位置访问任意实体的任意组件。

您可以在实体的 IComponentData 及其动态缓冲区中查找数据。您查找数据的方式取决于您的代码是使用 Entities.ForEach、IJobEntityBatch 作业,还是在系统中执行的主线程上的其他一些方法。

在系统中查找实体数据

要从系统的 Entities.ForEach 或 Job.WithCode 方法中查找存储在任意实体组件中的数据,请使用 GetComponent(Entity)

例如,以下代码使用 GetComponent(Entity) 获取 Target 组件,该组件具有标识要定位的实体的实体字段。然后它将跟踪实体旋转到它们的目标:

[RequireMatchingQueriesForUpdate]
public partial class TrackingSystem : SystemBase
{
    protected override void OnUpdate()
    {
        float deltaTime = SystemAPI.Time.DeltaTime;

        Entities
            .ForEach((ref Rotation orientation,
            in LocalToWorld transform,
            in Target target) =>
            {
                // Check to make sure the target Entity still exists and has
                // the needed component
                if (!HasComponent<LocalToWorld>(target.entity))
                    return;

                // Look up the entity data
                LocalToWorld targetTransform = GetComponent<LocalToWorld>(target.entity);
                float3 targetPosition = targetTransform.Position;

                // Calculate the rotation
                float3 displacement = targetPosition - transform.Position;
                float3 upReference = new float3(0, 1, 0);
                quaternion lookRotation = quaternion.LookRotationSafe(displacement, upReference);

                orientation.Value = math.slerp(orientation.Value, lookRotation, deltaTime);
            })
            .ScheduleParallel();
    }
}

如果要访问存储在动态缓冲区中的数据,还需要在 SystemBase 的 OnUpdate 方法中声明一个 BufferLookup 类型的局部变量。然后,您可以在 lambda 表达式中捕获局部变量。例如:

public struct BufferData : IBufferElementData
{
    public float Value;
}
[RequireMatchingQueriesForUpdate]
public partial class BufferLookupSystem : SystemBase
{
    protected override void OnUpdate()
    {
        BufferLookup<BufferData> buffersOfAllEntities
            = this.GetBufferLookup<BufferData>(true);

        Entities
            .ForEach((ref Rotation orientation,
            in LocalToWorld transform,
            in Target target) =>
            {
                // Check to make sure the target Entity with this buffer type still exists
                if (!buffersOfAllEntities.HasBuffer(target.entity))
                    return;

                // Get a reference to the buffer
                DynamicBuffer<BufferData> bufferOfOneEntity = buffersOfAllEntities[target.entity];

                // Use the data in the buffer
                float avg = 0;
                for (var i = 0; i < bufferOfOneEntity.Length; i++)
                {
                    avg += bufferOfOneEntity[i].Value;
                }
                if (bufferOfOneEntity.Length > 0)
                    avg /= bufferOfOneEntity.Length;
            })
            .ScheduleParallel();
    }
}

在作业中查找实体数据

要在 IJobEntityBatch 等作业结构中随机访问组件数据,请使用以下类型之一:

  • 组件查找
  • 缓冲区查找
    这些类型获得一个类似于数组的组件接口,由 Entity 对象索引。

要使用它们,请声明一个类型为 ComponentLookup 或 BufferLookup 的字段,设置该字段的值,然后安排作业。

例如,您可以使用 ComponentLookup 字段来查找实体的世界位置:

[ReadOnly]
public ComponentLookup<LocalToWorld> EntityPositions;

此声明使用 ReadOnly 属性。您应该始终将 ComponentLookup 对象声明为只读,除非您想写入您访问的组件。

以下示例说明了如何设置数据字段和安排作业:

protected override void OnUpdate()
{
    var job = new ChaserSystemJob();

    // Set non-ECS data fields
    job.deltaTime = SystemAPI.Time.DeltaTime;

    // Schedule the job using Dependency property
    Dependency = job.ScheduleParallel(query, this.Dependency);
}

要查找组件的值,请在作业的 Execute 方法中使用实体对象:

                float3 targetPosition = entityPosition.Position;
if !ENABLE_TRANSFORM_V1
                float3 chaserPosition = transform.Value.Position;
else
                float3 chaserPosition = position.Value;
endif
                float3 displacement = targetPosition - chaserPosition;
                float3 newPosition = chaserPosition + displacement * deltaTime;
if !ENABLE_TRANSFORM_V1
                transform.Value.Position = newPosition;
else
                position = new Translation { Value = newPosition };
endif

以下完整示例显示了一个系统,该系统将具有目标字段的实体移动到目标的当前位置:

    [RequireMatchingQueriesForUpdate]
    public partial class MoveTowardsEntitySystem : SystemBase
    {
        private EntityQuery query;

        [BurstCompile]
        private partial struct MoveTowardsJob : IJobEntity
        {

            // Read-only data stored (potentially) in other chunks
            [ReadOnly]
            public ComponentLookup<LocalToWorld> EntityPositions;

            // Non-entity data
            public float deltaTime;

if !ENABLE_TRANSFORM_V1
            public void Execute(ref LocalToWorldTransform transform, in Target target, in LocalToWorld entityPosition)
else
            public void Execute(Translation position, in Target target, in LocalToWorld entityPosition)
endif
            {
                // Get the target Entity object
                Entity targetEntity = target.entity;

                // Check that the target still exists
                if (!EntityPositions.HasComponent(targetEntity))
                    return;

                // Update translation to move the chasing enitity toward the target
                float3 targetPosition = entityPosition.Position;
if !ENABLE_TRANSFORM_V1
                float3 chaserPosition = transform.Value.Position;

                float3 displacement = targetPosition - chaserPosition;
                transform.Value.Position = chaserPosition + displacement * deltaTime;
else
                float3 chaserPosition = position.Value;

                float3 displacement = targetPosition - chaserPosition;
                position = new Translation
                {
                    Value = chaserPosition + displacement * deltaTime
                };
endif
            }
        }

        protected override void OnCreate()
        {
            // Select all entities that have Translation and Target Component
            query = this.GetEntityQuery
                (
if !ENABLE_TRANSFORM_V1
                    typeof(LocalToWorldTransform),
else
                    typeof(Translation),
endif
                    ComponentType.ReadOnly<Target>()
                );
        }

        protected override void OnUpdate()
        {
            // Create the job
            var job = new MoveTowardsJob();

            // Set the component data lookup field
            job.EntityPositions = GetComponentLookup<LocalToWorld>(true);

            // Set non-ECS data fields
            job.deltaTime = SystemAPI.Time.DeltaTime;

            // Schedule the job using Dependency property
            Dependency = job.ScheduleParallel(query, Dependency);
        }
    }

数据访问错误

如果您查找的数据与您要在作业中读取和写入的数据重叠,则随机访问可能会导致竞争条件。

如果您确定要直接读取或写入的实体数据与要随机读取或写入的特定实体数据之间没有重叠,则可以使用 NativeDisableParallelForRestriction 属性标记访问器对象。

写入组

写入组为一个系统提供了一种覆盖另一个系统的机制,即使您无法更改另一个系统。

一种常见的 ECS 模式是系统读取一组输入组件并将其写入另一个组件作为其输出。但是,您可能希望覆盖系统的输出,并使用基于不同输入集的不同系统来更新输出组件。

目标组件类型的写入组由 ECS 将 WriteGroup 属性应用于的所有其他组件类型组成,并以该目标组件类型作为参数。作为系统创建者,您可以使用写入组,以便您的系统用户可以排除您的系统将以其他方式选择和处理的实体。这种过滤机制允许系统用户根据自己的逻辑为排除的实体更新组件,同时让您的系统在其余部分照常运行。

使用写组

要使用写入组,您必须对系统中的查询使用写入组过滤器选项。这从查询中排除所有实体,这些实体具有来自查询中可写的任何组件的写入组的组件。

要覆盖使用写入组的系统,请将您自己的组件类型标记为该系统输出组件的写入组的一部分。原始系统忽略任何具有您的组件的实体,您可以使用您自己的系统更新这些实体的数据。

编写组示例

在此示例中,您使用外部包根据角色的健康状况为游戏中的所有角色着色。为此,包中有两个组件:HealthComponent 和 ColorComponent;

public struct HealthComponent : IComponentData
{
   public int Value;
}

public struct ColorComponent : IComponentData
{
   public float4 Value;
}

包中还有两个系统:

  1. ComputeColorFromHealthSystem,它从 HealthComponent 读取并写入 ColorComponent
  2. RenderWithColorComponent,从 ColorComponent 读取
    为了表示玩家何时使用能量提升并且他们的角色变得无敌,您将 InvincibleTagComponent 附加到角色的实体。在这种情况下,角色的颜色应该更改为单独的不同颜色,而上面的示例不适用。

您可以创建自己的系统来覆盖 ColorComponent 值,但理想情况下 ComputeColorFromHealthSystem 不会计算实体的颜色。它应该忽略任何具有 InvincibleTagComponent 的实体。当屏幕上有成千上万的玩家时,这就变得更加重要。

这个系统来自另一个不知道 InvincibleTagComponent 的包,所以这是写组有用的时候。当您知道它计算的值无论如何都会被覆盖时,它允许系统忽略查询中的实体。您需要做两件事来支持这一点:

  1. 将 InvincibleTagComponent 标记为 ColorComponent 写入组的一部分:

    [WriteGroup(typeof(ColorComponent))]
    struct InvincibleTagComponent : IComponentData {}

    ColorComponent 的写入组由所有组件类型组成,这些组件类型具有以 typeof(ColorComponent) 作为参数的 WriteGroup 属性。

  2. ComputeColorFromHealthSystem 必须明确支持写组。为此,系统需要为其所有查询指定 EntityQueryOptions.FilterWriteGroup 选项,如下所示:

    ...
    protected override void OnUpdate() {
    Entities
        .WithName("ComputeColor")
        .WithEntityQueryOptions(EntityQueryOptions.FilterWriteGroup) // support write groups
        .ForEach((ref ColorComponent color, in HealthComponent health) => {
            // compute color here
        }).ScheduleParallel();
    }
    ...

    执行时,会发生以下情况:

  3. 系统检测到你写入 ColorComponent 因为它是一个引用参数

  4. 它查找 ColorComponent 的写入组并在其中找到 InvincibleTagComponent

  5. 它排除了所有具有 InvincibleTagComponent 的实体

好处是,这允许系统根据系统未知的类型排除实体,并且可能存在于不同的包中。

有关更多示例,请参阅 Unity.Transforms 代码,它为其更新的每个组件使用写入组,包括 LocalToWorld。

创建写入组

要创建写入组,请将 WriteGroup 属性添加到写入组中每个组件类型的声明中。 WriteGroup 属性采用一个参数,即组中组件用于更新的组件类型。单个组件可以是多个写入组的成员。

例如,如果您的系统在实体上存在组件 A 或 B 时写入组件 W,那么您可以为 W 定义一个写入组,如下所示:

public struct W : IComponentData
{
   public int Value;
}

[WriteGroup(typeof(W))]
public struct A : IComponentData
{
   public int Value;
}

[WriteGroup(typeof(W))]
public struct B : IComponentData
{
   public int Value;
}

您没有将写入组的目标(上例中的组件 W)添加到它自己的写入组。

启用写组过滤

要启用写入组过滤,请在您的作业上设置 FilterWriteGroups 标志:

public class AddingSystem : SystemBase
{
   protected override void OnUpdate() {
      Entities
          // support write groups by setting EntityQueryOptions
         .WithEntityQueryOptions(EntityQueryOptions.FilterWriteGroup) 
         .ForEach((ref W w, in B b) => {
            // perform computation here
         }).ScheduleParallel();}
}

对于查询描述对象,在创建查询时设置标志:

public class AddingSystem : SystemBase
{
   private EntityQuery m_Query;

   protected override void OnCreate()
   {
       var queryDescription = new EntityQueryDesc
       {
           All = new ComponentType[] {
              ComponentType.ReadWrite<W>(),
              ComponentType.ReadOnly<B>()
           },
           Options = EntityQueryOptions.FilterWriteGroup
       };
       m_Query = GetEntityQuery(queryDescription);
   }
   // Define IJobEntityBatch struct and schedule...
}

当您在查询中启用写入组过滤时,该查询会将可写组件的写入组中的所有组件添加到查询的 None 列表中,除非您明确将它们添加到 All 或 Any 列表中。因此,如果查询明确需要来自特定写入组的实体上的每个组件,则查询只会选择该实体。如果实体具有来自该写入组的一个或多个附加组件,则查询将拒绝它。

在上面的示例代码中,查询:

  • 排除具有组件 A 的任何实体,因为 W 是可写的并且 A 是 W 的写入组的一部分。
  • 不排除具有组件 B 的任何实体。即使 B 是 W 的写入组的一部分,它也在 All 列表中明确指定。

覆盖另一个使用写入组的系统

如果系统在其查询中使用写组过滤,您可以使用自己的系统覆盖该系统并写入那些组件。要覆盖系统,请将您自己的组件添加到其他系统写入的组件的写入组中。

因为写组过滤排除了查询未明确要求的写组中的任何组件,所以其他系统会忽略任何具有您的组件的实体。

例如,如果您想通过指定旋转的角度和轴来设置实体的方向,您可以创建一个组件和一个系统来将角度和轴值转换为四元数并将其写入 Unity.Transforms.Rotation零件。

为了防止 Unity.Transforms 系统更新 Rotation,无论除您之外的其他组件是否存在,您都可以将您的组件放在 Rotation 的写入组中:

using System;
using Unity.Collections;
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;

[Serializable]
[WriteGroup(typeof(Rotation))]
public struct RotationAngleAxis : IComponentData
{
   public float Angle;
   public float3 Axis;
}

然后,您可以无竞争地使用 RotationAngleAxis 组件更新任何实体:

using Unity.Burst;
using Unity.Entities;
using Unity.Jobs;
using Unity.Collections;
using Unity.Mathematics;
using Unity.Transforms;

public class RotationAngleAxisSystem : SystemBase
{
   protected override void OnUpdate()
   {
      Entities.ForEach((ref Rotation destination, in RotationAngleAxis source) =>
      {
         destination.Value 
             = quaternion.AxisAngle(math.normalize(source.Axis), source.Angle);
      }).ScheduleParallel();
   }
}

扩展另一个使用写组的系统

如果你想扩展另一个系统而不是覆盖它,或者如果你想让未来的系统覆盖或扩展你的系统,那么你可以在你自己的系统上启用写组过滤。但是,当您这样做时,默认情况下两个系统都不会处理任何组件组合。您必须明确查询和处理每个组合。

在前面的示例中,它定义了一个写入组,其中包含以组件 W 为目标的组件 A 和 B。如果将名为 C 的新组件添加到写入组,那么知道 C 的新系统可以查询包含的实体C,这些实体是否也有组件 A 或 B 并不重要。

但是,如果新系统还启用了写组过滤,那就不再是这样了。如果您只需要组件 C,则写入组过滤会排除任何具有 A 或 B 的实体。相反,您必须显式查询每个有意义的组件组合。

您可以在适当的时候使用查询的 Any 子句。

var query = new EntityQueryDesc
{
    All = new ComponentType[] {
       ComponentType.ReadOnly<C>(), 
       ComponentType.ReadWrite<W>()
    },
    Any = new ComponentType[] {
       ComponentType.ReadOnly<A>(), 
       ComponentType.ReadOnly<B>()
    },
    Options = EntityQueryOptions.FilterWriteGroup
};

如果有任何实体包含未明确提及的写入组中的组件组合,则写入写入组目标的系统及其过滤器不会处理它们。但是,如果存在任何这些类型的实体,则很可能是程序中的逻辑错误,它们不应该存在。

版本号

您可以使用 ECS 架构各部分的版本号(也称为世代)来检测潜在的变化并实施有效的优化策略,例如在数据自应用程序的最后一帧以来未发生变化时跳过处理。对实体执行快速版本检查以提高应用程序的性能非常有用。

此页面概述了 ECS 使用的所有不同版本号,以及导致它们更改的条件。

版本号结构

所有版本号都是 32 位有符号整数。它们总是增加,除非它们环绕:有符号整数溢出是 C# 中定义的行为。这意味着要比较版本号,您应该使用(不)相等运算符,而不是关系运算符。

例如,检查 VersionB 是否比 VersionA 更新的正确方法是使用以下内容:

bool VersionBIsMoreRecent = (VersionB - VersionA) > 0;

无法保证版本号增加多少。

实体版本号

EntityId 包含索引和版本号。因为 ECS 回收索引,所以每次实体销毁实体时它都会增加 EntityManager 中的版本号。如果在 EntityManager 中查找 EntityId 时版本号不匹配,则意味着引用的实体不再存在。

例如,在您通过 EntityId 获取一个单位正在跟踪的敌人的位置之前,您可以调用 ComponentDataFromEntity.Exists。这使用版本号来检查实体是否仍然存在。

世界版本号

ECS每创建或销毁一个管理器(如系统),就会增加一个世界的版本号。

作业组件系统版本号

EntityDataManager.GlobalVersion 在每次作业组件系统更新之前都会增加。

您应该将此版本号与 System.LastSystemVersion 结合使用。这会在每次作业组件系统更新后获取 EntityDataManager.GlobalVersion 的值。

您应该将此版本号与 Chunk.ChangeVersion[] 结合使用。

块.ChangeVersion

对于原型中的每个组件类型,此数组包含 EntityDataManager.GlobalVersion 的值,在组件数组最后一次被访问为在此块中可写时。这并不能保证任何事情都发生了变化,只是它可能已经发生了变化。

您不能以可写方式访问共享组件,即使也为这些组件存储了版本号:它没有任何用处。

当您在 Entities.ForEach 构造中使用 WithChangeFilter() 方法时,ECS 将该特定组件的 Chunk.ChangeVersion 与 System.LastSystemVersion 进行比较,并且它仅处理其组件数组在系统上次开始运行后被访问为可写的块。

例如,如果保证一组单位的生命值数量自上一帧以来没有变化,则可以跳过检查这些单位是否应更新其损坏模型。

非共享组件版本号

对于每个非共享组件类型,每当涉及该类型的迭代器变得无效时,ECS 都会增加 EntityManager.m_ComponentTypeOrderVersion[] 版本号。换句话说,任何可能修改该类型数组(不是实例)的东西。

例如,如果您有特定组件标识的静态对象和每个块的边界框,则只需在该组件的类型顺序版本更改时更新这些边界框。

共享组件版本号

当存储在引用共享组件的块中的实体发生任何结构更改时,SharedComponentDataManager.m_S​​haredComponentVersion[] 版本号会增加。

例如,如果您为每个共享组件保留一个实体计数,您可以依靠该版本号仅在相应版本号更改时重做每个计数。


ECS 系统
https://www.kuanmi.top/2022/11/16/ECS-systems-intro/
作者
KuanMi
发布于
2022年11月17日
许可协议