Made available to everyone starting with Unity 2018.1, the C# Job System allows users to write multithreaded code that interacts well with Unity. For many Unity users, this was a big deal. Better performance is so important to many people playing video games that players will often set their game’s graphics settings to something low so that the game will run optimally. But who says you have to force players to tweak their game’s settings to get the performance they want? Why not have peak performance from the start?
With Unity’s C# jobs, this is made much easier for the developer. This is especially true if you plan to create a game that requires many objects in the game’s world, with all of them doing something at the same time. Normally, this would be incredibly taxing for the machine running this game, but thanks to the newly implemented Job System, you can now more easily achieve this scenario without taking a performance hit. Unity’s C# jobs have been touched on before back when Unity 2018.1 was first released, but it only went skin deep. This time jobs will be explained in much more detail along with a tutorial showing you how to create a job that moves 3,000 cubes around in a scene. Note: you may need to adjust this number depending on the power of your computer.
Setting Up
Once you’ve started Unity, create a new project.
After that, name the project 3000Cubes. Then set your file path of choice. You’ll also want to make sure you’re using the 3D project template. After this has been done, click Create Project.
Unity will then do some work, then present you with a blank project like that shown in Figure 3.
Believe it or not, there are only two things that need to be done before jumping into the code. First, in the Hierarchy menu, click the Create button and select Create Empty to create a new object.
Name this object JobObject. After that, with JobObject selected in the Hierarchy, click the Add Component button in the Inspector window. In the window that appears, scroll to the very bottom and select New Script.
In the next window, name this script CubeMovementJob, then click Create and Add.
With that finished, JobObject should now look like what’s shown in Figure 7.
Setup is now complete! Yes, even the process of setting up a project is made faster thanks to Unity jobs. In the Inspector window, double click the Script field in the newly added Cube Movement Job component to open up Visual Studio and create your new C# job!
The Code
This project aims to show you two things: the first is to show how to create jobs. The second is to show off how much of a boost using C# jobs can give you compared to what you might usually do. Before declaring variables and creating your first job, you will need to enter some using statements. At the top of the script, before the class declaration, add the following lines of code:
using UnityEngine.Jobs; using Unity.Collections; using Unity.Jobs;
UnityEngine.Jobs
and Unity.Jobs
are required to access and utilize the job functionality in your script. They’ll also be required for certain variables you will declare later. Unity.Collections
allow you to make use of the NativeArray<>
struct type, which will be required when working with C# jobs. Next, declare the following variables:
public int count = 3000; public float speed = 20; public int spawnRange = 50; public bool useJob; private Transform[] transforms; private Vector3[] targets; private List<GameObject> cubes = new List<GameObject>(); private TransformAccessArray transAccArr; private NativeArray<Vector3> nativeTargets;
The first four public variables will be used to dictate how many cubes will be spawned, the speed at which it moves, the range that the cubes can be spawned in, and, finally, if you wish to use C# jobs or not. They have been made public so that they can be edited later from within the Unity editor in case you wish to add more cubes or increase the area they can spawn in. After these have been declared, a handful of private arrays will be created. The first two, transforms
and targets
, are arrays that will store the transform
and Vector3
data of the various cubes you create.
Next, you’ll have a new List named cubes, followed by the creation of the TransformAccessArray
and a NativeArray<Vector3>
. Cubes
will simply be a list kept of all the cubes spawned and will be used later when creating the same project in non-job code. Then there’s transAccArr
and nativeTargets
. These two arrays will store the information gathered from transforms
and targets
and send them to the job you shall soon create. But why can’t we use the transforms
and targets
arrays instead? This is because, according to Unity’s own debugger, the type of Transform
and Vector3
is not a value type, and jobs cannot contain any reference types. Put simply, jobs can only work with other structs, which Transform
and Vector3
are not.
So now you may be wondering why you would declare those first two arrays at all? They will be needed to fill the transAccArr
and nativeTargets
arrays, as you cannot simply add a new item to a TransformAccessArray
and NativeArray
. Instead, you will fill the transforms
and targets
arrays then hand the data over to transAccArr
and NativeTargets
to put to use in the job. In addition, you’ll also want them for later in the project when you use non-job code to perform the same task.
Quite a bit of explanation to be done here! Let’s take a break and see where your code should be now.
Now seems like a good time to create the C# job. Underneath your variable declarations, add the following:
struct MovementJob : IJobParallelForTransform { public float deltaTime; public NativeArray<Vector3> Targets; public float Speed; public void Execute(int i, TransformAccess transform) { transform.position = Vector3.Lerp(transform.position, Targets[i], deltaTime / Speed); } }
Let’s break this down. For starters, all jobs are structs
and must inherit from either IJob
, IJobParallelFor
or IJobParallelForTransform
. In this case, it’s inheriting from IJobParallelForTransform
because you will use this job to move objects, which IJobParallelForTransform
allows you to do.
Next, a few variables are declared. The first, deltaTime
, will simply keep track of what is currently in Time
.deltaTime
. You can’t simply say Time
.deltaTime
in the job, so you get the value of deltaTime
and store it as a float in the job. Next is a NativeArray
that will store an array of Vector3s
called Targets
. Finally, there’s another float
named Speed
, which will simply get the value of the public variable speed
.
Then comes the interesting part. Execute
is a function all jobs are required to have. As you may have guessed, whatever is inside Execute
is what the job will actually do. In this case, you have the job doing a simple task. It will get all the cube objects and have them Lerp
(meaning to smoothly move from one position to the next) from one point to another at a certain speed. Within the () of the Execute
function lies two parameters, an integer simply named i
, and a TransformAccess
simply named transform
. The variable i
will be treated much like i
would if this were a for loop
, and transform
will contain a given object’s transform.
At this point, the script should now look something like what’s shown in Figure 9.
Before working on the Start
and Update
functions, there are two more variables to declare, and they’re both important to the job you just created. Beneath your new job and above the Start
method, enter these lines:
private MovementJob job; private JobHandle newJobHandle;
The first variable is pretty simple. You’re simply declaring a reference to the MovementJob
. After that you declare a JobHandle
that you’ll just call newJobHandle
. A JobHandle
is almost exactly what it sounds like. It handles jobs, doing so by scheduling and completing the jobs you assign it. With everything declared and ready to roll, it’s time to work on the Start
function.
transforms = new Transform[count]; for (int i = 0; i < count; i++) { GameObject obj = GameObject.CreatePrimitive(PrimitiveType.Cube); cubes.Add(obj); obj.transform.position = new Vector3(Random.Range(-spawnRange, spawnRange), Random.Range(-spawnRange, spawnRange), Random.Range(-spawnRange, spawnRange)); obj.GetComponent<MeshRenderer>().material.color = Color.green; transforms[i] = obj.transform; } targets = new Vector3[transforms.Length]; StartCoroutine(GenerateTargets());
You create the Start
function by first taking the transforms
array and creating a new array of Transform
with count
defining the number of elements in the array. Count
is the variable that keeps track of how many cubes you will spawn. Speaking of which, the next part of the function has you creating a for loop
. Within this for loop
, you create a cube
, add it to the cubes
list, give it a random starting position, and give it a green color. Of course, feel free to change the color if you wish.
After that, the transforms
array gets its next value by getting the recently created cube’s transform. This process continues until every cube is spawned. Then, the targets
array gets a new array of Vector3
using transforms
.Length
to define the number of elements within this array. Finally, a Coroutine
will be run to fill the targets
array. But that Coroutine
has not yet been defined, so no doubt Visual Studio will start telling you it has no idea what this is. It’s now time to create this Coroutine
, but first, check to make sure your code looks like Figure 10 below.
A Coroutine
is created by creating an IEnumerator
. It then operates very similarly to a function except that it can pause execution and return control to Unity, but then carry on wherever it left off on the next frame. It is required that a yield return statement is included somewhere within the body of the Coroutine
. The yield return line is the point when an execution pauses and can be resumed in the following frame. Now that you know what a Coroutine
is, it’s time to create one! Place the following code underneath the Update
function.
public IEnumerator GenerateTargets() { for (int i = 0; i < targets.Length; i++) targets[i] = new Vector3(Random.Range(-spawnRange, spawnRange), Random.Range(-spawnRange, spawnRange), Random.Range(-spawnRange, spawnRange)); yield return new WaitForSeconds(2); }
In this case, your GenerateTargets
coroutine will simply create the cubes within the range you specify. This is among one of the simpler tasks you can do with coroutines. You can also create a typewriter effect with text and more using coroutines. Now, move on to the Update
function and input this code.
transAccArr = new TransformAccessArray(transforms); nativeTargets = new NativeArray<Vector3>(targets, Allocator.Temp); if (useJob == true) { job = new MovementJob(); job.deltaTime = Time.deltaTime; job.Targets = nativeTargets; job.Speed = speed; newJobHandle = job.Schedule(transAccArr); } else { for (int i = 0; i < transAccArr.length; i++) cubes[i].transform.position = Vector3.Lerp(cubes[i].transform.position, targets[i], Time.deltaTime / speed); }
Your Update
function will do one of two things depending on the value of the useJob
boolean
. If you set it to true, then your project will utilize the job you created to move the various cubes around the scene. When using jobs, you create a new instance of MovementJob
and assign the different variables in the job. Next, you utilize the JobHandle
called newJobHandle
to schedule the job you created. When scheduling the job, you give transAccArr
as the TransformAccessArray
that the job will use in its Execute
function.
If you set useJob
to false, then the program will instead accomplish the same task without using the C# job system. You’ll see later that, especially with many objects in the scene at once, that using jobs can greatly improve your project’s performance at runtime. Once you’re finished, the Update
function and GenerateTargets
coroutine should look similar to the figure below.
There is still one last task to complete before you can test out the project. Between the Update
function and GenerateTargets
coroutine, create a new function called LateUpdate
and give it the following code.
private void LateUpdate() { newJobHandle.Complete(); transAccArr.Dispose(); nativeTargets.Dispose(); }
There’s not much to this code, but it’s important to include this function to properly finish jobs and prevent memory leaks. LateUpdate
is called whenever all Update
functions have been called. It can be useful to order script execution. Some examples of where LateUpdate
can be used include moving a camera or, in your case, disposing native collections. There’s also the act of calling newJobHandle's
Complete
function. This function simply ensures that the job has been completed before moving on to another job you may give Unity. Once you’ve added this code, your script should look like this:
The time has now come to finish this project. Save your code and return to the Unity editor.
Finishing the Project
Much like the setup, finishing the project has very little to it. Select jobObject in the Hierarchy window, then navigate to the Inspector window and check out the Cube Movement Job script component. All the variables shown dictate the number of cubes spawned, how quickly they move, and the range that they can spawn in. There is also a checkbox that toggles your useJob boolean to true or false. For the moment, leave this boolean
as false (unchecked). The rest of the variables can be left at their default values if you wish, but the example will assume you kept the cube count at 3,000.
Before playing the project, it would be helpful to open the Profiler window to view the performance of your project. To do this, click Window->Analysis->Profiler or simply press Ctrl + 7. Place the profiler anywhere you wish on your screen.
After you’ve pulled up the Profiler window, save your project. If your computer finds itself unable to handle 3,000 cubes, it could lead to Unity crashing. Saving the project will, therefore, prevent any time and effort being lost. Should Unity crash, lower the number of cubes created. Begin the project by clicking the Play button at the top of the editor.
While the project runs, click anywhere in the top part of the Profiler window to view more information about how much time it takes to do specific tasks. The blue area in the Profiler window represents how much CPU usage is going towards performing the tasks in your script.
Remember, you should have it set up where you are currently not using jobs. Now, either pause the project or stop it to go back and set the useJob boolean to true, then run your program again to see the difference.
Notice how when using jobs, the amount of time the CPU takes to complete the task is cut almost in half. The difference is even more noticeable when increasing the number of cubes to spawn. On my computer, when increasing the number of cubes to 10,000 and not using jobs, the process could take 18 ms. Utilizing jobs in the same set of circumstances brought that time down to 13 ms. Of course, how much of a performance improvement one sees can depend on their individual CPU and what its capabilities are. Regardless, there’s no denying the improved performance that came once C# jobs entered the picture.
Conclusion
Multi-threaded code offers better performance to the developer though can be difficult to write. Thanks to Unity Technologies’ latest offerings, creating multi-threaded code is more easily achievable for the developer. Though situations demanding the C# job system may vary, the performance boost it can bring is immense. Another new tech for Unity, the Entity Component System, can also be utilized to further increase performance. ’Performance by default’ is the tagline for Unity 2018, and it’s easy to see why.
Some examples of where the job system can be put to great use include battle simulators, ocean simulators, and more. This example shows the job system moving around objects in a scene but can also be used to deform meshes and other tasks. Many tasks with a heavy load on your CPU can be lightened thanks to C# jobs, and it all starts by simply creating a struct and inheriting from a job interface.
The post Introducing the Unity Job System appeared first on Simple Talk.
from Simple Talk https://ift.tt/2xnk6La
via
No comments:
Post a Comment