All videos. All books. One low price.

Get unlimited access to all video courses and books on this site with the new raywenderlich.com Ultimate Subscription. Plans start at just $19.99/month.

Home Unity Tutorials

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.

5/5 6 Ratings

Version

  • C# 3.5, Unity 2019.3, Unity

Writing scalable multi-threaded code in games has always been tough, but this is changing rapidly with the release of Unity’s Data-Oriented Technology Stack (DOTS). 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.

You’ll get hands-on experience with the following topics:

  • Turning single-threaded code into efficient jobs.
  • Using the Burst compiler to speed up your projects.
  • Utilizing Unity’s Mathematics system for multi-threading.
  • Modifying mesh data in real time.
Note: This tutorial assumes that you know the basics of Unity development. If you’re new to Unity, check out this great Getting Started in Unity tutorial.

You’ll need a copy of Unity 2019.3 (or newer) installed on your machine to follow this tutorial.

Getting Started

After installing Unity, download the sample project by clicking on the Download Materials button at the top or bottom of this tutorial.

Extract the files and open the Introduction to Job System Starter project in Unity. Open RW using the Project window and take a look at the folder structure:

Folder Structure of Project

Here’s a quick breakdown of what each folder contains:

  • Materials: Materials for the water and fish.
  • Models: Models of the water and fish.
  • Prefabs: A fish prefab, which you’ll instantiate hundreds of times.
  • Scenes: The Main Scene, which you’ll modify.
  • Scripts: Starter scripts ready for you to add your awesome code.

Open the Main Scene and look at the Game view. You’ll see an empty stretch of water. Press the Play button and… nothing happens.

Empty Scene

Press the Stats button on the Game view and note the FPS. The FPS largely depends on the computer you have. You’ll use it throughout the tutorial to benchmark the performance of the Job System.

By the end, you’ll have waves on the water with thousands of fish swimming inside.

Here’s a breakdown of one frame:

  1. The code loops through 10,000 vertices of the water mesh, applying a mathematical function to change its height.
  2. Each of the 1,000 to 2,000 fish gets a random destination and velocity to swim inside the water.
Note: Remember that not all problems require multi-threading. Sometimes, code runs more slowly when it uses unnecessary threading. Multi-threading also comes with a bunch of limitations, which you’ll discover throughout this tutorial.

Installing Required Packages

Before you begin using the Job System, you have to install some packages from the Package Manager. Select Window ▸ Package Manager from the top menu.

Installing Packages from Manager

In the Package Manager, select Advanced ▸ Show preview packages and install the following:

  1. Job System
  2. Burst Compiler
  3. Mathematics

You’ll learn more about the purpose of these packages throughout the tutorial.

Understanding the Job System

So what exactly is the Job System and what makes it different from just writing normal multi-threaded code?

Overall, it allows you to run processes over multiple cores safely and simply, without worrying about race conditions, deadlocks and other issues that usually arise.

Annotation of Multi-threading

The Job System allows games to use all the CPU cores in a computer. All modern CPUs have multiple cores, yet many games don’t take advantage of them. When you split large tasks into multiple smaller chunks and run them in parallel, you run them simultaneously instead of linearly. This greatly improves performance.

Unity’s Job System is a part of their larger project called the Data Oriented Technology Stack (DOTS). DOTS keeps performance in mind from the start. It contains the Job System, Burst compiler and Entity Component System (ECS). The Job System is for highly parallel code. ECS is for efficient memory management and the Burst compiler is for efficient native machine code.

Understanding the Burst Compiler

The Burst compiler works perfectly with the Job System. The mechanisms of the compiler are well beyond the scope of this tutorial, but the basic premise is that it’s able to compile C# code into much more efficient and performant native code.

Unity’s entire scripting uses Mono. Mono is an implementation of .NET that can compile C# on multiple systems such as Windows, Mac and PlayStation. Unfortunately, the cost of being able to execute code on multiple platforms is high. Managed C# will never reach the performance of code designed for a specific platform.

Their solution to this was the Burst compiler, which is a ‘math-aware’ compiler that produces highly optimized machine code depending on the platform. It’s pretty complicated technology that utilizes the LLVM Project. Luckily, all you have to do is add a line or two of code to benefit from it.

You’ve also installed the Unity Mathematics package, which is simply a C# math library that’s used by the Burst compiler for low-level optimization.

Setting up the Wave Generator

For your first step, you’ll create the waves. You’ll use shaded wire-frame mode so you can see the massive number of vertices within the mesh.

Wire-frame mesh view

Understanding Perlin Noise

To create waves on the mesh, you’re going to sample a value from Perlin noise for each vertex to set its height. Perlin noise generates smooth, continuous random heights that can move over time to generate wave-like features.

Here’s some static Perlin noise:

Perlin Noise

You can shift and scale this Perlin noise over time:

Modified Perlin Noise

Setting up the Wave Generator

Open RW/Scripts/WaveGenerator.cs and populate the file with the following namespaces to get started:

using UnityEngine.Jobs;
using Unity.Collections;
using Unity.Burst;
using Unity.Jobs;
using Unity.Mathematics;

