陈巧倩

Unity Dots概念与原理

· 673 words · 4 minutes to read
Categories: Unity
Tags: Dots

DOTS是指可以利用多核处理器来实现数据的并行处理并提高Unity项目的性能。

Dots相关概念与原理 🔗

Dots 🔗

DOTS(Data-Oriented Technology Stack)是一种数据驱动的技术堆栈,旨在提高Unity游戏引擎的性能和可伸缩性。主要包含以下三个部分:

  • Burst:Burst是一种高性能编译器,专门用于将C#代码编译成本机代码,从而提高游戏性能。它能够分析C#代码并生成最佳的本机代码,减少了虚拟机的开销。
  • Job System:JobSystem是一种可以极大提高游戏性能的工具,可以让开发人员使用并行运算来处理数据。它允许在多个线程上同时执行代码,从而使游戏更加流畅和轻松处理计算密集型任务。
  • Entity Component System:ECS(实体组件系统)是一种有别于传统OOP(面向对象思想)的编程模式,其编程模式对CPU Catch友好,因此可提升CPU效率。

注意事项(理解误区):

  • DOTS分为三个组件:ECS、JobSystem、Burst 三个组件可相互独立使用,并非必须捆绑使用。(区别在于单个组件的扩展项可能在其他的组件中,故而有可能会加入进来)
  • JobSystem无需配合ECS使用,各种需要大量或并行计算的地方都可以使用。
  • Burst无需配合ECS使用,各种计算密集的同步方法也可以使用。
  • 使用ECS不代表整个项目必须全用ECS来编写,可根据项目需求将ECS和传统面对对象方式组合使用。

Burst 🔗

概念 🔗

  • Burst Compiler:是Unity的一种编译器,它可以将C#代码编译为高效的本地代码,从而提高Unity应用程序的性能。Burst Compiler的优势在于它可以自动将C#代码转换为本地代码,并且可以使用SIMD指令和多线程技术来优化代码的性能。Burst在UnityEditor模式下采用JIT即时编译,在构建完成后运行的应用中使用AOT静态编译。

  • SIMD:Single Instruction Multiple Data,单指令多数据流,可以使用一条指令同时完成多个数据的运算操作。传统的指令架构是SISD就是单指令单数据流,每条指令只能对一个数据执行操作。

  • JIT:(即时编译Just In Time),程序在运行过程中,讲CIL的byte code转译为目标平台的原生指令。 Unity的Scripting Backend的Mono模式就是采用JIT编译。

  • AOT:(提前编译Ahead Of Time),程序运行前,将exe或dll文件中的CIL的byte code转译为目标平台的原生指令并存储。 Unity的Scripting Backend的IL2CPP模式就是采用AOT编译。

安装 🔗

在Unity编辑器中,可以在Package Manager中搜索Burst,然后点击安装即可。

设置 🔗

在Unity编辑器中,Edit > Player Settings > Burst AOT Settings。

实例 🔗

在C#代码中使用Burst Attribute来标记要使用Burst编译的方法或类。

[BurstCompile]
public int Add(int a, int b)
{
    return a + b;
}
[BurstCompile]
struct BurstCompileJob : IJob
{
    public void Execute()
    {

    }
}

JobSystem 🔗

