Unity* 实体组件系统 (ECS)、C# 作业系统和突发编译器入门

作者:Cristiano Ferreira (@cristianohh) 和 Mike Geig (@mikegeig)

Figure from a video game

低、中、高。GPU 设置的标准费用,但为何不使用 CPU 设置呢?如今,最终用户设备的 CPU 潜能可能会大不相同。通常情况下,开发人员将定义 CPU 最小规格,并使用该性能目标来实施仿真和游戏系统。这将使现代主流 CPU 中内置的许多潜在可用内核和功能处于闲置状态。Unity* 中的全新 C# 作业系统和实体组件系统不仅可以让您轻松利用以前未使用的 CPU 资源,还可以帮助您更高效地运行所有游戏代码。然后,您可以使用这些额外的 CPU 资源来添加更多场景动态和沉浸感。在本文中,您将了解如何快速开始学习这些新功能。

Unity 用于解决游戏引擎计算中两个重要的性能问题。它解决的第一个问题是数据布局效率低下。Unity 的实体组件系统 (ECS) 可改进数据存储的管理,在这些结构上实现高性能运算。第二个问题是缺少高性能作业语言和可以在有序数据上运行的 SIMD 矢量化。Unity 的全新 C# 作业系统、实体组件系统和突发编译器技术不具备这些缺点。

Unity 实体组件系统和 C# 作业系统是两个不同的系统,但它们密不可分。若要了解这两个系统,我们来看看用于在场景中创建对象的当前 Unity 工作流程,然后加以区分。

在当前的 Unity 工作流程中,您:

  • 创建 GameObject
  • 向游戏对象添加组件,为您的对象提供所需属性:
    • 渲染
    • 冲突
    • 刚体物理
  • 创建 MonoBehaviour 脚本并将其添加到对象中,以便在运行时控制和更改这些组件的状态。

我们将其称之为 Classic Unity 工作流程。这种做法有一些固有的缺点和性能考虑因素。首先,数据和处理是紧密耦合的。这意味着代码重用的发生频率较低,因为处理与一组特定数据相关联。除此之外,典型系统非常依赖于引用类型。

在下面所示的 Classic GameObject 和 Components 示例中,GameObject 依赖于 Transform、Renderer、Rigidbody 和 Collider 引用。在这些性能关键型脚本中引用的对象分散在堆内存中。因此,数据不会转换成可由更快的 SIMD 矢量单元进行操作的形式。

Classic gameobject and components lists
图 1.典型 gameobject 和组件列表。

通过缓存预取提高速度

从系统内存访问数据比从附近缓存中提取数据要慢得多。这就是预取发挥作用的地方。高速缓存预取是指计算机硬件预测接下来要访问哪些数据,然后抢先将其从速度较慢的最初内存提取到更快的内存中,以便在需要时进行加热和准备。使用此功能,硬件可以实现预测计算性能的提升。如果要迭代阵列,硬件预取单元可以学习将大量数据从系统内存拉入缓存中。当处理器在阵列的下一部分上运行时,必要的数据就位于缓存中并准备就绪。对于紧密打包的连续数据,就像在阵列中一样,硬件预取器很容易预测并获得正确的对象。当许多不同的游戏对象稀疏地分配在堆内存中时,预取器无法完成它的任务,迫使它获取无用的数据。

Scattered memory references between gameobjects
图 2.游戏对象、其行为及其组件之间分散的内存引用。

上图显示了这种数据存储方法的随机偶发性质。通过上面显示的场景,每个单引用(箭头) - 即使作为成员变量缓存 - 都可能从系统内存中全部拉出。Classic Unity GameObject 场景可以让您的游戏在非常短的时间内完成原型构建并运行,但它对于性能关键型模拟和游戏来说不太理想。为了深化这个问题,每个引用类型都包含可能不需要访问的许多额外数据。这些未使用的成员也占用了处理器缓存中的宝贵空间。如果只需要选择现有组件的少量成员变量,则可以将其余部分视为浪费空间,如下面的“浪费空间”图所示:

The few items used for the movement operation
图 3.粗体项目表示实际用于移动操作的成员;其余就是浪费空间

若要移动您的 GameObject,脚本需要从 Transform 组件访问位置和旋转数据成员。当您的硬件从内存中获取数据时,缓存行中会填充许多可能无用的数据。如果您只是为所有应该移动的GameObjects 设置一个只有位置和旋转成员的阵列,那不是很好吗?这将使您能够在很短的时间内执行通用操作。

进入实体组件系统

Unity 的新实体组件系统可帮助消除低效的对象引用。我们来考虑一下只包含它所需数据的实体,而不考虑带自己组件集合的GameObjects

在下面带有作业图的实体组件系统中,请注意 Bullet 实体没有附加Transform Rigidbody 组件。Bullet 实体只是显式运行您的更新例程所需的原始数据。借助这个新系统,您可以将处理与各个对象类型完全分离。

Entity component system with jobs diagram
图 4.具有作业图的实体组件系统

当然,从中获益的不仅仅是运动系统。许多游戏中的另一个常见组件是在各种敌人和盟友中建立的更复杂的卫生系统。对于不同的对象类型,这些系统通常几乎没有差异,所以它们是利用新系统的另一个绝佳备选。实体是一个句柄,用于索引表示它的不同数据类型的集合(ComponentDataGroups 的原型)。系统可以在没有程序员帮助的情况下,使用所需数据对所有组件进行过滤和操作;稍后我们将介绍更多信息。数据全部以紧凑的连续阵列高效组织,并在后台过滤,而无需将系统与实体类型明确结合。这个系统具有很大优势。它不仅可以提高缓存效率,缩短访问时间;它还支持在需要使用这种数据对齐方式的现代 CPU 中采用先进技术(自动矢量化/SIMD)这可为您提供游戏所需的默认性能。您可以提高每帧效率,或在更短时间内完成相同任务。您还将从即将发布的突发编译器特性中免费获得巨大的性能提升。

Wasted space generated by the classic system
图 5.请注意缓存行存储中的碎片和典型系统生成的空间浪费。数据比较见下图。

Comparison between Transform and DataNeededToMove
图 6.将与单个移动操作相关的内存空间与实现相同目标的两个操作进行对比。

突发编译器

突发编译器是实体组件系统更高效地组织数据所产生的后台性能增益。从本质上讲,突发编译器将根据玩家设备上的处理器功能优化代码操作。例如,您可以通过填充未使用的寄存器来执行 16、32 或 64,而不是一次只进行 1 次浮点运算。新的编译器技术用于 Unity 的新数学命名空间和 C# 作业系统中的代码(如下所述),基于系统知道数据已经通过实体组件系统正确设置的事实。英特尔 CPU 的当前版本支持英特尔® SIMD 流指令扩展 4(英特尔® SSE4)、英特尔® 高级矢量扩展指令集 2(英特尔® AVX2)以及用于浮点和整数的英特尔® 高级矢量扩展指令集 512(英特尔® AVX-512)。该系统还支持在每种方法中使用不同的精确度,以过渡方式应用。例如,如果您在低精度的顶级方法内使用余弦函数,则整个方法也将使用余弦的低精度版本。该系统还根据当前运行游戏的处理器的功能支持,通过动态选择适当的优化功能为 AOT(前期)编译做准备。这种编译方法的另一个优势是确保游戏的未来适用性。如果一款全新的处理器产品线上市,其中包含一些令人惊叹的新功能,Unity 可以在后台为您完成所有费力工作。只需对编译器进行升级,以获取优势。编译器是基于软件包的,无需 Unity 编辑器更新即可升级。由于突发软件包将以自己的节奏进行更新,因此您将能够利用最新的硬件架构改进和功能,而无需等待代码进入下一个编辑器版本。

C# 作业系统

