[Unity] 【转载】深入解读Job System

查看:496 |回复:0 | 2019-11-20 18:06:38

您需要 登录 才可以下载或查看,没有账号?注册

x
通常而言,最好不要把Unity实体组件系统ECS和Job System看作互相独立的部分,要把它们看作用于大幅提升游戏性能的组合系统。

20191120-104328.jpg




本系列文章我们将深入了解使用二者开发项目的过程,从而使项目获得高性能。今天我们来了解ECS和Job System的基础知识,了解ECS请阅读:
《详解实体组件系统ECS》

什么是Job System

一些人认为Unity无法进行多线程处理,那个观点是错的,因为这是可以实现的,但是你可能无法使用任何Unity中特定的命名空间。你可以多线程处理不同类型的任务,只要任务不需要在主线程外访问Transform或游戏对象即可,所以在独立线程执行一些Vector3数学运算是没有问题的。

如果你非常了解Unity相关知识,或许你已经知道引擎的部分功能已经实现多线程处理。现在加入Job System后,Unity允许我们利用它的多线程处理功能。

Job System允许我们轻松编写多线程代码,从而实现高性能游戏体验。它不仅能改善帧率,而且在做移动开发时,它还能显著改善移动设备的电池寿命。

通过该功能,我们能够编写和Unity引擎功能共享工作线程的代码。

什么是多线程处理

通常在单线程程序中,每次只处理一个执行调用,一次只输出一个结果。

程序性能主要取决于加载和完成所用的时间。单线程会按线性顺序进行处理,需要的时间会比双线程同时处理更长,这种多个线程同时处理就是我们说的多线程处理。

多线程处理会利用CPU功能来同时在多个内核处理多个线程。

默认情况下,“主线程”会在程序开始时运行。主线程会创建新线程来处理任务。这些新线程会并行运行,通常在完成后将结果与主线程同步。

多线程处理方法适合用来处理多个需要长时间运行的任务。然而,游戏开发代码通常带有很多需要同时执行的小指令。如果为每个小指令都创建一个线程,结果会得到很多线程,每个线程的生命周期都很短。从而导致CPU和操作系统处理能力达到极限。

你可以通过线程池来解决线程生命周期的问题,然而即使使用线程池,还是会同时有很多活动线程。如果线程数量比CPU内核数量多,会造成线程互相竞争CPU资源,并且频繁切换上下文(Context switching)。

上下文切换是指切换线程时,会保存当前进程的执行状态,然后处理另一线程,在重构第一个线程后,继续处理该线程。上下文切换是个资源密集型过程,所以要尽量避免该过程。

Job System和传统多线程的区别

在多线程处理时,要打开线程然后提供任务。你需要注意将辅助线程合并到主线程的时间,还要正确关闭线程。所以多线程处理需要你管理很多操作。

Job System使用不同的方法,因为我们不会创建任何线程,而是会使用Unity在多个内核上的工作线程,给它们提供任务-Unity称之为Jobs作业。很容易看出,这种方法更为简单,因为避免了管理线程时可能遇到的问题。不仅如此,我们还不必担心出现竞态条件。

通过内置的安全检查,Job System可以检测所有潜在的竞态条件。通过给每个作业发送需要处理的数据副本而不是在主线程引用数据,Job System可以避免发生竞态条件,进而消除竞态条件,因为现在处理的是独立数据而不是它的引用。

因此,作业只能访问blittable数据类型。当在托管代码和本地代码之间传递数据时,该类型数据不需要转换。

Unity使用C++方法复制的内存块在Unity的托管部分和本地部分复制和传递数据。在调度作业时,我们会将数据放入本地内存,并在执行作业的同时允许托管部分访问数据副本。

你甚至不必担心发生上下文切换和CPU争用,因为Unity通常在每个CPU内核有一个工作线程,作业会在这些线程间同步调度。

Job System中,所有作业都会放入队列中。空闲工作线程会获取作业,并按照队列的顺序执行。为了确保作业按照所需顺序执行,我们可以利用作业依赖。

Job是什么

总的来说,每个作业(Job)都可以看作是方法调用,每个作业在创建时会得到数据和参数,之后用于执行过程。作业可以是独立的,这意味着当它们什么时候完成对我们来说并不重要。或者在更合理情况下,它们可以拥有依赖。依赖能为我们带来便利,因为它能让代码在正确的时间执行。

对多线程处理来说,这非常重要,你需要确保执行过程能避免发生竞态条件,这意味着一项任务不必等待其它任务完成才执行,那样会造成延迟。

