Main

Unity ECS End Game - Implementing High-Performance Systems with Unity DOTS Job System

Support the channel : - Donation : https://wayn.games/ko-fi - AssetStore : https://wayn.games/assetstore-affiliate - Likes & Share 👍 Follow us on : - Discord : https://wayn.games/discord - X / Twitter : https://wayn.games/twitter - GitHub : https://wayn.games/github Resources ------------------------------------------------------------------------------------------------------------------ Learn Unity DOTS : https://github.com/WAYN-Games/DOTS-Training Deep Dive into Job System : https://youtu.be/LP0wmX9dzAM?si=2K97D4FI4gQ8lKwf Key concepts ------------------------------------------------------------------------------------------------------------------ Physics Package Collision Detection Jobs ITriggerEventsJob Component Lookups Entity Query Entity Command Buffer Playback Policy Description ------------------------------------------------------------------------------------------------------------------ Welcome to our Unity DOTS Masterclass series! In this episode, we dive deep into implementing a game over system for our tower defense game using Unity's Data-Oriented Technology Stack (DOTS). Learn how to leverage ITriggerEventsJob to handle collisions, manage game over conditions, and ensure smooth gameplay. Follow along as we walk you through the implementation step-by-step, providing valuable insights into component lookups, entity systems, entity queries, entity command buffers and more. Plus, get a sneak peek at upcoming topics and ways to support our channel. Don't miss out on this essential tutorial for Unity developers! Like, share, and subscribe for more expert insights into game development with Unity DOTS.

WAYN Games

12 days ago