大多数使用多线程代码和通用任务系统的人都知道编写线程安全代码很难。争用情况可能会发生,但非常罕见。如果编程员没有想到这个问题,可能会导致潜在的严重错误。除此之外,上下文切换的成本很高,因此学习如何平衡工作负载以尽可能高效地跨核心运行是很困难的。最后,编写 SIMD 优化代码或 SIMD 内联函数是一种深奥的技能,有时最好留给编译器。新的 Unity C# 作业系统为您解决所有这些难题,以便您可以在现代 CPU 中放心地使用所有可用的内核和 SIMD 矢量化。

C# job system diagram
图 7.C# 作业系统图。

我们来看一下简单的子弹运动系统。大多数游戏程序员都为某种类型的 GameObject 编写了一个管理器,如 Bullet Manager 中所示。通常,这些管理器会汇集一个 GameObjects 列表,并每帧更新场景中所有活动项目符号的位置。这对 C# 作业系统很有用。由于运动可以单独处理,因此非常适合并行化。借助 C# 作业系统,您可以轻松地将此功能拉出,并在不同核心上并行运行不同的数据块。作为开发人员,您不必担心管理这项工作分配;您只需要完全专注于游戏特定代码。您将明白如何轻松地做到这一点。

结合这两个新系统

实体组件系统和 C# 作业系统的结合可以为您提供比其各部分之和更强大的力量。由于实体组件系统以高效、紧凑的方式设置数据,因此作业系统可以拆分数据阵列,以便可以高效地并行操作。此外,您还可以从 缓存局部性和一致性中获得一些主要的性能优势。精简的按需分配和数据排列增加了作业所需的数据在需要之前存储在共享内存中的可能性。布局和作业系统的组合将产生可预测的访问模式,帮助硬件在后台做出明智的决策,从而为您提供出色的性能。

“好的!”您说:“这太棒了,但我如何使用这个新系统?”

为了帮助您熟悉,我们来对比一个非常简单的游戏的代码,它使用以下编程系统:

  1. 典型系统
  2. 使用作业的典型系统
  3. 使用作业的实体组件系统

以下是游戏的运行方式:

  • 玩家敲击空格键并在该帧中产生一定数量的船只。
  • 生成的每个船只都设置为屏幕边界内的随机 X 坐标。
  • 生成的每个船只都有一个移动功能,可将其发送到屏幕底部。
  • 一旦超过底部界限,生成的每个船只将重置其位置。

测试配置:

  • 在本文中,我们将介绍 Unity 分析器,这是一个非常强大的工具,用于隔离瓶颈和查看工作分配。参见 Unity 文档,了解更多信息!
    • 屏幕截图和数据采用英特尔® 酷睿™ i7-8700K 处理器和 NVIDIA GeForce* GTX 1080 显卡。

1.典型系统

典型系统检查每个帧的空格键输入并触发 AddShips() 方法。这种方法在屏幕的左侧和右侧之间找到随机 X/Z 位置,将船的旋转角度设置为指向下方,并在该位置生成船只预制件。

void Update()
{
    if (Input.GetKeyDown("space"))
        AddShips(enemyShipIncremement);
}

void AddShips(int amount)
{
    for (int i = 0; i < amount; i++)
    {
        float xVal = Random.Range(leftBound, rightBound);
        float zVal = Random.Range(0f, 10f);

        Vector3 pos = new Vector3(xVal, 0f, zVal + topBound);
        Quaternion rot = Quaternion.Euler(0f, 180f, 0f);

        var obj = Instantiate(enemyShipPrefab, pos, rot) as GameObject;
    }
}

代码示例显示如何使用典型系统添加船只

Classic battleship
图 8.典型船只预制件(资料来源:Unity.com 资产商店战舰包)。

船只对象生成,其每个组件都在堆内存中创建。附加的移动脚本每帧访问变换组件并更新位置,确保保持在屏幕的底部和顶部边界之间。超级简单!

using UnityEngine;

namespace Shooter.Classic
{
    public class Movement : MonoBehaviour
    {
        void Update()
        {
            Vector3 pos = transform.position;
            pos += transform.forward * GameManager.GM.enemySpeed * Time.deltaTime;

            if (pos.z < GameManager.GM.bottomBound)
                pos.z = GameManager.GM.topBound;

            transform.position = pos;
        }
    }
}

 

