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?
Scheduling the Job
Now that you’ve created the job, you need to run it. Unity has outlined the correct way to approach this. Their motto is: “Schedule Early, Complete Late”. This means, schedule the job and wait as long as possible before ensuring its completion and collecting its values.
For you, this means schedule Update()
and ensure its completion in LateUpdate()
. This prevents the main thread from hanging while it waits for a job to complete.
Why would the main thread hang if it’s running in parallel? Well, you can’t retrieve the data inside a job until it completes. Before you do either, add these two variables to the top of WaveGenerator
:
JobHandle meshModificationJobHandle; // 1
UpdateMeshJob meshModificationJob; // 2
- This
JobHandle
serves three primary functions:- Scheduling a job correctly.
- Making the main thread wait for a job’s completion.
- Adding dependencies. Dependencies ensure that a job only starts after another job completes. This prevents two jobs from changing the same data at the same time. It segments the logical flow of your game.
- Reference an
UpdateMeshJob
so the entire class can access it.
Now, add the following within Update()
:
// 1
meshModificationJob = new UpdateMeshJob()
{
vertices = waterVertices,
normals = waterNormals,
offsetSpeed = waveOffsetSpeed,
time = Time.time,
scale = waveScale,
height = waveHeight
};
// 2
meshModificationJobHandle =
meshModificationJob.Schedule(waterVertices.Length, 64);
- You initialize the
UpdateMeshJob
with all the variables required for the job. - The IJobParallelFor’s
Schedule()
requires the length of the loop and the batch size. The batch size determines how many segments to divide the work into.
Completing the Job
Calling Schedule
puts the job into the job queue for execution at the appropriate time. Once scheduled, you cannot interrupt a job.
Now that you’ve scheduled the job, you need ensure its completion before assigning the vertices to the mesh. So, in LateUpdate()
, add the following:
// 1
meshModificationJobHandle.Complete();
// 2
waterMesh.SetVertices(meshModificationJob.vertices);
// 3
waterMesh.RecalculateNormals();
Here’s what this code is doing:
- Ensures the completion of the job because you can’t get the result of the vertices inside the job before it completes.
- Unity allows you to directly set the vertices of a mesh from a job. This is a new improvement that eliminates copying the data back and forth between threads.
- You have to recalculate the normals of the mesh so that the lighting interacts with the deformed mesh correctly.
Implementing the Burst Compiler
Save the script and attach the Water Mesh Filter and the wave parameters within the inspector on the Water Manager.
Here are the parameter settings:
- Wave Scale: 0.24
- Wave Offset Speed: 1.06
- Wave Height: 0.16
- Water Mesh Filter: Assign the reference from the scene
Press Play and enjoy the beautiful waves. Why go to the beach when you can watch this at home?
Congratulations, you’ve used the Job System to create waves and they’re running effortlessly. However, something’s missing: You haven’t used the Burst compiler yet.
To implement it, include the following line, right above UpdateMeshJob
:
[BurstCompile]
Placing the attribute before all jobs allows the compiler to optimize the code during compilation, taking full advantage of the new mathematics library and Burst’s other optimizations.
The code structure of the WaveGenerator.cs should look like this:
Save, then play the scene and observe the frame rate:
The Burst compiler increased the frame rate from 200 to 800 with a single line of code. This may vary on your machine, but there should be a significant improvement.
The water looks a bit lonely at the moment. Time to populate it with some fish.
Creating Swimming Fish in the Water
Open RW/Scripts/FishGenerator.cs and add the following namespaces:
using Unity.Jobs;
using Unity.Collections;
using Unity.Burst;
using UnityEngine.Jobs;
using math = Unity.Mathematics.math;
using random = Unity.Mathematics.Random;
Now that you have all the namespaces, add these additional variables into the class:
// 1
private NativeArray<Vector3> velocities;
// 2
private TransformAccessArray transformAccessArray;
So what do these do?
- The
velocities
keep track of the velocity of each fish throughout the lifetime of the game, so that you can simulate continuous movement. - You can’t have a
NativeArray
of transforms, as you can’t pass reference types between threads. So, Unity provides a TransformAccessArray, which contains the value type information of a transform including its position, rotation and matrices. The added advantage is, any modification you make to an element of the TransformAccessArray will directly impact the transform in the scene.
Spawning the Fish
Now’s a great oppor-tuna-ty to spawn some fish.
Add the following code in Start()
:
// 1
velocities = new NativeArray<Vector3>(amountOfFish, Allocator.Persistent);
// 2
transformAccessArray = new TransformAccessArray(amountOfFish);
for (int i = 0; i < amountOfFish; i++)
{
float distanceX =
Random.Range(-spawnBounds.x / 2, spawnBounds.x / 2);
float distanceZ =
Random.Range(-spawnBounds.z / 2, spawnBounds.z / 2);
// 3
Vector3 spawnPoint =
(transform.position + Vector3.up * spawnHeight) + new Vector3(distanceX, 0, distanceZ);
// 4
Transform t =
(Transform)Instantiate(objectPrefab, spawnPoint,
Quaternion.identity);
// 5
transformAccessArray.Add(t);
}
In this code, you:
- Initialize
velocities
with a persistent allocator of sizeamountOfFish
, which is a pre-declared variable. - Initialize
transformAccessArray
with sizeamountOfFish
. - Create a random spawn point within
spawnBounds
. - Instantiate
objectPrefab
, which is a fish, atspawnPoint
with no rotation. - Add the instantiated transform to
transformAccessArray
.
Make sure to add OnDestroy()
to dispose of the NativeArrays
:
private void OnDestroy()
{
transformAccessArray.Dispose();
velocities.Dispose();
}
Save and return to Unity. Then modify the parameters in the inspector like so:
Here are the parameter settings:
- Amount of Fish: 200
- Spawn Bounds: X: 470, Y: 47, Z: 470
- Spawn Height: 0
- Swim Change Frequency: 250
- Swim Speed: 30
- Turn Speed: 4.6
Press Play and notice the 200 randomly-scattered fish in the water:
It looks a little fishy without motion. It's time to give the fish some life and get them moving around.
Creating the Movement Job
To move the fish, the code will loop through each transform within the transformAccessArray
and modify its position and velocity.
This requires an IJobParallelForTransform
interface for the job, so add a job struct called PositionUpdateJob into the scope of FishGenerator
:
[BurstCompile]
struct PositionUpdateJob : IJobParallelForTransform
{
public NativeArray<Vector3> objectVelocities;
public Vector3 bounds;
public Vector3 center;
public float jobDeltaTime;
public float time;
public float swimSpeed;
public float turnSpeed;
public int swimChangeFrequency;
public float seed;
public void Execute (int i, TransformAccess transform)
{
}
}
Note that you've already added the [BurstCompile]
attribute, so you'll get the performance improvements that come with the compiler.
Execute()
is also different. It now has an index as well as access to the transform the job currently iterates on. Anything within that method will run once for every transform in transformAccessArray
.
The PositionUpdateJob
also takes a couple of variables. The objectVelocities
is the NativeArray
that stores the velocities. The jobDeltaTime
brings in Time.deltaTime
. The other variables are the parameters that the main thread will set.
For your next step, you'll move each fish in the direction of its velocity and rotate it to face the velocity vector. The parameters passed into the job control the speed of the fish.
Add the following code to Execute()
:
// 1
Vector3 currentVelocity = objectVelocities[i];
// 2
random randomGen = new random((uint)(i * time + 1 + seed));
// 3
transform.position +=
transform.localToWorldMatrix.MultiplyVector(new Vector3(0, 0, 1)) *
swimSpeed *
jobDeltaTime *
randomGen.NextFloat(0.3f, 1.0f);
// 4
if (currentVelocity != Vector3.zero)
{
transform.rotation =
Quaternion.Lerp(transform.rotation,
Quaternion.LookRotation(currentVelocity), turnSpeed * jobDeltaTime);
}
Here's what this code does:
- Sets the current velocity of the fish.
- Uses Unity's Mathematics library to create a psuedorandom number generator that creates a seed by using the index and system time.
- Moves the transform along its local forward direction, using
localToWorldMatrix
. - Rotates the transform in the direction of
currentVelocity
.
Now to prevent a fish-out-of-water experience, add the following after the code above in Execute()
:
Vector3 currentPosition = transform.position;
bool randomise = true;
// 1
if (currentPosition.x > center.x + bounds.x / 2 ||
currentPosition.x < center.x - bounds.x/2 ||
currentPosition.z > center.z + bounds.z / 2 ||
currentPosition.z < center.z - bounds.z / 2)
{
Vector3 internalPosition = new Vector3(center.x +
randomGen.NextFloat(-bounds.x / 2, bounds.x / 2)/1.3f,
0,
center.z + randomGen.NextFloat(-bounds.z / 2, bounds.z / 2)/1.3f);
currentVelocity = (internalPosition- currentPosition).normalized;
objectVelocities[i] = currentVelocity;
transform.rotation = Quaternion.Lerp(transform.rotation,
Quaternion.LookRotation(currentVelocity),
turnSpeed * jobDeltaTime * 2);
randomise = false;
}
// 2
if (randomise)
{
if (randomGen.NextInt(0, swimChangeFrequency) <= 2)
{
objectVelocities[i] = new Vector3(randomGen.NextFloat(-1f, 1f),
0, randomGen.NextFloat(-1f, 1f));
}
}
Here's what's going on:
- You check the position of the transform against the boundaries. If it's outside, the velocity flips towards the center.
- If the transform is within the boundaries, there's a small possibility that the direction will shift to give the fish a more natural movement.
This code is very math-heavy. It wouldn't scale well on a single thread.