谈谈Unity实体组件ECS与Jobs System

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/jxw167/article/details/81776399

Unity2018版本提供了ECS和Jobs System功能,网上也有很多这方面的技术介绍,本篇博客从Unity架构优化的角度给读者介绍关于ECS和Jobs System的使用,结合着实际案例希望让读者更容易理解它们,尤其是在IT游戏行业工作了两年以上的开发者,更应该掌握一些架构技术。
Unity 实体组件系统和 C# Job System 是两个不同的系统,但它们密不可分,若要了解这两个系统,我们先看看在 Unity 场景中创建游戏对象的工作流程如下所示:

  • 创建一个GameObject对象;
  • 在对象上添加组件:Renderer,Collider,Rigidbody physics;
  • 创建 MonoBehaviour 脚本并将其添加到对象中,以便在运行时控制和更改这些组件的状态属性;
    以上三个步骤执行,我们称为Unity的执行流程,作为Unity开发者来说,这个是最基本的流程。但是这种做法有它自己的缺点和性能问题。比如数据和逻辑是紧密耦合的,这意味着代码重用的频率较低,因为逻辑与特定数据相关联,无法单独分离出来。
    例如下图所示的 GameObject 和 Components 示例中,GameObject 依赖于 Transform、Renderer、Rigidbody 和 Collider 引用,在这些脚本中引用的对象分散在堆内存中。
    这里写图片描述
    游戏对象、其行为及其组件之间的内存引用,看下图:
    这里写图片描述
    Unity GameObject 场景可以让游戏在非常短的时间内完成原型构建并运行,这个也是Unity的特色可以让开发者快速入手,但它对于性能来说不太理想。我们再深层次的探讨这个问题,每个引用类型都包含可能不需要访问的许多额外数据,这些未使用的成员也占用了处理器缓存中的宝贵空间。比如我们继承的Mono就是一个典型的案例,如果只需要现有组件的极少功能接口函数或者变量,则可以将其余部分视为浪费空间,如下面的“浪费空间”图所示:
    这里写图片描述
    在上图中,粗体表示实际用于移动操作的成员,其余的就是浪费空间,若要移动 GameObject,脚本需要从 Transform 组件访问位置和旋转数据成员。当硬件从内存中获取数据时,缓存行中会填充许多可能无用的数据,如果只是为所有应该移动的GameObjects 设置一个只有位置和旋转成员的阵列,这将能够在很短的时间内执行,如何去掉无用的数据?ECS就是为解决此问题而设计的。
  • ECS实体组件系统
    Unity 的新实体组件系统可帮助消除低效的对象引用,我们考虑只包含它所需数据的实体,而不考虑带自己组件集合的GameObjects 。
    在下面的实体组件系统中,请注意 Bullet 实体没有附加Transform 或 Rigidbody 组件,Bullet 实体只是显式运行更新所需的原始数据,借助这个新系统,您可以将逻辑与各个对象类型完全分离。
    这里写图片描述
    这个系统具有很大的优势:它不仅可以提高缓存效率,缩短访问时间;它还支持在需要使用这种数据对齐方式的现代 CPU 中采用先进技术(自动矢量化/SIMD)这为游戏提供了所需的默认性能。如下图所示:
    这里写图片描述
    这里写图片描述
    上图请注意缓存行存储中的碎片和继承Mono系统生成的空间浪费,数据对比如下所示:
    这里写图片描述
    上图是将与单个移动操作相关的内存空间与实现相同目标的两个操作进行对比的结果。

  • C# Jobs System
    大多数使用多线程代码的人都知道编写线程安全代码很难,线程争抢资源可能会发生,但机会非常少,如果程序员没有想到这个问题,可能会导致潜在的严重错误。除此之外,上下文切换的成本很高,因此学习如何平衡工作负载以尽可能高效地运行是很困难的。新的 Unity C# Jobs System为您解决所有这些难题,如下图所示:
    这里写图片描述
    我们来看一下简单的子弹运动系统,大多数游戏程序员都会为 GameObject 编写一个管理器,如 Bullet Manager 中所示,通常,这些管理器会管理一个 GameObjects 列表,并每帧更新场景中所有子弹活动的位置。这非常符合使用 C# Jobs System的条件,由于子弹运动可以单独处理,因此非常适合并行化,借助 C# Jobs System,可以轻松地将此功能拉出来,并行运行不同的数据块,作为开发人员,您只需要专注于游戏逻辑代码即可。再介绍介绍实体组件系统和C# Jobs System二者的结合。