代码示例显示移动行为

下图显示了分析器一次在屏幕上跟踪 16,500 个对象。不错,但我们可以做得更好!继续阅读。

The profiler tracking 16,500 objects on 30 FPS screen
图 9.在一些初始化之后,分析器正在 30 FPS 下跟踪屏幕上的 16,500 个对象。

Classic performance visualization
图 10.典型性能可视化。

查看 BehaviorUpdate() 方法,您可以看到完成所有发货的行为更新需要 8.67 毫秒。另请注意,这一切都在主线程上进行。

在 C# 作业系统中,该工作将分配到所有可用内核。

2.使用作业的典型系统

using Unity.Jobs;
using UnityEngine;
using UnityEngine.Jobs;

namespace Shooter.JobSystem
{
    [ComputeJobOptimization]
    public struct MovementJob : IJobParallelForTransform
    {
        public float moveSpeed;
        public float topBound;
        public float bottomBound;
        public float deltaTime;

        public void Execute(int index, TransformAccess transform)
        {
            Vector3 pos = transform.position;
            pos += moveSpeed * deltaTime * (transform.rotation * new Vector3(0f, 0f, 1f));

            if (pos.z < bottomBound)
                pos.z = topBound;

            transform.position = pos;
        }
    }
}

 

使用 C# 作业系统显示作业移动实施的示例代码

我们的新 MovementJob 脚本是一个实现 IJob 接口变体之一的结构。这种自包含结构定义了任务或“作业”,以及完成该任务所需的数据。我们将使用 作业系统安排这种结构。对于每个船只的移动和边界检查计算,您知道您需要 calculations, you know you need the 移动速度上限下限增量时间 值。该作业没有增量时间的概念,因此必须明确提供数据。新位置的计算逻辑本身与典型系统相同,但是将数据分配回原始变换必须通过 TransformAccess 参数进行更新,因为引用类型(如 Transform)在此处无效。创建工作的基本要求包括实施 IJob 接口变量之一,如上例中的 IJobParallelForTransform 并实施针对您任务的 Execute 方法。创建后,可以将此作业结构传递到 Job Scheduler 中。在此处,系统将完成所有执行和相应处理。

为了了解关于这一任务结构的更多信息,我们来分析一下它使用的界面:IJob | ParallelFor | Transform。IJob 是所有 IJob 变体继承的基本接口。Parallel For Loop 是一种并行模式,它基本上采用典型的单线程进行循环,并根据在不同内核中操作的索引范围将工作主体拆分为块。最后,Transform 关键字表示要实施的 Execute 函数将包含 TransformAccess 参数,用于将移动数据提供给外部 Transform引用。若要将所有这些概念化,考虑在常规 for 循环中迭代的 800 个元素的数组。如果您有一个 8 核系统并且每个内核可以自动完成 100 个实体的工作,将会如何?啊哈!这正是系统要做的。

Using Jobs speeds up the iteration task
图 11.使用任务可大幅加速迭代任务。

界面名称末尾的 Transform 关键词为我们的 Execute 方法提供了 TransformAccess 参数。现在,只需知道针对每个 Execute 调用,每个船只的个别转换数据都会被传入。现在我们来看看游戏管理器中的 AddShips()Update() 方法,了解每帧如何设置这些数据。

 

using UnityEngine;
using UnityEngine.Jobs;

namespace Shooter.JobSystem
{
    public class GameManager : MonoBehaviour
    {

        // ...
        // GameManager classic members
        // ...

        TransformAccessArray transforms;
        MovementJob moveJob;
        JobHandle moveHandle;


        // ...
        // GameManager code
        // ...
    }
}

 

代码示例显示了设置和跟踪作业所需的变量

