Jobsystem in Depth – Part II – Using the Jobsystem

Now, in this last article I explained you everything there is to know about the Job system. But for many of us – me included it is much easier to get a grip of things when I see an implementation, therefore I decided to make a little Mesh deformation Project to explain you everything you need to know on an example.

In this Project we are going to procedurally generate a plane and then use our mouse click input to spawn a Sphere, which will then make a nice indentation in our plane. This could be a base for some footsteps for example. (However you’d need to do that in batches as you’d quickly run out of vertices with increasing terrain size – so maybe keep that in the back of your head!).

I hope you’ll also realize that this project isn’t really meant for you to put inside your game – it’s literally just a starting point for doing some efficiant Meshdeformation using Unities Jobsystem.

 

DeformableMesh.cs:

Let’s start out by writing our code that’ll generate our plane. To do this we will create a new C# Script and call it DeformableMesh.
We are going to include the using statements Unity.Collections as we need NativeArrays and Unity.Jobs as our Job will have to inherit from IJobParalelFor.

Generally we are simply defining a few variables that help are going to help us define the size of our procedurally generated plane and the force and radius that is going to act upon it later when we come to the deformation part. We are also caching everything that is needed to render our mesh in 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;

  //MeshInformation
  Vector3[] vertices;
  Vector3[] modifiedVertices;
  int[] triangles;

  Vector2 verticeAmount;

  void Awake() 
  {
    meshRenderer = GetComponent<MeshRenderer>();
    meshFilter = GetComponent<MeshFilter>();
    meshFilter.mesh = new Mesh();
    mesh = meshFilter.mesh;

    GeneratePlane();	

  }

Let’s take a closer look as to how to procedurally generate a plane in code. I am trying to keep the explanations here rather simple, however I commented the code. (If you are interested in procedural mesh generation, let me know!)

/*Now a Mesh is build out of vertices and triangles there are basically build up out of three
of its vertices - we will start by working out the positions of our vertices.
Our vertices need an array of Vector3 as they have 3D positions in our world. The length of said array is dependend
on the size of our generated plane. It's easiest to imagine our plane with a grid overlay on top, each of our grids fields needs a
vertice at each of it's corners but adjacent fields can obviously share corners - knowing that we now know the we are going to need one more 
vertice than we have fields in each dimensions */
void GeneratePlane()
{
  vertices = new Vector3[((int)horizontalSize + 1) * 
  ((int)verticalSize + 1)];
  Vector2[] uv = new Vector2[vertices.Length];

  /*Let's position our vertices accordingly unsing a nested for loop*/
  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);
    }
  }

  /*Now that we have generated and position our vertices we should take a look at generating a proper mesh out of this.
  We'll start this process by setting our vertices as the mesh vertices */
  mesh.vertices = vertices;

  /*We also need to make sure that our vertices and modified verticies match up 
  at the very beginning */
  modifiedVertices = new Vector3[vertices.Length];
  for(int i = 0; i < vertices.Length; i++)
  {
    modifiedVertices[i] = vertices[i];
  }

  mesh.uv = uv;

  /*The mesh shouldn't show up yet as it doesnt have any triangles at that point. We'll generate these by looping over the points thaty build 
  up our triangles, their indiced will go into our array of int's called 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;
    }
  }

  /*Finally we need to assign our triangles as the mesh triangles and recalculate the normals to ensure the lighting will be correct.*/
  mesh.triangles = triangles;
  mesh.RecalculateNormals();
  mesh.RecalculateBounds();
  mesh.RecalculateTangents();

  /*We will also need a collider, this is necessary so we are able to use the physics syste to detect interactions */
  meshCollider = gameObject.AddComponent<MeshCollider>();
  meshCollider.sharedMesh = mesh;


  //We also need to set our MeshMaterial to avoid seeing an ugly magenta colored plane!
  meshRenderer.material = meshMaterial;
}

For collision detection I am using a rather different approach here as I am basically using a MouseInput script that will trigger a Coroutine to start that will create a primitive sphere on top of my plane which will then leave an indentation.
I could have very easily just used the mouse Input but I somehow like this weird bumpy way of doing it, that – full disclosue – was only in there for debugging.

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[i].point;
        currentContactpoint = transform.InverseTransformPoint(currentContactpoint);
        		contactPoints[i] = 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);

  }

 

Okay, let’s finally get to the juicy part a schedule a few jobs! I am going to do something a little bit different here for the sake of visualizing why it is important to know how to schedule the jobs, this first snippet is a way to schedule our jobs that works – you can copy this over into your project and it’ll run HOWEVER it is not as efficient as it could be and that is for a very simple reason, we might be using IJobParalelFor but we aren’t really executing our jobs parallel here because we are calling our complete right after the schedule which ultimately makes each executing wait for the next.

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();
  }

Let’s take a quick look at the profiler so you can actually see what I am talking about.

If you take a look my worker threads here you might be able to see all of the wait times in between – these happen because I am not scheduling my jobs accordingly. I hope this showed you very clearly why scheduling is so important. And let’s not waste anymore time and schedule our jobs neatly.

As you’ll see in the snippet below I am creating a class that will help me to hold my native array as well as the job handle. I am basically keeping track of every job I create and then Complete it from a loop within update. I know it does make the code a little uglier but it does make a big difference.

However,  another thing I want to mention again as you should be aware of it – we are defining  a few variables before we are schedule our job to be executed, in the following snippet you’ll see that I am not using a regular array of Vector3 anymore but rather a NativeArray<Vector3>. NativeArrays have been added with the Jobsystem namespaces to ensure secure handling of multi-threaded code. As mentioned before they mainly differ from regular arrays by the fact that you’ll have to define an allocator. This is basically your value for the NativeArrays persistence/allocation. They also aren’t going to be affected by the garbage collection and are therefore somewhat resemblent to native code, as you have to dispose/deallocate them manually.

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;
}

And ultimately the profiler will make it very clear why this is a better approach, by inspecting this you should be able to see that we are now really working much more efficiently.

IndentationJob.cs

Finally you’ll need to write the IndentationJob.cs, which is the struct that’ll ultimately execute our job. It being a job it also has to be inherited from one of the IJob interfaces, in our case this is IJobParallelFor which is ultimately what makes the most sense for Meshdeformation, because we want it to be run a set number of times per job. We are going to call the jobs execute function exactly the number of times that is the number of vertices of our mesh.

I’ve already mentioned the Execute() function, every job you are writing has to have it, as this is where you are going to add your custom code into a job.

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[i] - contactPoints[c]);
      float distance = pointToVert.sqrMagnitude;

      if(distance < radius)
      {
        Vector3 newVertice = initialVertices[i] + Vector3.down * (force);
        modifiedVertices[i] = newVertice;
      }
      
    }
  }
}

Within our Execute() function we are basically looping through our vertices and the given contactpoints (that we cached upon collision with our spheres) and then compare the radius, if it applies we are just adding a negative force value to our vertices which will cause the indentation you can see here. Random fact – if your force is negative you’ll see the vertices rise instead of sink.

And that is basically all there is to know.

I am not going over the MouseInput.cs in this article as its really just straight forwards, however you’ll find it and everything else on GitHub.

 

 

Liked it? Take a second to support Kristin on Patreon!

1 comment

Leave a Reply

Your email address will not be published. Required fields are marked *