实体组件系统和 C# Jobs System的结合可以提供更强大的功能,由于实体组件系统以高效、紧凑的方式设置数据,因此Jobs System可以拆分数据阵列,以便可以高效地并行操作。但我如何使用这个新系统?
通过一个案例给读者介绍,下面是我们设计的游戏运行方式:

  • 玩家敲击空格键并在该帧中产生一定数量的船只。
  • 生成的每个船只都设置为屏幕边界内的随机 X 坐标。
  • 生成的每个船只都有一个移动功能,可将其发送到屏幕底部。
  • 一旦超过底部界限,生成的每个船只将重置其位置。
    这是一个比较简单的游戏逻辑,我们以此为例给读者介绍几种实现方式:
  • 继承Mono的设计
    这个是最常用的设计,作为游戏开发者,最容易掌握的,只需要检查每帧的空格键输入并触发 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;
    }
}

这里写图片描述
船只对象生成,其每个组件都在堆内存中创建,附加的移动脚本每帧更新位置,确保保持在屏幕的底部和顶部边界之间,超级简单!

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 个对象。不错,但我们可以做得更好!继续给读者分析。
这里写图片描述

这里写图片描述
我们查看 BehaviorUpdate() 方法,可以看到完成所有的行为更新需要 8.67 毫秒,另请注意,这一切都在主线程上进行,在 C# Jobs System中,该工作将分配到所有可用CPU上运行。我们测试时还是要充分利用Unity提供的工具分析方法的可行性是否影响效率?是由有优化的空间等等。
下面再看一种实现方式,在上述方案的基础上加入Jobs System。

  • 继承Mono的Jobs System
    上述方法编写脚本简单,作为刚入门的开发者是可以这么做的,但是作为工作几年的开发者如果还这样做就有问题了,我们要继续学习架构设计喽,先看代码实现:
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;
        }
    }
}

我们的新 MovementJob 脚本是一个实现 IJob 接口的结构,对于每个船只的移动和边界检查计算,需要计算移动速度、上限、下限和 增量时间 值。该Jobs System没有增量时间的概念,因此必须明确提供数据,新位置的计算逻辑本身与继承Mono相同,但是将数据分配回Transform变换必须通过 TransformAccess 参数进行更新,因为引用类型(如 Transform)在此处无效。如上面代码中的 IJobParallelForTransform 运行 Execute 方法,可以将此结构传递到 Job Scheduler 中,在此处,系统将完成所有执行和相应逻辑运行。
为了了解关于这一任务结构的更多信息,我们来分析一下它使用的界面:IJob | ParallelFor | Transform,IJob 是所有 IJob 变体继承的基本接口,Parallel For Loop 是一种并行模式,它基本上采用的是单线程进行循环,并根据在不同CPU中操作的索引范围将主体拆分为块。最后,Transform 表示要执行的 Execute 函数将包含 TransformAcess参数,用于将移动数据提供给外部 Transform引用,考虑在常规 for 循环中迭代的 800 个元素的数组,如果有一个 8 核系统并且每个CPU可以自动完成 100 个实体的工作,将会如何?这正是该系统要做的。
这里写图片描述
界面名称末尾的 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 是我们刚刚创建的Jobs结构的一个实例,我们将使用它在Jobs System中配置工作,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);
    }
}

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