您会立即注意到,您需要跟踪一些新变量:

  • TransformAccessArray 是数据容器,它将保存对每个船只 Transform (job-ready TransformAccess) 的修改参考。普通的 Transform 数据类型不是线程安全的,因此这是一个方便的助手类型,用于为GameObjects设置移动相关数据。
  • MovementJob 是我们刚刚创建的作业结构的一个实例。我们将使用它在作业系统中配置工作。
  • JobHandle 是您的任务的唯一标识符,用于为各种操作(例如验证完成)引用您的任务。当您安排工作时,您将收到任务的句柄。
void Update()
{
    moveHandle.Complete();

    if (Input.GetKeyDown("space"))
        AddShips(enemyShipIncremement);

    moveJob = new MovementJob()
    {
        moveSpeed = enemySpeed,
        topBound = topBound,
        bottomBound = bottomBound,
        deltaTime = Time.deltaTime
    };

    moveHandle = moveJob.Schedule(transforms);

    JobHandle.ScheduleBatchedJobs();
}

void AddShips(int amount)
{
    moveHandle.Complete();

    transforms.capacity = transforms.length + amount;

    for (int i = 0; i < amount; i++)
    {
        float xVal = Random.Range(leftBound, rightBound);
        float zVal = Random.Range(0f, 10f);

        Vector3 pos = new Vector3(xVal, 0f, zVal + topBound);
        Quaternion rot = Quaternion.Euler(0f, 180f, 0f);

        var obj = Instantiate(enemyShipPrefab, pos, rot) as GameObject;

        transforms.Add(obj.transform);
    }
}

 

代码示例显示 C# 作业系统 + Classic Update() and AddShips() 实施

现在,您需要跟踪我们的任务,并确保它完成并重新安排每帧的新数据。上面的moveHandle.Complete() 行可确保主线程在计划任务完成之前不会继续执行。使用此作业句柄,可以准备并再次分派作业。返回 moveHandle.Complete() 后,您可以使用当前帧的新数据更新我们的 MovementJob,然后安排作业再次运行。虽然这是一个阻止操作,但它会阻止安排作业,同时仍执行旧任务。此外,它还会阻止我们在船只集合仍在迭代时添加新船只。在一个有很多任务的系统中,出于该原因,我们可能不想使用 Complete() 方法。

当您在 Update() 结束时安排 MovementJob 时,您还会向其传递需要从船只更新的所有变换的列表,通过 TransformAccessArray 访问。当所有作业都完成设置和计划后,您可以使用 JobHandle.ScheduleBatchedJobs() 方法调度所有作业。

AddShips() 方法类似于之前的实施,但有一些小的例外。如果从其他地方调用该方法,它会仔细检查作业是否已完成。这应该不会发生,但小心不出大错!此外,它还保存了对 TransformAccessArray 成员中新生成的变换的引用。让我们看看工作分布和性能如何。

With C# the number of figures is double
图 12.通过使用 C# 作业系统,我们可以在相同的帧时间(约 33 毫秒)内将典型系统中的屏幕对象数量增加近一倍。

C# job system + classic Profiler View
图 13.C# 作业系统 + 典型分析器视图。

现在您可以看到,Movement UpdateBoundingVolumes 作业每帧大约需要 4 毫秒。这有大幅改进!另请注意,屏幕上的船只数量几乎是典型系统的两倍!

但是,我们仍然可以做得更好。目前的方法仍然存在一些限制:

  • GameObject 实例化是一个冗长的过程,涉及系统调用内存分配。
  • Transforms 仍然分配在堆中的随机位置。
  • Transforms 仍包含未使用的数据,污染缓存行并降低内存访问效率。

3.使用作业的实体组件系统

这个问题有一些复杂,但是一旦明白了,您就会永远掌握这个知识。我们先来看看我们的新敌舰预制件如何解决这个问题:

Entity Component System ship prefab.
图 14.C# 作业系统 + 实体组件系统船只预制件