The Unity.Collections package brings in Unity’s optimized version of System.Collections. The remaining packages came pre-installed from the Package Manager.

Note the following variables that represent the modifiers of the Perlin noise function:

[Header("Wave Parameters")]
public float waveScale; // 1
public float waveOffsetSpeed; // 2
public float waveHeight; // 3
  1. Wave Scale: Scales the Perlin noise function.
  2. Wave Offset Speed: The speed that the Perlin noise shifts over time.
  3. Wave Height: The height multiplier of the Perlin noise.

Different scene components also have their own reference variables.

Add the following variables:

NativeArray<Vector3> waterVertices;
NativeArray<Vector3> waterNormals;

waterVertices and waterNormals are responsible for transporting the vertices and normals of the water mesh to and from the jobs.

NativeArray comes from the Unity.Collections namespace. It’s a key component of sending and receiving information from jobs. A NativeArray is a child of the NativeContainer value type.

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.

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
  1. 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.
  2. 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);
  1. You initialize the UpdateMeshJob with all the variables required for the job.
  2. 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:

  1. Ensures the completion of the job because you can’t get the result of the vertices inside the job before it completes.
  2. 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.
  3. 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.

Setting up Variables

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?

Mesh Modification Without Burst Compiler

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.

Burst Compiler Attribute

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:

Wave Generator Code Structure

Save, then play the scene and observe the frame rate:

Mesh Modification With Burst Compiler

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?

  1. The velocities keep track of the velocity of each fish throughout the lifetime of the game, so that you can simulate continuous movement.
  2. 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:

  1. Initialize velocities with a persistent allocator of size amountOfFish, which is a pre-declared variable.
  2. Initialize transformAccessArray with size amountOfFish.
  3. Create a random spawn point within spawnBounds.
  4. Instantiate objectPrefab, which is a fish, at spawnPoint with no rotation.
  5. 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:

Setting up Fish Generator Parameters

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:

Randomly Spawned Fish

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:

  1. Sets the current velocity of the fish.
  2. Uses Unity's Mathematics library to create a psuedorandom number generator that creates a seed by using the index and system time.
  3. Moves the transform along its local forward direction, using localToWorldMatrix.
  4. 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:

  1. You check the position of the transform against the boundaries. If it's outside, the velocity flips towards the center.
  2. 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.

Scheduling the Movement Job

To run PositionUpdateJob, you have to schedule it. Like before, you'll schedule the job on Update() and complete it on LateUpdate().

First, add the following variables to the top of the class:

private PositionUpdateJob positionUpdateJob;

private JobHandle positionUpdateJobHandle;

This is a reference to the job and its handle, so you can access it throughout Update() and LateUpdate().

Place this code in the Update():

// 1
positionUpdateJob = new PositionUpdateJob()
{
    objectVelocities = velocities,
    jobDeltaTime = Time.deltaTime,
    swimSpeed = this.swimSpeed,
    turnSpeed = this.turnSpeed,
    time = Time.time,
    swimChangeFrequency = this.swimChangeFrequency,
    center = waterObject.position,
    bounds = spawnBounds,
    seed = System.DateTimeOffset.Now.Millisecond
};

// 2
positionUpdateJobHandle = positionUpdateJob.Schedule(transformAccessArray);

First, all the variables within the main thread set the job's data. seed gets the current millisecond from the system time to ensure a different seed for each call.

Secondly, you schedule positionUpdateJob. Note that each job type has its own Schedule() parameters. A IJobParallelForTransform takes a TransformAccessArray.

Finally, add this into LateUpdate():

 positionUpdateJobHandle.Complete(); 

This ensures the completion of the job before moving onto the next Update cycle.

The structure of FishGenerator.cs should look like this:

Fish Generator Code Structure

Now, save the file and enter Unity. Press Play and watch those fish go!

Fish Swimming in Water

While 200 swimming fish is impressive, the system can do a whole lot better. For fun, run a little stress test by increasing the amount of fish to 5,000:

5,000 Fish Stress Test

5,000 fish swimming in simulated water, and it's still running at around 200 FPS. Clearly, the Job System is very impressive. However, the Burst compiler plays a major role in optimizing your code.

Inspecting the Profiler

A Job System manages a group of worker threads across multiple cores. The Profiler shows the segmentation of work.

Profiler Inspection

Notice how there are multiple worker threads running the scripts in parallel, reducing the duration of the process.

Where to Go From Here?

Download the complete project using the Download Materials button at the top or bottom of this tutorial.

In this tutorial, you learned how to:

  • Use the Job System to multithread loops.
  • Implement the Burst compiler.
  • Modify the properties of transforms over multiple threads.

This project is only the beginning; there's so much more you can add. I'm interested to see what you come up with! Implementing ECS with the fishes would be a great next step in optimizing this game.

Did you enjoy this tutorial? Want to learn more? Check out our book Unity Games by Tutorials, which has more info on making games with Unity.

If you want to learn more about Unity's Job System, check out What Is a Job System? by Unity.

You'll also find useful information in the official Job System Manual.

If you have any suggestions, questions or you want to show off what you did to improve this project, join the discussion below.

Average Rating

5/5

Add a rating for this content

6 ratings

More like this

Contributors

Comments