所以基本上,依赖意味着我们的第二个任务依赖于第一个任务,第二个任务会在第一个任务完成后才开始执行。

句法

每个作业都需要实现以下三个类型的其中一个类型:IJob、IJobParallelFor或 IJobParallelForTransform。

IJobParallelFor用于需要多次并行执行单个任务的作业。JobParallelForTransform和IJobParallelFor差不多,尤其是用于处理Unity Transform时。

这些类型实际上都是接口,因此只要脚本中没有Execute函数,编译器就会出问题。还要记住,作业必须是nullable类型,这意味着它必须是struct,并且在任何情况下都不能是类,这是因为内存分配问题。

Unity创建新容器是为了让我们能够很容易就写出线程安全的代码。

using Unity.Collections;


using Unity.Jobs;


/*作业(Job)需要是可空类型,这意味着它们必须为struct结构…

每个作业都必须继承自IJobParallelFor、IJobParallelForTransform或IJob*/

Every job has to inherit from either IJobParallelFor, IJobParallelForTransform or IJob */

public struct MyJob : IJobParallelFor {

/*在作业中,需要定义所有用于执行作业和输出结果的数据

Unity会创建内置数组,它们大体上和普通数组差不多,但是需要自己处理分配和释放设置*/

public NativeArray<Vector3> waypoints;

public float offsetToAdd;

/*所有作业都需要Execute函数*/

public void Execute(int i)

{

  /*该函数会保存行为。要执行的变量必须在该struct开头定义。*/

   waypoints = waypoints * offsetToAdd;

}

}

调度作业

现在已创建MyJob.cs struct,要如何使它工作呢?我们必须调度它。

通常该过程非常简单,但需要注意,每个作业都需要被调度。那意味着我们首先发起作业,添加数据,然后发送到队列中等待执行。一旦该过程发生,我们就无法中断该过程。

Unity提供的常见句法参考中的作业代码如下:

// 创建单个浮点数的本地数组(NativeArray)来存储结果。为了更好说明功能,该示例会等待作业完成。

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

// 设置作业数据

MyJob jobData = new MyJob();

jobData.a = 10;

jobData.b = 10;

jobData.result = result;

// 调度作业

JobHandle handle = jobData.Schedule();

// 等待作业完成

handle.Complete();

//NativeArray的所有副本都指向相同内存,你可以在NativeArray的副本中访问结果。

float aPlusB = result[0];

// 释放结果数组分配的内存

result.Dispose();

这些正确的代码,它可以正常执行,但带有一些缺点,因为在调度完成后进行完成调用会产生短暂的等待时间,在性能分析器中,该时间称为“Idle Time”。

相反如果你习惯调度作业,性能分析器中显示的等待时间将最小化,而且会得到不错的性能,至少在旧机器上效果会很明显。

高效调度作业

在调度作业后,因为工作线程没有时间完成任何任务。这造成在调度调用期间会产生空闲时间,会对性能产生影响。

本示例中,我们会创建struct,保存对句柄和本地数组的引用。为什么保存这些内容?

保存句柄是为了在之后调用作业,保存本地数组是因为需要释放本地数组,NativeArray和常规数组的工作方式差不多,但是需要设置Allocator,用来定义数组在内存中的保留时间,本示例中使用Allocator.TempJob。

我们还需要在调用完成时释放内存,然后复制数据。我们创建了JobResultAndHandle的引用,然后对它调用ScheduleJob()。这会使我们的作业开始调度,而且它的引用会保存在列表中。

然后我们可以查看列表中的每个条目,调用完成,复制执行数据,然后弃用NativeArray来释放内存。

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

using Unity.Collections;

using Unity.Jobs;

public class MyJobScheduler : MonoBehaviour

{

Vector3[] waypoints;

float offsetForWaypoints;

  //我们将保存结果和句柄的列表

List<JobResultAndHandle> resultsAndHandles = new List<JobResultAndHandle>();

void Update()

{

/*我们会在需要时创建新的JobResultANdHandle(该代码不必在Update方法中,因为它只是个示例)

然后我们会给ScheduleJob方法提供引用。*/

   JobResultAndHandle newResultAndHandle = new JobResultAndHandle();

   ScheduleJob(ref newResultAndHandle);

   /*如果ResultAndHAndles的列表非空,我们会在该列表进行循环,了解是否有需要调用的作业。*/

   if(resultsAndHandles.Count > 0)

   {

     for(int i = 0; i < resultsAndHandles.Count; i++){

       CompleteJob(resultsAndHandles);

     }

   }

}