你可能会注意到一些新的东西。首先,除了 Transform 组件(未使用)之外,没有附加的内置 Unity 组件。这一预制件现在代表我们将用于生成实体的模板,而不是带组件的 GameObject 。预制件的概念并不像您习惯的那样完全适用于新系统。您可以将其视为存储实体数据的便捷容器。这一切都可以完全在脚本中完成。您现在还有一个附加到预制件的 GameObjectEntity.cs 脚本。这一必需组件表示此 GameObject 将被视为实体并使用新的实体组件系统。您可以看到,对象现在也包含一个 RotationComponent、一个PositionComponent 和一个 MoveSpeedComponent。标准组件(如位置和旋转)是内置的,不需要显式创建,但 MoveSpeed 需要。除此之外,我们有一个MeshInstanceRendererComponent,它向公共成员公开了一个支持 GPU 实例化的材质参考,这是新实体组件系统所必需的。让我们看看这些如何与新系统相结合。

using System;
using Unity.Entities;

namespace Shooter.ECS
{
    [Serializable]
    public struct MoveSpeed : IComponentData
    {
        public float Value;
    }

    public class MoveSpeedComponent : ComponentDataWrapper<MoveSpeed> { }
}

 

代码示例显示如何为实体组件系统设置 MoveSpeed 数据 (IComponentData)

当您打开其中一个数据脚本时,您会看到每个结构都继承自 IComponentData。这将数据标记为实体组件系统要使用和跟踪的类型,并允许在后台以智能方式分配和打包数据,同时您可以完全专注于您的游戏代码。ComponentDataWrapper 类允许您将这些数据公开到其附加的预制件的检视窗。您可以看到与此预制件关联的数据仅表示基本移动(位置和旋转)和移动速度所需的 Transform 组件的一部分。这是一个线索,您将不会在这一新工作流程中使用 Transform 组件。

让我们看看 GameplayManager 脚本的新版本:

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

namespace Shooter.ECS
{
    public class GameManager : MonoBehaviour
    {
        EntityManager manager;


        void Start()
        {
            manager = World.Active.GetOrCreateManager<EntityManager>();
            AddShips(enemyShipCount);
        }

        void Update()
        {
            if (Input.GetKeyDown("space"))
                AddShips(enemyShipIncremement);
        }

        void AddShips(int amount)
        {
            NativeArray<Entity> entities = new NativeArray<Entity>(amount, Allocator.Temp);
            manager.Instantiate(enemyShipPrefab, entities);

            for (int i = 0; i < amount; i++)
            {
                float xVal = Random.Range(leftBound, rightBound);
                float zVal = Random.Range(0f, 10f);
                manager.SetComponentData(entities[i], new Position { Value = new float3(xVal, 0f, topBound + zVal) });
                manager.SetComponentData(entities[i], new Rotation { Value = new quaternion(0, 1, 0, 0) });
                manager.SetComponentData(entities[i], new MoveSpeed { Value = enemySpeed });
            }
            entities.Dispose();
        }
    }
}

 

代码示例显示 C# 作业系统 + 实体组件系统 Update() 和 AddShips() 实施

我们进行了一些更改,以使实体组件系统能够使用脚本。请注意,您现在有一个 EntityManager 变量。您可以将此视为创建、更新或销毁实体的渠道。您还会注意到,用船只数量构建的NativeArray<Entity> 类型将生成。管理器的实例化方法采用 GameObject 参数和指定实例化实体数量的 NativeArray<Entity> 设置。传入的 GameObject 必须包含前面提到的 GameObjectEntity 脚本以及所需的任何组件数据。EntityManager 会根据 预制件 上的数据组件创建实体,而从未实际创建或使用任何 GameObjects

创建实体后,遍历所有实体并设置每个新实例的起始数据。此示例会设置起始位置、旋转和移动速度。完成后,必须释放安全且强大的新数据容器,以防止内存泄漏。移动系统现在可以提供演示。

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

namespace Shooter.ECS
{
    public class MovementSystem : JobComponentSystem
	{
        [ComputeJobOptimization]
        struct MovementJob : IJobProcessComponentData<Position, Rotation, MoveSpeed>
        {
            public float topBound;
            public float bottomBound;
            public float deltaTime;