概念 🔗

  • JobSystem:管理一组多核中的工作线程(Work Thread),为避免上下文切换通常一个逻辑配一个工作线程,JobSystem 持有一个 Job 队列,工作线程从该队列中获取 Job 执行,JobSystem 执行时复制而非引用数据,避免了数据竞争,但 JobSystem 只能使用memcpy复制 blittable数据。。

  • Job:一个job就是一个任务单位,一般在Worker Thread上执行(也有情况在Main Thread上执行),类似于可以在不同的thread上执行function一样,但是Job是一个struct。Job会接收参数并对数据进行操作,其行为方式类似于方法调用。Job可以是独立的,也可依赖其他Job完成之后才能运行。

  • SafetySystem:多线程编程中,为了避免出现竞争条件(不同线程同时访问一份数据),在给Job输送数据的时候,一律都是值类型,来避免不同thread使用值类型去修改同一份数据,减少出现竞争条件的可能性。

  • Native Container:NativeContainer是一种托管的值类型,为原生内存提供一种相对安全的C#封装。它包括一个指向非托管分配内存的指针。当和Unity C# Job System一起使用时,一个NativeContainer使得一个Job可以访问和主线程共享的数据,而不是在一份拷贝数据上工作。 Job内部只能访问blittable类型的数据和NativeContainer容器,并且不应访问静态数据。 Unity 自带 NativeContainer类型为 NativeArray,ECS 包又扩展了NativeList、NativeHashMap、NativeMultiHashMap和NativeQueue。

  • NativeContainer Allocator:每个Native Container都需要一个Allocator,Allocator代表NativeContainer的生命周期。 Allocator.Temp:分配速度最快,适用于在一帧内的主线程执行逻辑,不能将此类容器传递给Job使用。 Allocator.TempJob:分配速度稍慢,适用于生命周期最长四帧的逻辑,并具有线程安全性,大多数Job使用的容器是此类型的。 Allocator.Persistent:分配速度最慢,并且可以在整个游戏生命周期一直存在,适用于持续时间长的Job。 所有的NativeContainer容器作为托管类型都需要被手动释放,使用NativeContainer.Dispose()函数进行释放。

实例 🔗

  • IJob:简单的任务单位,返回一个JobHandle,可以实现依赖运行。
    public class RunJob
    {
        private NativeArray<int> _originalData;
        private NativeArray<int> _outputData;
        private JobHandle _jobHandle;
    
        public void Run()
        {
            _originalData = new NativeArray<int>(2, Allocator.TempJob);
            _originalData[0] = 1;
            _originalData[1] = 2;
    
            _outputData = new NativeArray<int>(1, Allocator.TempJob);
    
            BurstCompileJob job = new BurstCompileJob
            {
                OriginalData = _originalData,
                OutPutData = _outputData
            };
    
            _jobHandle = job.Schedule();
    
            // Ensure the job has completed	确保任务已经完成
            // It is not recommended to Complete a job immediately, 不建议立即完成任务
            // since that gives you no actual parallelism. 因为没有实际的并行性
            // You optimally want to schedule a job early in a frame and then wait for it later in the frame.
            // 你最好在一个帧的早期调度一个作业,然后在该帧的后期等待它。
            _jobHandle.Complete();
    
            Debug.Log(_outputData[0]);
        }
        public void Tick()
        {
            if (_jobHandle.IsCompleted)
            {
                _jobHandle.Complete();
    
                Debug.Log(_outputData[0]);
            }
        }
        //依赖执行
        public void DependenciesRun()
        {
            BurstCompileJob job1 = new BurstCompileJob();
            var jobHandle1 = job1.Schedule();
    
            BurstCompileJob job2 = new BurstCompileJob();
            var jobHandle2 = job2.Schedule(jobHandle1);
    
            jobHandle2.Complete();
        }
    }
    [BurstCompile]
    struct BurstCompileJob : IJob
    {
        [ReadOnly] public NativeArray<int> OriginalData;
        [WriteOnly] public NativeArray<int> OutPutData;
        public void Execute()
        {
            OutPutData[0] = OriginalData[0] + OriginalData[1];
        }
    }
    
  • IJobParallelFor:并行任务,当一个Job中同时存在大量重复计算的时候使用。
    private int _worldEdgeSize = 100;
    private NativeArray<Vector3> _outputs;
    private NativeArray<Vector3> _originals;
    private JobHandle _parallelJobHandle;
    
    public void Initial()
    {
        _outputs = new NativeArray<Vector3>(_worldEdgeSize * _worldEdgeSize * _worldEdgeSize, Allocator.TempJob);
        _originals = new NativeArray<Vector3>(_worldEdgeSize * _worldEdgeSize * _worldEdgeSize, Allocator.TempJob);
        var index = 0;
        for (int x = 0; x < _worldEdgeSize; x++)
        {
            for (int y = 0; y < _worldEdgeSize; y++)
            {
                for (int z = 0; z < _worldEdgeSize; z++)
                {
                    _originals[index] = new Vector3(x, y, z) * 5f - new Vector3(_worldEdgeSize * 5f * 0.5f, _worldEdgeSize * 5f * 0.5f, 0);
                    index++;
                }
            }
        }
    }
    //直接运行
    public void Run()
    {
        for (int i = 0; i < _originals.Length; i++)
        {
            var sinx = Mathf.Sin(1f * Time.time + Mathf.PerlinNoise(0.3f * _originals[i].x + Time.time, 0.3f * _originals[i].x + Time.time));
            var siny = Mathf.Cos(1f * Time.time + Mathf.PerlinNoise(0.3f * _originals[i].y + Time.time, 0.3f * _originals[i].y + Time.time));
            var sinz = Mathf.Sin(1f * Time.time + Mathf.PerlinNoise(0.3f * _originals[i].z + Time.time, 0.3f * _originals[i].z + Time.time));
        }
    }
    //job并行运行
    public void ParallelJobRun()
    {
        var paralleJob = new BurstCompileJobParallelFor
        {
            ElapsedTime = Time.time,
            OriginalData = _originals,
            OutPutData = _outputs
        };
        _parallelJobHandle = paralleJob.Schedule(_originals.Length, 1, _parallelJobHandle);
        _parallelJobHandle.Complete();
    }
    
    运行结果:上图为主线程直接运行,执行时间565ms;下图为并行到Job线程里执行,执行时间161ms。

