首先在学习ECS之前,我们先来了解了解ECS所依赖的Job System,这样方便我们可以更轻松的读懂ECS的一些代码。
利用Job System,我们可以编写简单并且安全的多线程代码,来提高游戏性能。
官方文档:https://docs.unity3d.com/Manual/JobSystem.html
在Job System中我们会使用到一种新的类型NativeContainer。它是一种托管值类型,为本机内存提供了一个相对安全的 C# 封装器,它包含一个指向非托管分配的指针。
与 Job System一起使用时,NativeContainer允许Job访问与主线程共享的数据,而不是拷贝数据。如果是拷贝数据会导致同样的数据到不同的Job中,其结果是相互隔离的,因此我们需要将结果存储在共享内存中,也就是NativeContainer。
Unity 附带了一个名为NativeArray
ECS为其进行了拓展Unity.Collections命名空间以包含其他类型的 NativeContainer:
创建 NativeContainer时,必须指定所需的内存分配类型(Allocator),分配类型取决于Job运行的时间。设置不同的值以便在每种情况下获得最佳性能。
例如:
NativeArray result = new NativeArray(10, Allocator.TempJob);
注:使用NativeContainer需要我们手动Dispose,而不是等GC的时候自动释放
如何创建Job呢,其实很简单,只需要创建一个结构体并且实现IJob接口即可。例如我们要创建一个做加法运算的Job,代码如下:
public struct AdditionJob : IJob
{
[ReadOnly] public float a;
[ReadOnly] public float b;
[WriteOnly] public NativeArray result;
public void Execute()
{
result[0] = a + b;
}
}
可以看到,我们对加法运算后的结果值的类型使用了NativeArray。因为这个值是在Job内部修改的,若不使用前面提到的NativeArray,外部就无法正确的获取到Job修改后的该值。
同时若有些字段并不需要读写两种属性,我们可以为其配置 [ReadOnly] 或 [WriteOnly] 的标签来提升性能。
注意,在Job中我们能使用的类型只有NativeContainer和Blittable两种类型。
Blittable类型包括:System.Byte | System.SByte | System.Int16 | System.UInt16 | System.Int32 | System.UInt32 | System.Int64 | System.UInt64 | System.IntPtr | System.UIntPtr | System.Single | System.Double
System.Boolean | System.Char,bool和char经测试也可使用
创建完Job之后,自然是如何使用它了,看下面这段代码。
AdditionJob additionJob = new AdditionJob();
additionJob.a = 1;
additionJob.b = 10;
additionJob.result = new NativeArray(1, Allocator.TempJob);
additionJob.Schedule().Complete();
Debug.Log(additionJob.result[0]);//Log 11
additionJob.result.Dispose();
和别的Struct一样,我们要收实例化这个Job,然后对其内部字段进行赋值,接下来调用的两个方法则是重点了
Schedule方法:也称调度,会返回一个JobHandle对象,调用该方法则将该Job放入Job队列中,以便在适当的时间执行。一旦Job被调度,就不能中断该Job了。只能从主线程调用 Schedule,Job会在子线程中被执行。
Complete方法:则是主线程等待Job执行完成。
除了Schedule外,我们还可以使用IJob.Run()方法来执行Job,但是使用该方法Job将在主线程被执行,因此不建议使用。
最后记住要释放我们的NativeArray,这样我们就实现了执行一个最简单的Job了。
若我们有多个Job,其中有些Job需要在别的Job执行完得到结果后再执行。面对这种情况,我们应该如何处理?
举个例子,假设我们有一个新的Job,用来给自身的值+1,类似于++,代码如下
public struct AddOneJob : IJob
{
public NativeArray result;
public void Execute()
{
result[0] = result[0] + 1;
}
}
接着我们实例化我们的两个Job并赋值
NativeArray result = new NativeArray(1, Allocator.TempJob);
AdditionJob additionJob = new AdditionJob();
additionJob.a = 1;
additionJob.b = 10;
additionJob.result = result;
AddOneJob addOneJob = new AddOneJob();
addOneJob.result = result;
我们需要的效果是先执行完AdditionJob后再执行AddOneJob,此时我们就需要使用到Job的依赖了。
当我们调用Schedule方法时会返回一个JobHandle的对象,我们可以将其作为参数传递到下个Job的Schedule方法中,就可以形成依赖关系,代码如下:
JobHandle additionJobHandle = additionJob.Schedule();
addOneJob.Schedule(additionJobHandle).Complete();
Debug.Log(result[0]);//Log 12
result.Dispose();
这样AddOneJob就会在AdditionJob执行完成后再执行了。
若一个Job依赖于多个Job,我们可以利用JobHandle.CombineDependencies()方法来合并JobHandle,合并完成后再传递给Job的Schedule方法中即可
NativeArray handles = new NativeArray(numJobs, Allocator.TempJob);
handles[0] = job1;
handles[1] = job2;
handles[2] = job3;
......
JobHandle allJobHandle = JobHandle.CombineDependencies(handles);
前面的Job中,我们只能对单个对象进行处理,若我们想要对大量对象进行一样的处理,例如对两个List的数据进行相加(当然Job中无法使用List或者Array这些,我们需要用NativeContainer来代替)就可以通过实现 IJobFor 或者 IJobParallelFor 来处理。
首先介绍一下IJobFor ,代码如下:
public struct BatchAdditionJob : IJobFor
{
[ReadOnly] public NativeArray a;
[ReadOnly] public NativeArray b;
[WriteOnly] public NativeArray result;
public void Execute(int index)
{
result[index] = a[index] + b[index];
Debug.Log($"threadId:{System.Threading.Thread.CurrentThread.ManagedThreadId} index:{index}");
}
}
BatchAdditionJob batchAdditionJob = new BatchAdditionJob();
batchAdditionJob.a = new NativeArray(7, Allocator.TempJob);
batchAdditionJob.a[0] = 1;
batchAdditionJob.a[1] = 3;
batchAdditionJob.a[2] = 5;
batchAdditionJob.b = new NativeArray(7, Allocator.TempJob);
batchAdditionJob.b[1] = 5;
batchAdditionJob.b[2] = 4;
batchAdditionJob.b[3] = 3;
batchAdditionJob.result = new NativeArray(7, Allocator.TempJob);
batchAdditionJob.Schedule(batchAdditionJob.result.Length, new JobHandle()).Complete();
foreach (var result in batchAdditionJob.result)
{
Debug.Log(result);
}
batchAdditionJob.a.Dispose();
batchAdditionJob.b.Dispose();
batchAdditionJob.result.Dispose();
和IJob.Schedule不同的是,IJobFor.Schedule多了两个参数,第一个参数即要处理的数据长度,第二个参数为一个JobHandle。使用这种方法,Job会在一个子线程中执行,因此index是有序的。若我们想Job在多个子线程中并行执行可以使用下面方法:
batchAdditionJob.ScheduleParallel(batchAdditionJob.result.Length, 3, new JobHandle()).Complete();
使用ScheduleParallel方法,可以让我们的Job在多个子线程中同时运行,这种情况下我们的index就是无序的了,该方法中第二个参数的值表示每个子线程可以处理多少项。(例如我们Demo中一共有7项数据,若填1,则会开启7个子线程来处理,填3,则会有三个子线程来处理)
而IJobParallelFor就是完全使用多线程的Job接口了,其实 IJobParallelFor.Schedule 等同于 IJobFor.ScheduleParallel,代码修改很简单,这边就不在赘述了。