            public void Execute(ref Position position, [ReadOnly] ref Rotation rotation, [ReadOnly] ref MoveSpeed speed)
            {
                float3 value = position.Value;

                value += deltaTime * speed.Value * math.forward(rotation.Value);

                if (value.z < bottomBound)
                    value.z = topBound;

                position.Value = value;
            }
        }

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            MovementJob moveJob = new MovementJob
            {
                topBound = GameManager.GM.topBound,
                bottomBound = GameManager.GM.bottomBound,
                deltaTime = Time.deltaTime
            };

            JobHandle moveHandle = moveJob.Schedule(this, 64, inputDeps);

            return moveHandle;
        }
    }
}

 

代码示例展示 C# 作业系统 + 实体组件移动系统实施

这是演示最基本的部分。设置实体后,您可以将所有相关的移动工作隔离到新的 MovementSystem。我们从示例代码的顶部到底部来介绍每个新概念。 

MovementSystem 类继承自 JobComponentSystem。这个基类为您提供了实施所需的回调函数,如 OnUpdate(),以确保与系统相关的所有代码保持独立。您可以在这个简洁的软件包中执行系统特定更新,而不是拥有 uber-GameplayManager.csJobComponentSystem 的理念是将包含的所有数据和生命周期管理存储在一个地方。

<ECS/ECS_MovementJobStruct.cs>

MovementJob 结构封装了作业所需的所有信息,包括通过 Execute 函数中的参数输入的每个实例数据以及通过 OnUpdate() 更新的成员变量的每个作业数据。请注意,除 position 参数之外,所有每个实例数据都标有 [ReadOnly] 属性。这是因为在这个例子中我们只更新每帧的位置。每个船只实体的 旋转 移动速度在其生命周期内都是固定的。实际的 Execute 函数包含对所有必需数据进行操作的代码。

您可能想知道如何将所有位置、旋转和移动速度数据输入到 Execute 函数调用中。这些操作会在后台自动进行。实体组件系统非常智能,能够针对包含 IComponentData 类型(指定为 IJobProcessComponentData 的模板参数)的所有实体自动过滤和注入数据。

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

namespace Shooter.ECS
{
    public class MovementSystem : JobComponentSystem
	{

        // ...
        // Movement Job
        // ...

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            MovementJob moveJob = new MovementJob
            {
                topBound = GameManager.GM.topBound,
                bottomBound = GameManager.GM.bottomBound,
                deltaTime = Time.deltaTime
            };

            JobHandle moveHandle = moveJob.Schedule(this, 64, inputDeps);

            return moveHandle;
        }
    }
}

 

 代码示例显示 C# 作业系统 OnUpdate() 方法实施

下面的 OnUpdate() 方法 MovementJob 也是新方法。这是 JobComponentSystem 提供的一个虚拟功能,因此您可以在同一个脚本中更轻松地组织每帧设置和调度。这里所做的一切都是:

  • 设置 MovementJob 数据,使用新注入的 ComponentDataArrays (每个实体实例数据)
  • 设置每帧数据(时间和边界)
  • 调度任务

瞧!我们的作业已经设置并且完全独立。在您首次实例化包含这一特定数据组件组的实体之前,不会调用OnUpdate() 函数。如果您决定添加一些具有相同移动行为的小行星,那么您只需要 GameObject 添加这三个相同的组件脚本(包含您实例化的代表性 GameObject 上的数据类型)即可。这里要知道的重要一点是,MovementSystem 并不关心它正在运行的实体是什么。它只关心实体是否包含它关注的数据类型。还有一些机制可以帮助控制生命周期。

With FPS ~33 ms can be used 91,000 objects
图 15.以约 33 毫秒的相同帧时间运行,我们现在可以使用实体组件系统在屏幕上一次拥有 91,000 个对象。

The available CPU time tracks more objects
图 16.由于不依赖于典型系统,实体组件系统可以使用可用的 CPU 时间来跟踪和更新更多对象。