  /* ScheduleJob会获取JobResultAndHandle的引用,初始化并调度作业。

void ScheduleJob(ref JobResultAndHandle resultAndHandle)

{

    //我们会填充内置数组,设置合适的分配器

   resultAndHandle.waypoints = new NativeArray<Vector3>(waypoints, Allocator.TempJob);

   //我们会初始化作业,提供需要的数据

   MyJob newJob = new MyJob

   {

     waypoints = resultAndHandle.waypoints,

     offsetToAdd = offsetForWaypoints,

   };

  //设置作业句柄并调度作业

   resultAndHandle.handle = newJob.Schedule();

   resultsAndHandles.Add(resultAndHandle);

}

  //完成后,我们会复制作业中处理的数据,然后弃用弃用内置数组

  //这一步很有必要,因为我们需要释放内存

void CompleteJob(JobResultAndHandle resultAndHandle)

{

   resultsAndHandles.Remove(resultAndHandle);

   resultAndHandle.handle.Complete();

   resultAndHandle.waypoints.CopyTo(waypoints);

   resultAndHandle.waypoints.Dispose();

}

}

struct JobResultAndHandle

{

public NativeArray<Vector3> waypoints;

public JobHandle handle;

}

JobHandles和依赖

对作业调用Schedule()会使它返回JobHandle。JobHandle对保留作业的引用非常有用,但也可以将它们用作其它作业的依赖。这是什么意思呢?

如果某个作业依赖其它作业的结果,我们可以将其它作业的句柄作为参数传递到myjobs调度方法中,这样能让该作业完成后执行我们的作业。

前文中提到的竞态条件问题、线程等待线程的问题,以及使用多线程代码的缺点问题都可以通过传递句柄来轻松避免。





我们讲解了Job System的基础知识,本文将以网格变形项目为示例,讲解Job System的使用。





该项目中,我们将程序化生成一个平面,然后使用鼠标点击输入来生成球体,然后球体会在平面上产生凹槽,该功能可以用于实现脚印的效果。此项目只是使用Unity的Job System来实现高效网格变形的一个开端。



访问代码

本文代码你可以在GitHub上查看:

https://github.com/itsKristin/Jobified-Meshdeformation

DeformableMesh.cs

首先编写生成平面的代码。创建一个C#脚本,命名为DeformableMesh。我们将加入using Unity.Collections声明,因为我们需要使用NativeArrays和Unity.Jobs,而且作业要继承自IJobParalelFor。

我们要定义几个变量,用来帮助定义程序化生成平面的大小、作用力和半径,在变形部分时会用到这些变量,我们还在Awake函数缓存了所有需要用于渲染网格的信息。

using System.Collections;


using System.Collections.Generic;


using UnityEngine;

using Unity.Collections;

using Unity.Jobs;

[RequireComponent(typeof(MeshFilter),(typeof(MeshRenderer)))]

public class DeformableMesh : MonoBehaviour

{

[Header("Size Settings:")]

[SerializeField] float verticalSize;

[SerializeField] float horizontalSize;

[Header("Material:")]

[SerializeField] Material meshMaterial;

[Header("Indentation Settings:")]

[SerializeField] float force;

[SerializeField] float radius;

Mesh mesh;

MeshFilter meshFilter;

MeshRenderer meshRenderer;

MeshCollider meshCollider;

//网格信息

Vector3[] vertices;

Vector3[] modifiedVertices;

int[] triangles;

Vector2 verticeAmount;

void Awake()

{

   meshRenderer = GetComponent<MeshRenderer>();

   meshFilter = GetComponent<MeshFilter>();

   meshFilter.mesh = new Mesh();

   mesh = meshFilter.mesh;

   GeneratePlane();

}

仔细观察代码以及注释内容,了解如何通过代码程序化生成平面。

/*网格是由顶点和三角形构建的,基本上由其中的三个顶点构建。我们首先处理顶点的位置。

顶点需要Vector3数组,因为它们在世界空间中拥有3D位置。数组的长度取决于所生成平面的大小。

简单来说,可以想象平面顶部有网格覆盖,每个网格区域的每个角都需要一个顶点,相邻区域可以共享同一个角。因此,在每个维度中,顶点的数量需要比区域的数量多1。*/

void GeneratePlane()

{

vertices = new Vector3[((int)horizontalSize + 1) *

((int)verticalSize + 1)];

Vector2[] uv = new Vector2[vertices.Length];

  /*现在使用嵌套的for循环相应地定位顶点*/

for(int z = 0, y = 0; y <= (int)verticalSize; y++)

{

   for(int x = 0; x <= (int)horizontalSize; x++, z++)

   {

     vertices[z] = new Vector3(x,0,y);

     uv[z] = new Vector2(x/(int)horizontalSize,

     y/(int)verticalSize);

   }

}

  /*我们已经生成并定位了顶点,应该开始生成合适的网格。

  首先设置这些顶点为网格顶点*/

mesh.vertices = vertices;

  /*我们还需要确保我们的顶点和修改的顶点在一开始就相互匹配*/

modifiedVertices = new Vector3[vertices.Length];

for(int i = 0; i < vertices.Length; i++)

{

   modifiedVertices = vertices;

}

mesh.uv = uv;

  /*网格此时还不会出现,因为它没有任何三角形。我们会通过循环构成三角形的点来生成三角形,这些三角形的标签会进入int类型的triangles数组中*/

triangles = new int[(int)horizontalSize *

(int)verticalSize * 6];

for(int t = 0, v = 0, y = 0; y < (int)verticalSize; y++, v++)

{

   for(int x = 0; x <(int)horizontalSize; x++, t+= 6, v++)

   {

     triangles[t] = v;

     triangles[t + 3] = triangles[t + 2] = v + 1;

     triangles[t + 4] = triangles[t + 1] = v + (int)horizontalSize + 1;

     triangles[t + 5] = v + (int)horizontalSize + 2;

   }

}

  /*最后,我们需要将三角形指定为网格三角形,然后重新计算法线,确保得到正确的光照效果*/

mesh.triangles = triangles;

mesh.RecalculateNormals();

mesh.RecalculateBounds();

mesh.RecalculateTangents();

  /*我们还需要碰撞体,从而能够使用物理系统检测交互*/

meshCollider = gameObject.AddComponent<MeshCollider>();

meshCollider.sharedMesh = mesh;

  //我们需要设置网格材质,以避免出现难看的红色平面

meshRenderer.material = meshMaterial;

}

我们使用了不同的方法进行碰撞检测,MouseInput脚本会触发一个协程,该协程会在平面上创建圆形球体并留下凹槽。

void OnCollisionEnter(Collision other) {

   if(other.contacts.Length > 0)

   {

    Vector3[] contactPoints = new Vector3[other.contacts.Length];

     for(int i = 0; i < other.contacts.Length; i++)

     {

       Vector3 currentContactpoint = other.contacts.point;

       currentContactpoint = transform.InverseTransformPoint(currentContactpoint);

       contactPoints = currentContactpoint;

     }

     IndentSnow(force,contactPoints);

   }

}

public void AddForce(Vector3 inputPoint)

{

   StartCoroutine(MarkHitpointDebug(inputPoint));

}

IEnumerator MarkHitpointDebug(Vector3 point)

{

   GameObject marker = GameObject.CreatePrimitive(PrimitiveType.Sphere);

   marker.AddComponent<SphereCollider>();

   marker.AddComponent<Rigidbody>();

   marker.transform.position = point;

   yield return new WaitForSeconds(0.5f);

   Destroy(marker);

}

现在来到了重点部分,调度作业。我们将在这部分可视化说明了解调度作业的方法的重要性。

第一个代码段是个调度作业的方法,可以复制该代码段到自己的项目中,然而它执行的效果不如预期的高效。原因很简单,我们可能会使用到IJobParalelFor,但并没有让作业并行执行,因为我们会在调度后马上调用Complete, 这样就会导致执行还是需要一个一个的来。

public void IndentSnow(float force, Vector3[] worldPositions)

{

   NativeArray<Vector3> contactpoints = new NativeArray<Vector3>

   (worldPositions, Allocator.TempJob);

   NativeArray<Vector3> initialVerts = new NativeArray<Vector3>

(vertices, Allocator.TempJob);

NativeArray<Vector3> modifiedVerts = new NativeArray<Vector3>

(modifiedVertices, Allocator.TempJob);



IndentationJob meshIndentationJob = new IndentationJob

{

      contactPoints = contactpoints,

      initialVertices = initialVerts,

      modifiedVertices = modifiedVerts,

      force = force,

      radius = radius

};

JobHandle indentationJobhandle = meshIndentationJob.Schedule(initialVerts.Length,initialVerts.Length);

indentationJobhandle.Complete();



   contactpoints.Dispose();

   initialVerts.Dispose();

   modifiedVerts.CopyTo(modifiedVertices);

   modifiedVerts.Dispose();

   mesh.vertices = modifiedVertices;

   vertices = mesh.vertices;

   mesh.RecalculateNormals();

}

现在查看下图性能分析器。



仔细注意到上图中的工作线程,你会看到所有线程中的等待时间,这是因为我们没有相应地调度作业。希望上图能清楚告诉你调度的重要性。

下面我们来进行正确的调度作业。

后面的代码段中,我们会创建一个类,它将帮助我保存本地数组和作业句柄。我会跟踪已创建的每个作业,然后在Update中从循环代码完成它。

在调度要执行的作业前,我们定义了一些变量,下面的代码段中我们没有使用Vector3的常规数组,而是使用了NativeArray<Vector3>。NativeArrays中添加了Job System命名空间,从而确保能够安全地处理多线程代码。

如前文所说,这些数组和常规数组不同,因为你必须定义一个分配器。这基本上是NativeArrays持续性和分配过程的数值。这些数组还不会受到垃圾收集过程的影响,因此它们和本地代码相似,所以你需要手动除去或释放这些数组。

void IndentSnow(float force, Vector3[] worldPositions,ref HandledResult newHandledResult)

{

   newHandledResult.contactpoints = new NativeArray<Vector3>

   (worldPositions, Allocator.TempJob);

   newHandledResult.initialVerts = new NativeArray<Vector3>

(vertices, Allocator.TempJob);

   newHandledResult.modifiedVerts = new NativeArray<Vector3>

(modifiedVertices, Allocator.TempJob);



IndentationJob meshIndentationJob = new IndentationJob

{

      contactPoints = newHandledResult.contactpoints,

      initialVertices = newHandledResult.initialVerts,

      modifiedVertices = newHandledResult.modifiedVerts,

      force = force,

      radius = radius

};

JobHandle indentationJobhandle = meshIndentationJob.Schedule(newHandledResult.initialVerts.Length,newHandledResult.initialVerts.Length);



   newHandledResult.jobHandle = indentationJobhandle;

   scheduledJobs.Add(newHandledResult);

}

void CompleteJob(HandledResult handle)

{

   scheduledJobs.Remove(handle);

   handle.jobHandle.Complete();



   handle.contactpoints.Dispose();

   handle.initialVerts.Dispose();

   handle.modifiedVerts.CopyTo(modifiedVertices);

   handle.modifiedVerts.Dispose();

   mesh.vertices = modifiedVertices;

   vertices = mesh.vertices;

   mesh.RecalculateNormals();

     

}

}

