Unity官方Dots范例工程学习——Jobs101
大家好,我是阿赵。
接下来继续学习Unity的DOTS。
首先来学习的是Jobs101项目,之前在Git上面检出的多个项目里面的其中一个:
使用Unity6打开该项目,会看到这里主要有4个Step的场景:
通过项目命名可以直观的知道,这个Jobs101的范例项目,是打算通过4个步骤,一步步的向我们展示Job System的使用方法。
一、 Step1
打开Step 1文件夹里面的Step1_NoJobs场景,并且运行:
可以看到
场景里面有很多红蓝色的方块,然后帧率非常低,非常的卡顿。
1、例子说明
接下来需要对这个例子进行一定的说明,好让大家知道,Unity想通过这个例子向我们说明什么。因为接下来的步骤里面其实都是同一个例子,只是处理的方式不一样,性能也不一样而已。
在这个例子里面,有2种对象:
1. Seeker
Seeker是探索者的意思,它在demo里面是蓝色的方块:
2. Target
Target是Seeker需要寻找的目标,在demo里面是红色方块
然后Seeker和Target上面挂载的Seeker和Target脚本,其实做的事情都是一样的,就是让自身沿着一个方向移动。
public class Seeker : MonoBehaviour{public Vector3 Direction;public void Update(){transform.localPosition += Direction * Time.deltaTime;}}
所以这两个脚本可以不用在意。
这个Demo要做的事情是,先根据设定的数量,生成大量的Seeker探索者和Target目标,然后这些大量的Seeker和Target都是在按照一定的方向和速度在移动的。而Seeker既然成为探索者,那么它们是需要在不断的对自己附近进行探索,找到离自己最近的一个Target的。
切换到Scene视图,就可以看到:
每一个蓝色的Seeker,都会通过一条线,找到离自己最近的一个Target。
然后在场景里面,我们可以找到一个Spawner的对象:
它上面挂载的Spawner脚本,里面指定了Seeker和Target分别使用的预设对象,还有Seeker和Target分别生成的数量,比如截图里面就是各1000个。最后是生成的范围Bounds是500x500的范围内生成。
接下来的几个Step其实都是这个例子,都是Seeker寻找离自己最近的Target。
2、 Step1例子说明
回头看看Step1这个例子,由场景的命名NoJobs,我们可以直观的理解到,这个例子,其实是没有使用JobSystem的。
那么使用传统的方法,如果我们需要找到每个Seeker最近的Target,一般有什么办法呢?很明显就是直接遍历然后对比距离的大小了。
所以在每个Seeker对象上面, 都挂载了一个FindNearest的脚本。
这个脚本的作用很简单:
namespace Tutorials.Jobs.Step1
{public class FindNearest : MonoBehaviour{public void Update(){// Find nearest Target.// When comparing distances, it's cheaper to compare// the squares of the distances because doing so// avoids computing square roots.Vector3 nearestTargetPosition = default;float nearestDistSq = float.MaxValue;foreach (var targetTransform in Spawner.TargetTransforms){Vector3 offset = targetTransform.localPosition - transform.localPosition;float distSq = offset.sqrMagnitude;if (distSq < nearestDistSq){nearestDistSq = distSq;nearestTargetPosition = targetTransform.localPosition;}}Debug.DrawLine(transform.localPosition, nearestTargetPosition);}}
}
就是先从Spawner里面拿到了所有的Target对象,然后逐个对比和自己的距离,找到最近的一个,然后画线。
这一步没什么问题,思路很正常。但由于对象的数量多,假设Seeker有m个,target有n个,那么它的计算次数就是m乘n了。所以,在场景里面各1000个对象的情况下,计算数量是1000x1000等于一百万次的距离计算和比较,就非常的卡顿了。
二、 Step2
接下来打开Step 2文件夹里面的Step2_SingleThreadedJob场景:
从Spawner可以看到,同样是分别创建1000个Seeker和Target,我们运行看看有什么区别:
最大的区别在于,帧率飙升了,从刚才step1只有个位数的fps,达到了100fps以上。
是什么操作导致了这么大的提升呢?
先来看看Seeker使用的预设
会发现之前的FindNearest脚本没有了,只剩下Seeker脚本。
而FindNearest的脚本变成在Spawner
我们从场景的命名上看,SingleThreadedJob,我们大致可以猜到,这个Step2实际上是想告诉我们怎样用一个线程来运行Job System的。
所以我们可以打开Step2里面的FindNearest脚本看看:
主要看这个部分,在设置了所有的Seeker和Target的Position之后,脚本创建了一个FindNearestJob,然后通过FindNearestJob又拿到了一个JobHandle。最后这个JobHandle调用了Complete方法。
这个脚本的注释里面已经说明了它所做的事情
1. 创建Job的实例并填充数据
//To schedule a job, we first need to create an instance and populate
its fields.
意思是,如果想计划一个job,我们首先需要创建一个实例并且填充它的字段。
所以
FindNearestJob findJob = new FindNearestJob{TargetPositions = TargetPositions,SeekerPositions = SeekerPositions,NearestTargetPositions = NearestTargetPositions,};
2. 把Job实例放进job队列
//Schedule() puts the job instance on the job queue.
所以
JobHandle findHandle = findJob.Schedule();
意思是通过Schedule函数的调用,把job的实例放到job的队列里面。
3. 等待Job完成
// The Complete method will not return until the job represented by// the handle finishes execution. Effectively, the main thread waits// here until the job is done.
意思是,这个Complete函数会一直等待job的处理完成才会返回,在这个过程中,主线程是会一直在这里等待job完成。
所以当调用:
findHandle.Complete();
这里其实主线程是会等待job执行的。
这个过程我们应该能理解了,它是在执行FindNearestJob。
而打开这个Step2文件夹里面的FindNearestJob脚本看看
这里实际上也是通过了两层循环去遍历所有的Seeker和Target,来寻找每个Seeker最近的Target。
但这里有些不一样,需要注意的地方:
1. 新数学库的使用
之前挂载在Seeker上的FindNearest脚本,是使用了Vector3来代表坐标,然后使用Vector3。sqrMagnitude来计算向量平方长度的
这个例子里面,使用了Unity.Mathematics.float3来代替了Vector3,然后使用了Unity.Mathematics.math.distancesq来代替 Vector3.sqrMagnitude.
使用新的Unity.Mathematics.math数学库,是因为旧的数学库诸如Vector3、Quaternion等类型的内存布局非最优,GC压力大。而新的数学库在解决了这些问题之余,还可以利用Burst编译器优化,提升计算的性能。
为了这个目的,所以在生成Job实例之前,需要先通过遍历,把所有的Seeker和Target的坐标,转换成NativeArray数组
NativeArray<float3> TargetPositions;
NativeArray<float3> SeekerPositions;
需要注意的是,Vector3是可以隐式转换成float3的,所以可以:
TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
2. 集中遍历
在之前的例子里面,都是单个Seeker对象在Update生命周期里面进行对Target列表的遍历。而这个例子里面,所有的遍历都集中在一起,通过一个线程去统一计算。
做了这么简单的修改之后,可以看到性能就有了突飞猛进的提高了。这让人更其他后面的两个步骤了。
三、 Step3
接下来打开Step3文件夹下的Step3_ParallelJob场景
运行看看:
吓了一跳,怎么step2的帧率都有100fps,step3才40多?不要急,看看生成的数量:
之前step1和step2都是Seeker和Target各生成1000个,这里变成了各5000个,从遍历的数量级来说,是整整25倍了,从100万次变成了2500万次了。如果改回各生成1000个,fps是有140多的。
所以很明显,这次的Step3又在Step2的基础上有了较大的进步。从场景的名称看,ParallelJob,是平行工作的意思,因为Step2是一个线程执行Job,那么我们可以理解成Step3其实是多个线程同时执行Job了。
看看Step2和Step3的主要区别:
1、 Schedule方法的调用
在Step3的FindNearest脚本里面,可以看到:
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100);
这里的说明是:
// Execute will be called once for every element of the SeekerPositions array,// with every index from 0 up to (but not including) the length of the array.// The Execute calls will be split into batches of 100.
意思是,Execute方法将会根据SeekerPositions数组长度的每个元素都调用一次,每次调用的index是从0一直到SeekerPositions 数组的长度(不包括数组长度本身)。
然后这些调用会分成每个批次调用100个。
怎么理解呢?
第一个参数很好理解,这个批处理里面我们总共需要处理多少个数据。而第二个参数,其实是指每个线程单次处理的时候,处理多少个数据,比如上面设置了100,那么第一个线程处理的将会是0-99号数据,第二个线程处理的是100-199号数据。
那么问题来了,如果这些数据之间是需要互相访问的,比如我在处理0-99号数据的时候,是需要拿到第110号的数据,怎么办呢?由于线程之间的是同时运行的,如果数据在线程中发生变化,其他线程是不知道的。如果真的需要把所有数据都在过程中修改并且可以互相调用,那么只能把调用批次设置成和数组总长度一样了。
2、 Execute方法的参数
由于Schedule方法设置了总数量和批次,所以Job的Execute方法也会带有Index参数
之前的Step2的Job,里面的Execute方法是没有Index参数的,意味着它每次进程会只调用一次。而Step3的Job,里面的Execute方法是带Index的,意味着根据数组的长度每一个元素会调用一次。
由于这样并行运行了,所以执行的效率就会比Step2更快了。
如果我们改成这样:
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, SeekerPositions.Length);
那么其实每次都是一个批次处理完所有元素,那么实际的执行效率将会和Step2一样了。
四、 Step4
打开Step4文件夹的Step4_ParallelJob_Sorting场景:
运行之后
看到帧率只有不到30,不过还是不用急,看看生成的数量。这次生成的是Seeker和Target各一万个,所以实际的遍历数量是1亿次了。
如果是这个数量级,在Step3里面运行:
帧率就降到只有10几了。
从场景命名看,ParallelJob_Sorting,比Step3多了一个Sorting,意味着多了一个排序,那么具体是做了什么呢?
主要的区别是:
SortJob<float3, AxisXComparer> sortJob = TargetPositions.SortJob(new AxisXComparer { });FindNearestJob findJob = new FindNearestJob
{TargetPositions = TargetPositions,SeekerPositions = SeekerPositions,NearestTargetPositions = NearestTargetPositions,
};JobHandle sortHandle = sortJob.Schedule();
JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100, sortHandle);
在这里创建了一个sortJob,这个是一个排序的Job,具体的方法是AxisXComparer :
public struct AxisXComparer : IComparer<float3>{public int Compare(float3 a, float3 b){return a.x.CompareTo(b.x);}}
这个排序是对TargetPositions里面的所有元素的x轴进行从小到大的排序的。
然后在FindNearestJob里面,再次利用AxisXComparer 方法,找到离当前Index的Seeker的x方向距离最短的Target,作为一个备选答案,求出当前Seeker和这个Target的实际距离的平方,然后再从这个Target的序号分别往前和往后遍历,每个Target和当前的备选Target做对比,看有没有比备选答案距离更短的。
我个人觉得,这只是一个优化方式的举例,各位不用太较真这个排序算法本身。这里是做了一个小算法,由于已经知道了目标和当前Seeker和备选Target的实际距离的平方,而实际距离的平方是x偏移x偏移+y偏移y偏移,所以只要计算下一个Target的x偏移*x偏移大于当前的距离平方,就可以不用考虑这个Target了:
而由于之前已经通过排序方法对TargetPositions进行排序了,所以应该来说从x最近的Target开始,从往左或者往右的其中一边,应该有连续的一大段都不需要参加计算。
不要在意这个算法本身,需要留意的是,我们可以在执行Schedule函数的时候,指定多一个handle,用于多线程处理。不过由于这个例子本身的计算还是比较简单,所以感受不出很大的变化。
五、 总结
看完了4个例子,已经基本掌握了Unity的Job System的使用了,这个官方例子告诉了我们这些内容:
1、 Job System是可以单独使用的
在这几个例子里面,并没有使用到ECS,只是普通的常用代码方式编写。所以并不是说它是DOTS中的一员,就一定要和ECS一起使用的,也可以单独使用。
2、需要使用Job System,需要的步骤是:
1.创建Job实例
通过new方法创建出想要的Job实例,并且填充里面的字段数据。创建出来的实例并不会立刻执行
2.调用计划
改过Job实例调用Schedule方法,可以传入总长度和批次,也可以不传。
3、等待完成
Schedule返回的handle调用Complete方法,等待Job完成