当在 Update() 结束时调用 MovementJob 时,还会向其传递需要从船只更新的所有Transform的列表,通过 TransformAccessArray 访问,当所有Job都完成设置和计划后,可以使用 JobHandle.ScheduleBatchedJobs() 方法调度所有Jobs。
AddShips() 方法类似于之前的Execute,但有一些小的区别,如果从其他地方调用该方法,它会仔细检查Job是否已完成,这应该不会发生,但小心不出大错!此外,它还保存了对 TransformAccessArray 成员中新生成的Transform的引用。让我们看看Job性能如何。
这里写图片描述
通过使用 C# Job System,我们可以在相同的帧时间(约 33 毫秒)内将继承Mono中的屏幕对象数量增加近一倍。
这里写图片描述
现在可以看到,Movement 和 UpdateBoundingVolumes 作业每帧大约需要 4 毫秒,这有大幅改进!另请注意,屏幕上的船只数量几乎是继承Mono的两倍!
但是,我们仍然可以做得更好,目前的方法仍然存在一些限制:

  • GameObject 实例化是一个冗长的过程,涉及系统调用内存分配。
  • Transforms 仍然分配在堆中的随机位置。
  • Transforms 仍包含未使用的数据并降低内存访问效率。
  • C# Jobs System
    这个问题有一些复杂,但是一旦明白了,就会永远掌握这个知识,我们先来看看我们的新敌舰预制体如何解决这个问题:
    这里写图片描述
    你可能会注意到一些新的东西,首先,除了 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> { }
}

当打开其中一个数据脚本时,您会看到每个结构都继承自 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();
        }
    }
}

我们做了一些改变,以使实体组件系统能够使用脚本,请注意,现在有一个 EntityManager 变量,可以将此视为创建、更新或销毁实体的渠道,还需要注意到,用船只数量构建的NativeArray 类型将生成,管理器的实例化方法采用 GameObject 参数和指定实例化实体数量的 NativeArray 设置,传入的 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;
        }
    }
}

设置实体后,您可以将所有相关的移动工作隔离到新的 MovementSystem,我们从示例代码的顶部到底部来介绍每个新概念:
MovementSystem 类继承自 JobComponentSystem,这个基类为您提供了实施所需的回调函数,如 OnUpdate(),以确保与系统相关的所有代码保持独立,可以在这个简洁的软件包中执行系统特定更新,而不是拥有 uber-GameplayManager.cs,JobComponentSystem 的理念是将包含的所有数据和生命周期管理存储在一个地方。

MovementJob 结构封装了Job所需的所有信息,包括通过 Execute 函数中的参数输入的每个实例数据以及通过 OnUpdate() 更新的成员变量的每个Job数据。请注意,除 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;
        }
    }
}

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

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

我们的Job已经设置并且完全独立,在首次实例化包含这一特定数据组件组的实体之前,不会调用OnUpdate() 函数。如果决定添加一些具有相同移动行为的小行星,那么只需要 GameObject 添加这三个相同的组件脚本(包含您实例化的代表性 GameObject 上的数据类型)即可。这里要知道的重要一点是:MovementSystem 并不关心它正在运行的实体是什么,它只关心实体是否包含它关注的数据类型,还有一些机制可以帮助控制生命周期。
这里写图片描述
以约 33 毫秒的相同帧时间运行,我们现在可以使用实体组件系统在屏幕上一次拥有 91,000 个对象。
这里写图片描述
由于不依赖于Mono,实体组件系统可以使用可用的 CPU 时间来跟踪和更新更多对象。
正如在上面的分析器窗口中看到的那样,因为我们完全绕过了之前的 TransformArrayAccess 管道,并直接更新了 MovementJob 中的位置和旋转信息,然后显式构建了我们自己的渲染矩阵,这意味着无需写回传统的 Transform 组件。

  • 结论
    花几天时间研究所有这些新概念,它们将为后续的项目带来好处,我们的新项目也准备使用Unity2018.2,前期先做点测试看看运行性能,总之,新系统的测试效果还是不错的。最后再给读者看一组数据,如下图所示,优化带来了大幅改进,如屏幕上支持的对象数量和更新成本。

    这里写图片描述

  • 参考:

Unity实体组件系统文档

Unity 实体组件系统示例

Unity 实体组件系统论坛

Unity 文档

阅读更多

扫码向博主提问

海洋_

博客专家

非学,无以致疑;非问,无以广识
  • 擅长领域:
  • 3D引擎架构
  • 服务器架构
  • GPU渲染
  • 客户端架构
  • 引擎优化
去开通我的Chat快问
换一批

没有更多推荐了,返回首页