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 3 of 4 of this article. Click here to view the first page.

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.