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中同时存在大量重复计算的时候使用。
运行结果:上图为主线程直接运行,执行时间565ms;下图为并行到Job线程里执行,执行时间161ms。
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(); }
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);
}
}
结论 🔗
搬砖愉快!