Survival Shooter using Unity’s Entity Component System (revisited)
A few months ago, right after Unity’s new Entity Component System (ECS from now on) was announced and its first preview released at GDC, I decided to write a version of the official Survival Shooter tutorial project using this new system for learning purposes.
The ECS has evolved quite a bit since then, so I decided to spend some of my Christmas time to bring the project up to date with the current state of the art of the library.
So, without further ado, what can we do to improve our original project?
Getting rid of injection
In the original codebase, injection was the main mechanism used to iterate over our entities. At the time, it was the most promoted way to do so, but today it is considered a less-than-ideal design (this Unite LA presentation by Aria Bonczek is an excellent source on why that is the case) and will be removed in the future. These are the main issues with injection:
- It performs a ton of non-obvious work behind the scenes.
- The way in which it does that work is not very performant (mainly due to the internal use of reflection and GC allocations).
Injection was simple and convenient but, in hindsight, the fact alone that your compiler will warn you about the related structs and variables not being used/initialized (because everything happens implicitly) was actually a pretty big red flag in itself API-wise.
The first alternative to letting injection create a component group automagically for us is to just do it directly ourselves. For example, where we used to have this:
We would now have this:
Not particularly bad, is it? We manually create a component group with the GetComponentGroup method, which needs to be called inside OnCreateManager, passing the required component types we are interested in using ComponentType.Create<> (we could use typeof instead, but Create is slightly more efficient and symmetrical with the ReadOnly and Subtractive variants). Inside OnUpdate, we retrieve the component arrays via the GetComponentDataArray (for ECS components) and GetComponentArray (for MonoBehaviour components) methods. We can also get the corresponding entities and game objects via the GetEntityArray and GetGameObjectArray methods. Pretty straightforward!
This actually looks significantly cleaner than injection to me, and opens up the door to additional micro-optimizations such as only retrieving every component array when actually needed (as opposed to every frame). Only thing is, ComponentDataArray has its own share of problems too and is also going to be removed in the future. So, what is the “officially-sanctioned” way of doing things in the ECS world today?
Introducing jobified systems
While we exclusively used systems that run on the main thread via ComponentSystem in the original project (and still need to do so with many systems that interact with classic MonoBehaviour-based components), we can already move some systems that only process ECS entities to jobified systems, which perform their work in a multithreaded way. Specifically, jobified systems using IJobProcessComponentData jobs (and its IJobProcessComponentDataWithEntity companion) are the recommended way to go today for most use cases.
We also have what is known as chunk iteration as the ultimate, most powerful mechanism to iterate over entities, but it is a bit more complex to discuss in the context of an introductory ECS project so we will not use it here (I still recommend you learn about it, as it is the lower-level foundation the rest of the ECS is built upon).
So, how does this type of system look like in practice? Let’s look at a specific example named EnemyHealthSystem, which is responsible for updating the health of enemies that have been hit by the player:
Rather than deriving from ComponentSystem, jobified systems derive from JobComponentSystem instead. Similarly to their main-threaded counterpart, we need to override their OnUpdate method; only this time taking a JobHandle parameter (which you can use to establish dependency chains between different jobs). Inside this method, we schedule a custom job named EnemyHealthJob that derives from IJobProcessComponentDataWithEntity, which performs the actual work of the system.
This job type takes a number of component types that you want to use as your entity “filters”. In this particular example, the job will iterate over enemy entities that are damaged. The maximum number of component types you can specify for the job is currently limited to 4, but more will be added in future versions of the library.
The job’s Execute method takes the entity, the job index and the specified component types as parameters and performs the following logic:
- It resolves the actual damage over the enemy entity by updating its HealthData component.
- It removes the DamagedData component to avoid processing the enemy entity more than once.
- If the enemy’s health reaches a value of 0 or lower, it adds the DeadData component to it to kill the entity. A different system will take care of processing dead enemies.
Note how all this is done via a concurrent EntityCommandBuffer rather than your usual EntityManager (which does not work with jobs). This command buffer is created via an injected system barrier and this is actually one of the very use cases where injection is still required; as far as I know there is no other way to get the barrier without it.
An interesting tidbit is that, in order to prevent adding the DeadData component to dead enemy entities more than once, we check if the entity in question has the component already by means of a ComponentDataFromEntity<DeadData> array, which gets passed into the job via the GetComponentDataFromEntity method.
What is even cooler here is that you could add a [BurstCompile] attribute to the EnemyHealthJob job and your code would be automatically compiled by the Burst compiler, which is a custom compiler built by Unity that accepts a high-performance-oriented subset of C# and makes use of auto-vectorization, optimized scheduling and better use of instruction-level parallelism to reduce data dependencies and stalls in the pipeline in the generated machine code. We cannot really use it in this particular job as of yet, because Burst still does not support jobs that use command buffers (unless you exclusively use them to destroy entities), but we will get there at some point in the near future.
I cannot stress enough how game-changing the whole approach to game development with Unity’s ECS, job system and Burst is. Attaining C++-like levels of performance (with the potential to do even better) without going through the pain of actually using the language? Writing highly-performant code while actually enjoying it at the same time? Well, sign me up!
You can find the complete, updated Survival Shooter project in this GitHub repository. The project requires Unity 2018.3.0f2 and the ECS-specific code lives in the ECS folder.
Even with all these updates, there is still a ton of code in the project that depends on classic, MonoBehaviour-based components (mainly everything related to rendering, physics and animations). While we could certainly write our own custom systems for these areas, in the context of this demo I would like to keep everything using the built-in ECS functionality.
Moving forward, more and more core engine systems will be available to use from the ECS. 2019 has just started and I cannot wait keep on updating our Survival Shooter project as that progress happens.