Unity Job System and Burst Compiler: Getting Started

In this tutorial, you’ll learn how to use Unity’s Job System and Burst compiler to create efficient code to simulate water filled with swimming fish. By Ajay Venkat.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Understanding the Native Container

NativeContainer includes the following subtypes, which are mostly modeled from types found within the System.Collections.Generic namespace:

  • NativeList: A resizable NativeArray.
  • NativeHashMap: Contains key-value pairs.
  • NativeMultiHashMap: Contains multiple values per key.
  • NativeQueue: A first in, first out queue.

So why would you use a NativeArray instead of a simple array?

Most importantly, it works with the safety system implemented in the Job System: It tracks what’s read and written to ensure thread safety. Thread safety can include things such as ensuring two jobs are not writing to the same point in memory at the same time. This is critical because the processes are happening in parallel.

Restrictions of the Native Container

You cannot pass references to a job because that would break the job’s thread safety. That means you can’t send in an array with the data you want as a reference. If you pass an array, the job will copy each element from the array to a new array within the job. This is a waste of memory and performance.

Even worse is that anything you change within the array on the job won’t affect the data on the main thread. Using the results you calculate on the job wouldn’t mean anything, defeating the purpose of using a job.

If you use a NativeContainer, its data is in native shared memory. The NativeContainer is simply a shared pointer to memory. This allows you to pass a pointer to the job, allowing you to access data within the main thread. Plus, copying the data of the NativeContainer won’t waste memory.

Keep in mind that you can pass floats, integers and all the primitive value types to the job. However, you cannot pass reference types such as GameObjects. To get data out of a job, you have to use a NativeContainer data type.

Initializing the Wave Generator

Add this initialization code into your Start():

waterMesh = waterMeshFilter.mesh; 

waterMesh.MarkDynamic(); // 1

waterVertices = 
new NativeArray<Vector3>(waterMesh.vertices, Allocator.Persistent); // 2

waterNormals = 
new NativeArray<Vector3>(waterMesh.normals, Allocator.Persistent);

Here’s a breakdown of what’s going on:

  1. You mark the waterMesh as dynamic so Unity can optimize sending vertex changes from the CPU to the GPU.
  2. You initialize waterVertices with the vertices of the waterMesh. You also assign a persistent allocator.

The most important concept here is the allocation type of NativeContainers. There are three primary allocation types:

  • Temp: Designed for allocations with a lifespan of one frame or less, it has the fastest allocation. It’s not allowed for use in the Job System.
  • TempJob: Intended for allocations with a lifespan of four frames, it offers slower allocation than Temp. Small jobs use them.
  • Persistent: Offers the slowest allocation, but it can last for the entire lifetime of a program. Longer jobs can use this allocation type.

To update the vertices within the waterVertices throughout the lifetime of the program, you used the persistent allocator. This ensures that you don’t have to re-initialize the NativeArray each time the job finishes.

Add this method:

private void OnDestroy()
{
    waterVertices.Dispose();
    waterNormals.Dispose();
}

NativeContainers must be disposed within the lifetime of the allocation. Since you’re using the persistent allocator, it’s sufficient to call Dispose() on OnDestroy(). Unity automatically runs OnDestroy() when the game finishes or the component gets destroyed.

Implementing Job System Into Wave Generator

Now you’re getting into the real fun stuff: the creation of the job! When you create a job, you must first decide which type you need. Here are some of the core job types:

  • IJob: The standard job, which can run in parallel with all the other jobs you’ve scheduled. Used for multiple unrelated operations.
  • IJobParallelFor: All ParallelFor jobs allow you to perform the same independent operation for each element of a native container within a fixed number of iterations. Unity will automatically segment the work into chunks of defined sizes.
  • IJobParallelForTransform: A ParallelFor job type that’s specialized to operate on transforms.

So what do you think is the best job type for iterating through all the vertices in the mesh and applying a Perlin noise function?

Need help? Open the spoiler below to find out.

[spoiler title=”Solution”]
You’ll the IJobParallelFor interface because you’re applying the same operation to a large number of elements.
[/spoiler]

Setting up the Job

A job comes in the form of a struct. Add this empty job inside the scope of WaveGenerator.

private struct UpdateMeshJob : IJobParallelFor
{

}

Here, you’ve defined the name of the job as UpdateMeshJob and applied the IJobParallelFor interface to it.

Wave Generator Job Struct

Now, there’s a red underline in your IDE. This is because you haven’t implemented the method required for the IJobParallelFor interface.

Apply the following code within the UpdateMeshJob:

public void Execute (int i)
{
           
}

Each type of job has its own Execute() actions. For IJobParallelFor, Execute runs once for each element in the the array it loops through.

i tells you which index the Execute() iterates on. You can then treat the body of Execute() as one iteration within a simple loop.

Before you fill out Execute(), add the following variables inside the UpdateMeshJob:

// 1
public NativeArray<Vector3> vertices;

// 2
[ReadOnly]
public NativeArray<Vector3> normals;

// 3
public float offsetSpeed;
public float scale;
public float height;

// 4
public float time;

Time to break this down:

  1. This is a public NativeArray to read and write vertex data between the job and the main thread.
  2. The [ReadOnly] tag tells the Job System that you only want to read the data from the main thread.
  3. These variables control how the Perlin noise function acts. The main thread passes them in.
  4. Note that you cannot access statics such as Time.time within a job. Instead, you pass them in as variables during the job’s initialization.

Writing the Functionality of the Job

Add the following noise sampling code within the struct:

private float Noise(float x, float y)
{
    float2 pos = math.float2(x, y);
    return noise.snoise(pos);
}

This is the Perlin noise function to sample Perlin noise given an x and a y parameter.

Now you have everything to fill out the Execute(), so add the following:

// 1
if (normals[i].z > 0f) 
{
    // 2
    var vertex = vertices[i]; 
    
    // 3
    float noiseValue = 
    Noise(vertex.x * scale + offsetSpeed * time, vertex.y * scale + 
    offsetSpeed * time); 
    
    // 4
    vertices[i] = 
    new Vector3(vertex.x , vertex.y, noiseValue * height + 0.3f); 
}

Here’s what’s happening:

  1. You ensure the wave only affects the vertices facing upwards. This excludes the base of the water.
  2. Here, you get a reference to the current vertex.
  3. You sample Perlin noise with scaling and offset transformations.
  4. Finally, you apply the value of the current vertex within the vertices.