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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Unity Job System and Burst Compiler: Getting Started
30 mins
- Getting Started
- Installing Required Packages
- Understanding the Job System
- Understanding the Burst Compiler
- Setting up the Wave Generator
- Understanding Perlin Noise
- Setting up the Wave Generator
- Understanding the Native Container
- Restrictions of the Native Container
- Initializing the Wave Generator
- Implementing Job System Into Wave Generator
- Setting up the Job
- Writing the Functionality of the Job
- Scheduling the Job
- Completing the Job
- Implementing the Burst Compiler
- Creating Swimming Fish in the Water
- Spawning the Fish
- Creating the Movement Job
- Scheduling the Movement Job
- Inspecting the Profiler
- Where to Go From Here?
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:
- You mark the
waterMesh
as dynamic so Unity can optimize sending vertex changes from the CPU to the GPU. - You initialize
waterVertices
with the vertices of thewaterMesh
. 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.
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:
- This is a public
NativeArray
to read and write vertex data between the job and the main thread. - The
[ReadOnly]
tag tells the Job System that you only want to read the data from the main thread. - These variables control how the Perlin noise function acts. The main thread passes them in.
- 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:
- You ensure the wave only affects the vertices facing upwards. This excludes the base of the water.
- Here, you get a reference to the current vertex.
- You sample Perlin noise with scaling and offset transformations.
- Finally, you apply the value of the current vertex within the
vertices
.