Job System and Burst Compiler

preface

This article is learned from hereThis is Download link of material file

Most of the content is the content of the article plus their own understanding, which is limited by time and their own ability level. Please refer to it carefully

First, you need three official packages of Unity. Please open the preview package option of Unity, otherwise you can't see many packages in the preview phase in the package manager, as shown in the figure

Then install Jobs, Burst, and Mathematics in the Package Manager

The first two of them can be found directly in the package manager, and Mathematics needs to be installed through gir url, as shown in the figure

Enter com. Unity. In the search bar that appears mathematics@1.1 Click Add to add the math library

Incidentally, Burst seems to be installed with Jobs. At present, there seems to be no need for additional installation. I haven't studied the specific relationship. Anyway, just ensure that these three packages are installed

About Job System

In my opinion, Jobs System is a safe way to use multithreading, but at present, the use method is also relatively limited. Under normal circumstances, our PC side is a multi-core CPU (this is 2021), but many times when we play games and write C# scripts, we rarely call multithreading directly. First, I don't have that level, and second, multithreading is easy to overturn

Jobs is a way of operating multithreading officially provided by Unity... Although it's a little uncomfortable to say that you have to write code according to the official "template", it's very convenient, fast and safe to use anyway... This is my understanding of jobs

Starter

Open the Introduction to Job System Starter project and open the main scene. It is a water surface. The purpose of this section is to create waves by moving the vertices of the water surface. There are still many vertices of this model. If you move one by one through the traditional method of traversing the vertices, it can be imagined that the FPS must be very low

Open the script WaveGenerator.cs

Among them, the three parameters under [Header("Wave Parameters") are used to generate random numbers, that is, random waves, which is not critical

The parameters under [Header("References and Prefabs")] are references to waves and are not the key. Just drag Prefab into it later

First add two parameters

NativeArray<Vector3> waterVertices; // vertex
NativeArray<Vector3> waterNormals;  // normal

NativeArray is a special container type provided by the Job System. Why is multithreading unsafe? One reason is that when each thread accesses a data at the same time, it is easy to cause competition, resulting in many bugs. The purpose of this NativeArray type is to prevent problems caused by competition. The Job System provides the following container types:

  • NativeList is an array with adjustable length (Job version List)
  • Native HashMap job version HasMap
  • NativeMultiHashMap one key can correspond to multiple values
  • Nativequeue job version

When two jobs write to the same NativeContainer at the same time, the system will automatically report an error to ensure thread safety

Here are two more points to note

1, Data of reference type cannot be passed into the Native container, which will cause thread safety problems, so only value type can be passed

2, If a Job thread only needs to access the Native container without writing data, it needs to mark the container as [ReadOnly], which will be mentioned below

Initialize in Start()

private void Start()
{
        waterMesh = waterMeshFilter.mesh; 

        waterMesh.MarkDynamic(); // 1

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

        waterNormals = 
            new NativeArray<Vector3>(waterMesh.normals, Allocator.Persistent);
}
  1. Let vertices be dynamically modified, independent of Job

  2. Initialize the Native container. The first parameter is data and the second parameter is allocation method. There are three allocation methods, as follows:

At present, I still like the third one. After all, it has a long life cycle and can be used at any time... The role of the first two distribution methods is not understood

Destroy in OnDestroy()

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

Just take it as a must

Implement Job System

A Job is essentially a structure one by one. Each structure is a small Job, but the Job must be implemented from one of the following three interfaces:

  • IJob, a standard task, can work in parallel with all other defined jobs
  • IJobParallelFor, a parallel task, can perform an operation on all data in a Native container in parallel
  • IJobParallelForTransform is similar to the second, but it works specifically for Native containers of Transform type

Our goal is to dynamically modify all vertex data. That must be the second way. Vertex data does not involve Transform!

The following structures are defined

private struct UpdateMeshJob : IJobParallelFor
{
        // 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;
        
        // 5
        private float Noise(float x, float y)
        {
            float2 pos = math.float2(x, y);
            return noise.snoise(pos);
        }

		// 6
        public void Execute(int i)
        {
            // 7
            if (normals[i].z > 0f) 
            {
                // 8
                var vertex = vertices[i]; 
    
                // 9
                float noiseValue = 
                    Noise(vertex.x * scale + offsetSpeed * time, vertex.y * scale + 
                                                                 offsetSpeed * time); 
    
                // 10
                vertices[i] = 
                    new Vector3(vertex.x , vertex.y, noiseValue * height + 0.3f); 
            }

        }
}

Let's analyze it one by one

  1. Define a native container. The container defined inside the structure is used to copy or modify the data of the external container. The container here is not defined as [ReadOnly], which means that this container will modify the data of a container

  2. The same as above, but note that here is [ReadOnly], indicating that this container is only used to access data on a main thread

    P.S. my understanding here is that the containers defined in the class can be understood as the data on the main thread, and the containers defined in the structure can be understood as the data on the sub thread. If the sub thread wants to access the data of the main thread, it can only be copied in this way. Moreover, if two sub threads are modifying the data of a container on the main thread at the same time, Unity will report an error.

  3. These data are used to generate random numbers, but we did not initialize them, because they can also be passed in through the outside. Because they are value types, it doesn't matter, but it won't work if you pass in a Transform

    P.S. there is also a doubt here. It is understandable that the first container needs to be defined as NativeArray, because it will modify the data of the main thread, but the second container is defined as [ReadOnly] It shows that it only accesses data without modifying. Why don't we think of an ordinary List and then import it from the outside? In fact, according to my understanding, this is feasible, but! Adding this List has a length of 1000. If we want to copy a List from the outside, we need to copy it one by one, which is a waste of space, and NativeArray is essentially a shared pointer , the replication between NativeArray is not consumed!

  4. Time.time cannot be passed in, so it is temporarily stored with a parameter, which is also used to generate random numbers

  5. A method of generating random numbers. It is very common noise. I won't repeat it specifically. It has nothing to do with Job

  6. This method comes from the interface IJobParallelFor, which belongs to the interface that must be implemented. Didn't we pass in a NativeArray vertices on it? This native array contains all vertex information of our water model, and our goal is to modify its vertex data at the same time. This Execute(i) method represents the operation we will do For each vertex, where i represents the sequence number of the vertex in the array. It seems that this function is similar to a For loop? However, the For loop is executed one by one, and the Execute method will be automatically allocated to multiple threads by Unity

  7. Vertices is the information of all vertices, while normals is the normal information of all vertices. Through this i, we can easily access the normal information of a vertex. This normal may require a little graphics knowledge. Let's not talk about it. What we mean here is that if a vertex is upward, we can modify its information. Let's be more popular, Let's just modify the upper side of the water surface and ignore the lower side

  8. The acquisition noise, independent of Job, is a random number

  9. Modify vertex data

Perform tasks

The above task is only defined, but it has not been executed yet. We want to modify the data in each frame, so as to form continuous waves!

First define two parameters

JobHandle meshModificationJobHandle; // 1
UpdateMeshJob meshModificationJob; // 2
  1. Handle is abstract. I don't know how to explain it. It can be regarded as the current information of a task. When a Job is to be executed, it will return a handle to you, which can access the execution of the current Job. In most cases, we need to wait for one Job to complete before executing the next Job. The function of handle is to wait
  2. It is the Job structure defined above

Perform tasks in Update()

private void 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. Initialization task, in which the parameters are various parameters defined by us
  2. When executing a task, there are two parameters: the data length of the first parameter and the parallel length of the second parameter. For example, the number of vertices is 128 and our parallel length is 64. Then the Job will be divided into two parts and carried out in two sub threads respectively. This explanation is very vague. It probably means this. Focus on understanding. The expression may be wrong, It's hard to make it clear in a few words

Wait for the task to end

private void LateUpdate()
{
    // 1
    meshModificationJobHandle.Complete();

    // 2
    waterMesh.SetVertices(meshModificationJob.vertices);
    
    // 3
    waterMesh.RecalculateNormals();

}
  1. This is the function of the handle, which means that we need to wait for the Job of Schedule() above to end. If it does not end, how can we get new vertex data
  2. Set vertex data
  3. Recalculate normals

Test it

Set script data

Click Run. It should be useful. At this time, check the Status

It's also gratifying that so many vertices can have this data. Check the task manager and find that all cores are running

Burst

I haven't studied the specific principle. In short, let's add a label to the Job just defined

Then run

Direct supernatural!

IJobParallelForTransform

We also mentioned a special Job, which is specifically for the Transform type. Because the Transform type is a reference type, you must implement the Job through a specific method

Open the script FishGenerator.cs, which will generate a large number of free moving small fish

First, add two properties

// 1
private NativeArray<Vector3> velocities;

// 2
private TransformAccessArray transformAccessArray;
  1. The moving speed is the same as the container mentioned above, except that it stores the Vector3 type
  2. A special container dedicated to Transform can only output data of Transform type and is an array

Initialize in Start()

private void 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);
    }

}
  1. There is no difference from the above, that is, initialize the container. The first parameter is the number of fish and the second parameter is the allocation method

  2. It is also an initialization container, but the initialization Transform container is slightly different from other containers, as long as the size is written

    3.4. It's just generating random numbers, which has nothing to do with Job. The purpose is to randomly generate the position of fish. Just look at it

  3. Add the generated random location to the container