struct HandledResult

{

public JobHandle jobHandle;

public NativeArray<Vector3> contactpoints;

public NativeArray<Vector3> initialVerts;

public NativeArray<Vector3> modifiedVerts;

}

最后,性能分析器会告诉新代码的效率明显高了很多。



IndentationJob.cs

最后需要编写IndentationJob.cs,该代码是执行作业的struct。作为作业,它也继承自IJob接口,本示例中是IJobParallelFor,它最后会对网格变形产生影响,因为想要让它在每个作业多次运行,我们将调用作业的执行函数,调用次数等于网格顶点的数量。

你编写的每个作业都必须拥有Execute()函数,因为你需要通过该函数添加自定义代码到作业中。

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

using Unity.Collections;

using Unity.Jobs;

public struct IndentationJob : IJobParallelFor {

public NativeArray<Vector3> contactPoints;

public NativeArray<Vector3> initialVertices;

public NativeArray<Vector3> modifiedVertices;

public float force;

public float radius;

public void Execute(int i)

{

   for(int c = 0; c < contactPoints.Length; c++)

   {

     Vector3 pointToVert = (modifiedVertices - contactPoints);

     float distance = pointToVert.sqrMagnitude;

     if(distance < radius)

     {

       Vector3 newVertice = initialVertices + Vector3.down * (force);

       modifiedVertices = newVertice;

     }

   }

}

}



在Execute()函数中,我们在顶点和特定在碰撞球体时缓存contactPoints变量中循环,然后比较半径大小,如果符合条件,我们会给顶点添加负作用力值,从而造成下图中的凹槽。顺便一提,如果作用力为负,顶点会上升而不是下沉。



小结

本文将以网格变形项目为示例,讲解Job System的使用就介绍到这里,希望大家学以致用,熟练掌握Job System。




评分

参与人数 2元素币 +10 活跃度 +22 展开 理由
源支始 + 10 + 12
KL呆呆L + 10 【诚意】很有诚意的内容,获取额外奖励

查看全部评分

2019-11-20 18:06:38  
 赞 赞 0

使用道具 登录

0个回答,把该问题分享到群,邀请大神一起回答。

CG 游戏行业专业问题

手机游戏引擎手游引擎Unity3D技术
您需要登录后才可以回帖 登录 | 注册

本版积分规则

快速回复 返回顶部 返回列表