ECS 🔗

概念 🔗

  • Entity:不代表任何意义的实体,仅包含一个 ID(代表包含哪些component,没有任何数据和行为)。
  • Component:只包含数据的组件。
  • System:处理数据的系统,负责处理 Entity 和Component 之间的交互。

原理 🔗

传统的面向对象设计对CPU从Cache读取数据,往往并不需要一个对象的全部数据,比如想操控一个GameObject的Position数据却要读取整个GameObject和其继承的MonoBehaviour的数据,大量的不需要的数据被写入CPU Catch,就会造成频繁的Catch Miss;此外托管类型的存储空间排列分散,寻址到所需的数据也需要一定时间,相比ECS在System处理数据时只会读取需要的数据。

Entities 🔗

Entitas是一个运行效率高的轻量级C# Entity-Component-System(ECS)框架,专门为unity订制。提供内部缓存和快速的组件访问。它经过精心设计,可以在垃圾收集环境中发挥最佳作用。 在Unity编辑器中,可以在Package Manager中搜索Entities,然后点击安装即可。 具体自己去实践。

实例 🔗

/// <summary>
/// 单独实体
/// 建立关联component的ID
/// </summary>
public class Entity
{
    public void AddComponent<T>() where T : IComponent
    {

    }
    public T GetComponent<T>() where T : IComponent
    {
        return default(T);
    }
}
public class IComponent
{

}
/// <summary>
/// UI组件
/// </summary>
public class UI : IComponent
{

}
/// <summary>
/// 三消组件
/// </summary>
public class Match : IComponent
{

}
/// <summary>
/// 城建组件
/// </summary>
public class Town : IComponent
{

}
/// <summary>
/// 三消项目system
/// </summary>
public class Match3System
{
    public void Work(List<IComponent> components)
    {

    }
}
public class MatchGame
{
    //entity创建只做演示
    private readonly Entity _uiEntity;
    private readonly Entity _matchEntity;
    private readonly Entity _townEntity;

    public MatchGame()
    {
        _uiEntity = new Entity();
        _uiEntity.AddComponent<UI>();
        _matchEntity = new Entity();
        _matchEntity.AddComponent<Match>();
        _townEntity = new Entity();
        _townEntity.AddComponent<Town>();
    }

    //只做演示
    public void Main()
    {
        //开发hs版本三消 ui+三消+城建
        List<IComponent> hsMatch = new List<IComponent>
        {
            _uiEntity.GetComponent<UI>(),
            _matchEntity.GetComponent<Match>(),
            _townEntity.GetComponent<Town>()
        };
        var matchSystem1 = new Match3System();
        matchSystem1.Work(hsMatch);

        //开发rm版本三消 ui+三消
        List<IComponent> rmMatch = new List<IComponent>
        {
            _uiEntity.GetComponent<UI>(),
            _matchEntity.GetComponent<Match>()
        };
        var matchSystem2 = new Match3System();
        matchSystem2.Work(rmMatch);
    }
}

结论 🔗

搬砖愉快!