You also need to destroy the container when OnDestroy()

private void OnDestroy()
{
        transformAccessArray.Dispose();
        velocities.Dispose();
}

Then fill in the parameters and test them

You will find that many small fish are randomly generated, but they are still

Create a small fish mobile Job

[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)
    {
        // 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);
        }
        
        Vector3 currentPosition = transform.position;

        bool randomise = true;

        // 5
        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;
        }

        // 6
        if (randomise)
        {
            if (randomGen.NextInt(0, swimChangeFrequency) <= 2)
            {
                objectVelocities[i] = new Vector3(randomGen.NextFloat(-1f, 1f), 
                    0, randomGen.NextFloat(-1f, 1f));
            }
        }

    }
}

This Job is very similar to the wave Job above. The difference is that it implements the self interface IJobParallelForTransform. Only this interface can access the transform container. Similarly, there is an Execute method to be implemented, where i represents the sequence number in the container and transform is a reference

In fact, the specific algorithm is not important. The main purpose is to make the Transform of small fish change linearly. If you don't use Job and write it in Update, I believe many people can write it casually

  1. Current speed of the i-th fish
  2. Random seed, using random = Unity.Mathematics.Random;
  3. The random moving distance will be determined according to the parameters specified above
  4. rotate
    1. Prevent small fish from moving out of the water

Perform tasks

You also need to create a handle and a task first

private PositionUpdateJob positionUpdateJob;

private JobHandle positionUpdateJobHandle;

Initialize and execute in Update()

private void 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);

}
  1. Initialization task. Note that we did not pass in a Transform array
  2. To execute the task, you can pass in our transformAccessArray

Also wait for the task to end in LateUpdate()

private void LateUpdate()
{
    positionUpdateJobHandle.Complete(); 
}

Click Run and the fish moves!

Posted by remlabm on Wed, 03 Nov 2021 11:54:42 -0700