Hello everyone, today we'll do the game over system for our tower defense game, and we'll use the ITriggerEventsJob that is exposed by the physics package, that will allow us to introduce the notion of jobs and multithreading. For that job we'll also need to introduce the concept of component lookups to allow us to access entities that are not iterated through. And we'll also create another system using entity queries and an entity command buffer and we'll dive a bit deeper into the playback pol
icies for that entity command buffer. As usual, you can get the full code for this tutorial in the repository linked in the description below. And you can import that repository into your project and have something like this. Once you've imported the fifth folder game over, system or game over folder You can see that you can open in the scene the game over scene. And if we look at the scene quickly, we have the spawner that we already implemented in the third episode of the series. So if you hav
en't been there yet, you can go back to watch the video for that particular feature. Then we have just a simple floor which is nothing particular, just a simple mesh for decoration. And we have a player, and the player is represented by this, wooden house. And we will have, on this player, a sphere collider, and a simple authoring component, giving the number of lives, the player will have. We can notice that the physics collider is on the player layer, so if you don't see that player layer her
e, it's because it's one of the custom, named layers that I created. And you can go to the add layer to add them, like we did for the previous episode. So here I've added to the project all the layers that we will use, so. 25 players, 26 projectiles, 27 enemies, 28 paths, and the three layers that we already defined in the previous episode. Okay, so if I enter play mode, now what should happen is that I have the spawner that will spawn a few enemies that we can see here. And once they have reach
ed the player, they will be destroyed. And when the player has no longer any life, I'm disabling every entity enemies and you can see that they are still there, but they are just disabled. Basically, that's our game over, and I'm also logging the game over in the console. Let's take a look at the scripts we have. So, We have first the player authoring, very simple, we just have a life count of five in the editor and we bake that into a simple player component, which stores the life count. Very
simple. Next, what we have also is the component for the life that we just seen and a tag component to indicate that the game is over. So we will spawn that component once the life count of our player has reached zero. The prefabs, so, the prefab we have here is the enemy. As you can see, we have the components that we have already used in the previous episodes to make it follow some path, and we have a sphere collider, and a rigidbody. So, here, the rigidbody is very important, Because if you r
emove the rigidbody and enter playmode again, you will see that the event will no longer be triggered. So the trigger event job that we will see in a few minutes will not be triggered. So my enemies are just bouncing back because the behavior I've designed is for them to loop back through the path. And I'm no longer having any gameover notification. And if I look at the entities of the player, the life count is no longer decreasing. If you don't have at least one rigidbody in your scene, all the
physics events will not be triggered because the physics system assumes that nothing is moving, so there shouldn't be any event to be triggered. You really need to keep the rigidbody on the prefab component for the enemy that's moving. The scenes I will skip over and the system. We have two systems. The first system is the entity player collision system. So that's where we will implement the ITriggerEventsJob. So let's take a look at it. This one, we will make it run in the fixed update system.
And we will update it after the physics system group. Because we need the trigger events. To be set by the physics system group. We need to run after that one. Actually, we could replace these two, Attributes by a single attribute because there is a system that is called after physics system group that basically does the same thing. So I could do exactly that and it would work exactly the same. So let's leave it like this for now. Then we have component lookups and buffer lookups. So these two.
Elements that I'm using in the system allows the system to access any entity data, so it doesn't have to be part of the query, I just need to have the entity reference and I can look up the component, particular component for the given entity reference. So here I'm having one component lookup for the player, and one buffer lookup, so this one looks up for the dynamic buffer waypoints, so this allows me to know that it's an enemy, and this allows me to know that it's a player. In the onCreate,
I'm still using the same as before the require for update, because I require a few singletons, a simulation for the physics, and two command buffers, one for initialization command buffer, and one for begin simulation command buffer. We'll see what, which one we use for what a bit later. Then this declaration above for the buffer lookup, and component lookup are just declarations, so they are variables that need to be assigned. I am as assigning them through the system API get buffer lookup or
get component lookup. And in parameter we can pass in if we are only wanting to read from that component lookup or if we actually want to write to the component that we will look up. So for the waypoints, I'm setting it to read only true, and for the player, since I will want to decrease the life counts, I need to write to it, so it's false. And if you don't specify it by default, as you can see, it's read only false. Meaning that by default, you will access the component lookup in a read write
manner. So you, you need to be careful about what you want to actually do. And I find it more explicit to always specify the value so that it's clear that we are not in read only and that we are in readonly Next, in the update, as for the previous episodes, we get our singletons and create our command buffers. And we have this part where we need to update the lookup. So the lookup is just a reference to the set of entities that have that component. And since that set of entity can change throug
h structural change, as we've seen in previous video, we need to update, continuously that lookup before using it. So every time we do an update, we need to call on the lookup the update method, passing in the reference, to the state of the system. Next, we are, diving into the job system, so if you are not familiar with the job system, all we did until now is single threaded and main threaded, meaning that there is a main thread that, Execute the player loop and all our code has been executed o
n that main player loop we can look at the analysis and profiler. And as I've hinted in a previous video, we can, in the profiler, see all of our jobs. So we have the main thread. So that's the execution of our player loop. And then we have a job section, where we can see all the workers we have access to. So basically, it's a job. A thread that we can execute code onto. And we can see here that some of the code is already executed in a multithreaded fashion. For instance, this drawBinCollector,
which is a job that probably is defined by Unity itself, is executed on the worker number 4, so on the fourth thread. And it's actually working over 30 threads, so it's multithreaded. It's not only threaded. So, when I will refer to main thread, I will mean this thread. When I will refer to threaded, I will mean that it's executing on one worker thread. And when I'm referring to multi threaded, it means that it's executing on several worker threads. If we look again at the job, we can see that
this job is scheduled. So that means that this will be run on a single worker thread. So it will be threaded, not multi threaded. The way we declare that is that we create a Jobstruct, so it will implement different interface. So in this case, we will view the ITriggerEventsJob interface, which is provided by the physics package. There are other kinds of jobs, and I actually have a video for jobs that are not related to ECS, if you want to have a look at it, and it goes into more detail about th
e dependency system and safety system that goes with the job . system from Unity that makes it simpler to do multi threaded code rather than the usual C sharp threading back to our system. So we declare a new struct that is our job and we pass in all the information that the job will need. So as we can see here, we are passing in the Lookup for the enemies, the lookup for the player, and our two, command buffers. Take note here that whatever you pass in the job system is a value type and not a
reference type. That means that if you were to pass in an int, for instance, an integer, and you want to increment that integer in the job and log it back after the job, you will not get the result of the sum. Or the operation you did in the job because it's a value type. There is a difference between a value type and a reference type. All structs are value type, meaning that when you pass it to a method, you are not actually passing the same area of memory, but you are passing a copy of the dat
a that was in that area of memory. If it was a class, it's a reference type. And in that case, you are basically copying a pointer to that particular area of memory. So you are using exactly the same, data and same exact area of memory. That's why also we have native containers. Because even though Native containers are value types, because they are structs. What they hold is actually a pointer, so you are not actually having the data inside the struct, you have the data inside the area of memo
ry somewhere, and that struct has the pointer to that area of memory. So when you are passing in a native container, you are copying the content of the native container, which is the address or the pointer to the memory where your data actually lives. So that's why the native containers can be used in jobs to modify data. Let's look at the job itself now. So as we've said, we are using the two lookups and the two common buffers in the execute method for the ITriggerEventsJob. That's not the case
for every job. The execute method for the ITriggerEventsJob gives us a TriggerEvent which is populated by the physics system. And this one contains a pair of entities, so entity A and B, but it's not very descriptive of which entity is which, so the first thing we need to actually do is to figure out which entity is the enemies and which entity are the players. So that's what we will see in this method a bit below. Once we have figured which is the player and which is the enemy, We actually ne
ed to check if, we are interested in that particular pair, because we could have, trigger that. Concerns a player and something else, or an enemy and something else. In that case, we need to filter out the event because it's not processed by this system, by this job. So, let's assume that we have an interaction between a player and an enemy. And in that case, what I will do is use the player's lookup here, just like an array, and say, okay, for my player, in my player lookup, I'm looking for the
player component because that's what is defined as the lookup component in the component lookup. And I'm looking for the player component of the player entity that I have identified. This here is an entity. Then I'm simply decreasing the life count by one and reassigning back through the lookup. The player component I just modified. Then I'm actually destroying my enemy, and same thing here, the enemy is the entity Enemy that I've identified through this method. Last thing I'm doing is checking
the life count. So if my life count is above zero, I'm fine. My player is still alive, but if my player is dead, meaning there is no more life on the player component. I'm actually creating a new entity with the ECB BIS initialization system. So I'm using a different system than the destroy system. You can look back at the previous videos to understand exactly why I'm doing this in two different systems. And, creating a new entity with the component GameOver. So this will create a tag component
or tag entity that allows me to know that the game is over, the player has lost. Let's look back at the IdentifyEntityPair method which is defined here. So we get in the trigger event and what we do is first we declare two entities, the player and the enemy. We declare them as entity null, meaning it's not a valid entity, it's nothing. And we check on the player's component lookup, which is declared as part of the job members. We check, does the entity A that is provided in the trigger event, S
o this entity, does it have the component player? If it does, it means that's a player. Same thing with the entity B. If the entity B has the component player, it means that it's a player. So we populate the player entity reference to the entity that has a player component, and we do the same thing with the enemies, with the enemies buffer lookup this time, and we check if it has that buffer, then it means it's an enemy. And as a return of this method, we give back the tuple player and enemy ent
ities that we've defined. So here, if none of the entities are the player, the player will remain entity null. Same thing, if none of the entities are the enemy, the enemy entity will remain null. And if we add an interaction between two enemies, for instance, The player enemy would remain new and the enemy's entity would be either entity A or entity B in that case entity B. So now we can, as a next step, check if we should process this particular tuple of entities. We do that in this method sho
uld process, and here we are checking that none of the entity are null because if one of the two entity is null, it means that it's not the pair that we care about based on this definition. So, very simple logic, but it's a logic that we need to do for every ITriggerEventsJob to make sure that we are processing the correct entity pairs. This will give us the logic to decrease the player life once an enemy reaches the player collider. Now, we've seen that when the player has 0 health, we declare
a component that is a game over component. And in the behavior we've seen in the play mode, we see that we disabled all the entities and we logged the game over in the console. This is done by another system, which is called the game over cleanup system. And for this one, I'm using a different syntax, let's say, and I'm using What we call an entity query. So up until now, what we've been using is the system API query to get the component we want to iterate over. And that is fine, but in some cas
es we need to have a different syntax and actually first build a query and then use that query to get the component we want or to iterate over the list of entities. That matches that query. So here I'm using the system API query builder to build a query. That says I want all entities that match and have the, GameOver component, and then I'm just building the query. I can also make it so that the system will only run if there is at least one entity that matches that query by using the requireForU
pdate and passing in the query, kind of like we did here in the onCreate saying, okay, I requireForUpdate that particular singleton. Here I'm just using a query syntax or a query to do the same thing. And actually this one is a leftover that does not exist. Next, in the onUpdate, what I want to do is first log that the game is over. And this will be logged only if the GameOver entity exists, because I have this RequireForUpdate query. So it will only update the system and create that log if I've
reached the player with 0 health. And then in an entity command buffer, so this one is created locally, so it's not using one of the systems defined by, by unity. I'm just creating a local entity command buffer and I will, Play it back manually also. So to create the command buffer, I need to provide an allocator. like we've seen in previous episodes for the native array or native list. we have the temp allocator, which is the fastest allocator we can get. And it does not require to be dispose
d of because it only lives for the time of the update. But you could use other allocator and I can specify a playback policy. So in the playback policy, we have two options, either single playback or multi playback. So my entity command buffer will be , played back with the ecb. playback method and this method with a single playback can only be called once. So if I were to duplicate that line and do it again, it would throw an exception. But if I add the multiple playback, it would be okay wit
h replaying the command buffer and, disabling again the entity, because that's actually what I'm doing as part of this system. I'm iterating over all the entities that have the waypoints, so that means all my enemies and actually my spawner itself, so that's how I disable my spawner and avoid having more enemies. And, I'm using the WithEntity access to get the entity corresponding to the one I'm iterating on. Then, in the EntityCommandBuffer, I'm just adding a DisabledComponent, and the disabled
component is a Unity component that, similar to the Prefab component, will make the entity ignored by all of the query systems, meaning that an entity with a disabled tag component will not be updated. Again, there are options, like for the prefab, to also update if you want the disabled entities. And last thing, like I said, since I'm using the temp allocator, I don't need to dispose of the entity command buffer, but if you were to create one with the temp job, for instance, you would need to
deallocate the entity command buffer. Okay, so, so far we have seen today the job system and the component lookups. We have seen a bit of physics overlap in the previous episode. We have seen how to spawn entities in the third episode and we have seen in the second episode how to move an entity along a path or to follow a target, a position. So now what I'm challenging you to do for next week is to start implementing the next step of the tutorial which will be to implement the damage system or
the shoot and kill mechanic. So basically, , implement, , the towers so that they shoot a projectile towards an enemy and damage it. So if you can do that for next week, it would be a good exercise for you to take in the knowledge I shared with you, , in these few episodes. And in the next episode, I will show you how I'm doing it using the things we've seen in the previous episode, but also introducing. A blob assets that we can use for managing a bit better our memory some, visual effects. I
will introduce also the notion of aspects and we will use other kinds of jobs also to do the mechanics also so we can introduce new, knowledge in addition to doing similar features that we have already implemented. Thank you for watching. Don't forget to like and share the video if you like the content. You can also support me on Ko fi. Or you can follow the link in the description below to the Asset Store, which is an affiliate link. And if you find something that interests you, like the list
of DOS related assets that I've compiled on the Asset Store, you can purchase one of the assets that interest you and it will support the channel at no additional cost to you. So, thank you for your support again, and see you in the next video.

Comments