Creating the deck: an introduction to ECS systems, part 3
With our 54 card entities created, now we need a way to group them together to form the game's dungeon deck. In the same spirit as the previous lectures, we create a system named DeckCreationSystem to do so, which is defined as follows:
This system introduces several new concepts:
Jobified systems: Note that this system derives from JobComponentSystem rather than ComponentSystem. The ECS is not only a conceptual paradigm shift in the way we write and structure our code; it is also a performance enabler. By writing code this way, we automatically make it so that it can be more easily multithreaded across the different cores of our machine. Similarly to ComponentSystem, we have an OnUpdate method where we perform the system's work; only this time, we do it in a multithreaded fashion rather than on the main thread.
The body of the OnUpdate method is where the real magic happens; we schedule three custom jobs which will run one after another:
- AddCardsToDeckJob: this job adds the cards to the deck.
- SortDeckJob: this job sorts the cards in the deck.
- StartGameJob: this job creates an entity with the sole purpose of signalling the start of the game to any interested system after the deck has been created and sorted.
These jobs use a helper struct implementing the IJob interface that performs the actual work in a multithreaded way. The BurstCompile attribute is responsible for optimizing the code inside the Execute method by means of auto-vectorization, optimized scheduling and better use of instruction-level parallelism to reduce data dependencies and stalls in the pipeline. The Burst compiler only accepts a limited, high-performance-oriented subset of C#; this is the reason we pass the random seed from the main thread.
Note the use of a system barrier via the barrier variable. When using jobs, you must request command buffers from a barrier on the main thread, and pass them to the jobs. When the BarrierSystem updates, the command buffers playback on the main thread in the order they were created. This extra step is required so that memory management can be centralized and determinism of the generated entities and components can be guaranteed. A command buffer is represented by the EntityCommandBuffer type. This type is not yet compatible with Burst; that is why the StartGameJob job does not use it.
The deck sorting is performed using the well-known Fisher-Yates shuffle algorithm, which is particularly convenient for card-based games.
Note how we chain the three jobs so that the deck sorting only runs after the deck has been constructed and the game starts only after the deck has been sorted.
We use the retrievedDeck boolean to make sure we only add the 54 cards to the deck the first time the system runs; otherwise, we would keep adding them every frame ad infinitum.
Systems run inside groups, and Unity defines two groups by default: the simulation group (for logic) and the presentation group (for rendering). We use the UpdateInGroup attribute used to indicate this system runs in the simulation group. Also, we apply the UpdateAfter attribute to the class to explicitly signal to the ECS that this system is intended to run after the card creation system. While, generally speaking, the ECS will automatically figure out a correct order based on every system's dependencies, it is still useful to use the attribute for documentation purposes.
The start game job is particularly interesting because it showcases how we can use ECS entities as events: the job creates an event-like entity, which will be picked up by a different system. This entity has the following component attached to it:
The Amount field indicates the number of cards to be dealt from the deck (in this case, when the game starts). This component will be processed by a different system that we will cover later.
Native collections: We store the card entities in a native list, which is a native collection provided by the ECS. Native collections provide a safe way for jobs to access some data from the main thread directly, without needing a copy. We initialize it with our deck size (54 cards) and using the persistent memory allocator, because we need the deck's data to persist for the entire lifetime of the game. We use the OnCreate and OnDestroy entry points to initialize and dispose the list (note that you always need to explicitly dispose a native collection; should you forget to do so, you will get a runtime exception informing you of the resulting memory leak). You can find the official documentation for native collections here.
Entity queries: This is the first system we cover that has an entity query. More specifically, the system takes all the entities with an associated CardData component and adds them to the deck. The way to express this dependency is via the query entity query, which you can think of as the definition of the "filters" the system is interested in. In this case, we use ComponentType.ReadOnly<CardData> to select all the entities that contain a CardData component. As we do not intend to modify this component, we mark the component type as read-only, which may unlock additional performance benefits from within the ECS. Note how we always create entity queries in the system's OnCreate method.
Inside the system's OnUpdate method, we can access the group's entities via the ToEntityArray method. Note it is important to dispose the resulting array once we are done with it, which in this case is performed automatically once the job that uses it is finished via the convenient DeallocateOnJobCompletion attribute.
We will use entity queries extensively throughout the course.