正如您在上面的分析器窗口中看到的那样,您现在已经丢失了转换更新方法,该方法在 C# 作业系统和上面显示的典型组合部分的主线程上花费了相当多的时间。这是因为我们完全绕过了之前的 TransformArrayAccess 管道,并直接更新了 MovementJob 中的位置和旋转信息,然后显式构建了我们自己的渲染矩阵。这意味着无需写回传统的 Transform 组件。我们忘记了突发编译器的一个小细节。

突发编译器

现在,我们将采用完全相同的场景,除了将 [ComputeJobOptimization] 属性保留在作业结构之上以允许突发编译器接收作业之外,对代码完全没有任何作用,我们将获得所有这些优势。只需确保在下面显示的“作业”下拉窗口中选择“使用突发作业”设置。

The dropdown allows the use of Burst Jobs
图 17.下拉菜单允许使用突发作业。

 Burst Jobs allows 150,000 objects at once
图 18.通过允许突发作业优化 [ComputeJobOptimization] 属性的作业,我们可以将屏幕上同时显示的对象数量从 91,000 个增加到具有更高潜力的 150,000 个。

Time to complete MovementJob from 25 to 2 ms
图 19.在这个简单的示例中,所有 MovementJob 和 UpdateRotTransTransform 任务的总完成时间从 25 毫秒缩短为仅 2 毫秒。我们现在可以看到,瓶颈已经从 CPU 转移到 GPU,因为在 GPU 上渲染所有这些小型船只的成本现在超过了在 CPU 端跟踪、更新和渲染命令生成/分派的成本。

从截图中可以看出,我们在屏幕上以相同的帧速率获得了 59,000 多个实体。全部是免费的。这是因为,突发编译器能够对 Execute() 函数中的代码执行一些神秘魔法,从而充分利用紧凑的新数据布局和现代 CPU 在后台提供的最新架构增强功能。如上所述,这种神秘魔法实际上采用自动矢量化的形式,提供优化调度,可以更好地利用指令级并行性来减少管道中的数据依赖性和停顿。

结论

花几天时间研究所有这些新概念,它们将为后续项目带来好处。通过这些新系统获得的出色收益和节能效果是实实在在的真金白银。

表 1.优化带来了大幅改进,如屏幕上支持的对象数量和更新成本。

 典型C# 作业系统 + 典型C# 作业系统 + 实体组件系统(关闭突发)C# 作业系统 + 实体组件系统(开启突发)
总帧时间~ 33 毫秒 / 帧~ 33 毫秒 / 帧~ 33 毫秒 / 帧~ 33 毫秒 / 帧
屏幕上的对象数量16,50028,00091,000150,000+
移动作业时间成本~ 2.5 毫秒 / 帧~ 4 毫秒 / 帧~ 4 毫秒 / 帧~ < 0.5 毫秒 / 帧
绘制所有船只的 CPU 渲染时间成本10 毫秒 / 帧18.76 毫秒 / 帧计算渲染矩阵的 18.92 任务 + 3 毫秒渲染命令 = 21.92 毫秒 / 帧计算渲染矩阵的约 4.5 毫秒作业 + 4.27 毫秒渲染命令 = 8.77 毫秒 / 帧
时间 GPU 绑定~ 0 毫秒 / 帧~ 0 毫秒 / 帧~ 0 毫秒 / 帧~ 15.3 毫秒 / 帧

如果您的目标是移动平台并希望大幅降低玩家保留的电池消耗系数,只需获取收益并保存即可。如果您正在为 PC 大师赛提供高端桌面体验,请利用这些优势通过先进的模拟或破坏技术做一些特别的事情,使您的环境更具动态性、可交互性和沉浸感。站在巨人的肩膀上,利用这种革命性的新技术,实现以前认为不可能实时完成的事情,然后将其放在资产商店中,以便我使用。

感谢阅读。请继续关注来自 Unity 的更多样本 - 拭目以待!

资源

Unity

Unity 实体组件系统文档

Unity 实体组件系统示例

Unity 实体组件系统论坛

Unity 文档

了解高效的内存布局

演示中使用的船只资产

有关编译器优化的更完整信息,请参阅优化通知