The Megacity Metro sample from Unity which uses some DOTS features
From the new e-book: DOTS and the Entity Component System
Hi everybody,
A few weeks ago the Technical CMM team released a new e-book Introduction to the Data-Oriented Technology Stack for advanced Unity developers. This is Unity’s first all-inclusive guide to DOTS. Our goal with this e-book is to help experienced Unity game developers make an informed decision about whether implementing some or all of the DOTS packages and technologies is the right decision for their existing or upcoming Unity project.
A major reason to use DOTS is to get the most performance from your target hardware, and this requires understanding multithreading and memory allocation. Additionally, to leverage DOTS, you’ll need to architect your data-oriented code and projects differently to your C#-based Monobehaviour projects with their higher level of abstraction.
This post includes a section from the e-book that explains the role each feature of the stack plays in enhancing a game’s execution speed and efficiency.
DOTS 1.0 is production-ready and Unity 6 is in preview so now could be a good time to learn about what DOTS features can provide for your next game.
The Entity Component System and DOTS features
Unity’s Entity Component System (ECS) is the data-oriented architecture that underpins DOTS packages and technologies. ECS delivers a high level of control and determinism over data in memory and runtime process scheduling in Unity.
ECS for Unity 2022 LTS comes with two compatible physics engines, a high-level Netcode package, and a rendering framework to render large amounts of ECS data to Unity’s Scriptable Render Pipelines (SRP), including the Universal Render Pipeline (URP) and the High Definition Render Pipeline (HDRP). It’s compatible with GameObject data, allowing you to leverage systems that as of Unity 2022 LTS, do not natively support ECS, such as animation, navigation, input, or terrain.
Let’s look at the features and packages that make stack.
The C# job system
The C# job system provides an easy and efficient way to write multithreaded code that helps your application take advantage of all available CPU cores.
Unlike the other features of DOTS, the job system is not a package but rather is included in the Unity core module.
A profile showing Burst-compiled jobs utilizing the potential of the CPU and running across many worker threads
Because MonoBehaviour updates are executed only on the main thread, many Unity games end up running all of their game logic on just one CPU core. To take advantage of additional cores, you could manually spawn and manage additional threads, but doing so safely and efficiently can be very difficult.
For an easier alternative, Unity provides the C# job system:
- The job system maintains a pool of worker threads, one for each additional core of the target platform. For example, when Unity runs on eight cores, it creates one main thread and seven worker threads.
- The worker threads execute units of work called jobs. When a worker thread is idle, it pulls the next available job from the job queue to execute.
- Once a job starts execution on a worker thread, it runs to completion (in other words, jobs are not preempted).
// A simple example job that multiplies the
// elements of two arrays.
// Implementing IJob makes this struct a job type.
struct MyJob : IJob
{
// A NativeArray is “unmanaged”, meaning it
// isn’t garbage collected.
public NativeArray<float> Input;
public NativeArray<float> Output;
// The Execute method is called when the
// job system executes this job.
public void Execute()
{
// Multiply every value in Output by the
// corresponding value in the Input array.
for (int i = 0; i < Input.Length; i++)
{
Output[i] *= Input[i];
}
}
}
Scheduling and completing jobs
- Jobs can only be scheduled (meaning, added to the job queue) from the main thread, not from other jobs.
- When the main thread calls the Complete() method on a scheduled job, it waits for the job to finish execution (if it hasn’t finished already).
- Only the main thread can call Complete().
- After Complete() returns, you can be sure that the data used by the job is once again safe to access on the main thread and safe to be passed into subsequently scheduled jobs.
Job safety checks and dependencies
In multithreaded programming, ensuring safety and managing dependencies between threads are critical for avoiding race conditions, data corruption, and other concurrency issues. It’s beyond the scope of this guide to explain these pitfalls. The key takeaway is to understand how the job system handles safety checks and dependencies:
- For guaranteed isolation, each job has its own private data that the main thread and other jobs can’t access.
- However, jobs may also need to share data with each other or the main thread. Jobs that share the same data should not execute concurrently because this creates race conditions. So the job system “safety checks” throw errors when you schedule jobs that might conflict with others.
- When scheduling a job, you can declare that it depends upon prior scheduled jobs. The worker threads will not start executing a job until all of its dependencies have finished execution, allowing you to safely schedule jobs that would otherwise conflict.
- For example, if jobs A and B both access the same array, you could make job B depend upon job A. This ensures job B will not execute until job A has finished, thus avoiding any possible conflict.
- Completing a job also completes all of the jobs it depends upon, directly and indirectly.
Many Unity features internally use the job system, so you will see more than just your own scheduled jobs running on the worker threads in the Profiler.
Note that jobs are intended only for processing data in memory, not performing I/O (input and output) operations, such as reading and writing files or sending and receiving data over a network connection. Because some I/O operations may block the calling thread, performing them in a job would defeat the goal of trying to maximize utilization of the CPU cores. If you want to do multithreaded I/O work, you should call asynchronous APIs from the main thread or use conventional C# multithreading.
To learn about jobs, start with the jobs tutorial in the samples repo (there is also a version on Unity Learn).
The Burst compiler
As stated earlier, C# code in Unity is by default compiled with Mono, a JIT (just-in-time) compiler or, alternatively with IL2CPP, an AOT (ahead of time) compiler which generally gives better runtime performance and may be better supported on some target platforms.
The Burst package provides a third compiler that performs substantial optimizations, often yielding dramatically better performance than Mono or even IL2CPP. Using Burst can greatly improve the performance and scalability of a heavy computation problem, as the following images illustrate:
Top image: From the jobs tutorial, the FindNearest updates, compiled with Mono, take 342.9 ms. Bottom image: From the same jobs tutorial, the FindNearestJob, compiled with Burst, takes 1.4 ms.
Understand, however, that Burst can only compile a subset of C#, so a lot of typical C# code can’t be compiled with it. The main limitation is that Burst-compiled code can’t access managed objects, including all class instances. As this excludes most conventional C# code, Burst compilation is only applied selectively to designated parts of code, such as jobs:
// A Burst-compiled version of the previous example job.
// The BurstCompile attribute marks this job to be Burst-compiled.
[BurstCompile]
struct MyJob : IJob
{
public NativeArray<float> Input;
public NativeArray<float> Output;
public void Execute()
{
for (int i = 0; i < Input.Length; i++)
{
Output[i] *= Input[i];
}
}
}
As described in this video, the performance gains of Burst come from the use of SIMD (a technique used to perform the same operation on multiple data elements simultaneously) and better awareness of aliasing (when two or more pointers or references refer to the same memory location), among other techniques.
For expert users, Burst provides a few advanced features, such as intrinsics and the Burst Inspector (pictured above), which shows the generated assembly code.
Collections
The Collections package provides unmanaged collection types, such as lists and hash maps which are optimized for usage in jobs and Burst-compiled code.
By “unmanaged”, it’s meant that these collections are not managed by the C# runtime or garbage collector; you are responsible for explicitly deallocating any unmanaged collection that you create by calling its Dispose() method once it’s no longer needed.
Because these collections are unmanaged, they don’t create garbage collection pressure, and can be safely used in jobs and Burst-compiled code.
The collection types fall into a few categories:
- The types whose names start with Native will perform safety checks. These safety checks will throw an error:
- if the collection is not properly disposed of.
- if the collection is used with jobs in a way that isn’t thread-safe.
- The types whose names start with Unsafe perform no safety checks.
- The remaining types which are neither Native or Unsafe are small struct types with no pointers, so they are not allocated at all. Consequently, they need no disposal and have no potential thread-safety issues.
Several Native types have Unsafe equivalents. For example, there is both NativeList and UnsafeList, and both NativeHashMap and UnsafeHashMap, among other pairs. For the sake of safety, you should prefer using the Native collections over the Unsafe equivalents when you can.
Mathematics
The Mathematics package is a C# math library that, similar to Collections, is created for Burst and the job system to be able to compile C#/IL code into highly efficient native code. It provides you with:
- Vector and matrix types, such as float3, quaternion, float3x3
- Many math methods and operators that follow HLSL-like shader conventions
- Special Burst compiler optimization hooks for many methods and operators
See this Unity.Mathematics cheat sheet for more information.
Note that most types and methods of the old UnityEngine.Mathf library are usable in Burst-compiled code, but the Unity.Mathematics equivalents will perform better in some cases.
Entities (ECS)
The Entities package provides an implementation of ECS, an architectural pattern composed of entities and components for data and systems for code.
In short, an entity is composed of components, where each component is usually a C# struct. Like with GameObjects, an entity’s components can be added and removed over its lifetime.
Unlike with GameObjects, an entity’s components do not usually have their own methods. Instead, in ECS, each “system” has an update method that is invoked usually once per frame, and these updates will read and modify the components of some entities. For example, a game with monsters might have a MonsterMoveSystem whose update method modifies the Transform components of every monster entity.
Archetypes
In Unity’s ECS, all entities with the same set of component types are stored together in the same “archetype”. For example, say you have three component types: A, B, and C. Each unique combination of component types is a separate archetype, e.g.:
- All entities with component types A, B, and C, are stored together in one archetype.
- All entities with component types A and B are stored together in a second archetype.
- All entities with component types A and C are stored in a third archetype.
Adding a component to an entity or removing a component from an entity moves the entity to a different archetype.
In Unity’s ECS, all entities with the same set of component types are stored together in the same “archetype”.
Chunks
Within an archetype, the entities and their components are stored in blocks of memory called chunks. Each chunk stores up to 128 entities, and the components of each type are stored in their own array within the chunk. For example, in the archetype for entities having component types A and B, each chunk will store three arrays:
- One array for the entity ID’s
- A second array for the A components
- And a third array for the B components
The ID and components of the first entity in a chunk are stored at index 0 of these arrays, the second entity at index 1, the third entity at index 2, and so on.
How chunks work in Unity’s ECS architecture
A chunk’s arrays are always kept tightly packed:
- When a new entity is added to the chunk, it’s stored in the first free index of the arrays.
- When an entity is removed from the chunk, the last entity in the chunk is moved to fill in the gap (An entity is removed from a chunk when it’s being destroyed or moved to another archetype.)
Queries
A primary benefit of the archetype- and chunk-based data layout is that it allows for efficient querying and iteration of the entities.
To loop through all entities having a certain set of component types, an entity query first finds all archetypes matching that criteria, and then it iterates through the entities in the archetypes’ chunks:
- Since the components in the chunks reside in tightly packed arrays, looping through the component values largely avoids cache misses.
- Since the set of archetypes tends to remain stable throughout most of a program, the set of archetypes matching a query can usually be cached to make the queries even faster.
// A simple example system.
public partial struct MonsterMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Query that loops through all entities with
// a LocalTransform, Velocity, and Monster component
foreach (var (transform, velocity) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<Velocity>>()
.WithAll<Monster>())
{
// Update the transform position from the
// velocity (factoring in delta time)
transform.ValueRW.Position +=
velocity.ValueRO.Value * SystemAPI.Time.deltaTime;
}
}
}
Job system integration
As long as entity component types are unmanaged, they can be accessed in Burst-compiled jobs. Two special job types are provided for accessing entities: IJobChunk and IJobEntity.
// A simple example system that schedules an IJobEntity.
public partial struct MonsterMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Create and schedule the job.
var job = new MonsterMoveJob {
DeltaTime = SystemAPI.Time.DeltaTime
};
job.ScheduleParallel();
}
}
// A Burst-compiled job that processes every entity that has
// a LocalTransform, Velocity, and Monster component.
[WithAll(typeof(Monster))]
[BurstCompile]
public partial struct MonsterMoveJob : IJobEntity
{
public float DeltaTime;
// Because we wish to modify the LocalTransform, we use ‘ref’.
// We only wish to read the Velocity, so we use ‘in’.
public void Execute(ref LocalTransform, in Velocity)
{
transform.Position += velocity.Value * DeltaTime;
}
}
For ease of use, systems can automatically handle job dependencies and job completion across systems.
Subscenes and baking
Unity ECS uses subscenes instead of scenes to manage the content of your application. This is because Unity’s core scene system is incompatible with ECS.
While entities can’t be directly included in Unity scenes, a feature called baking allows for loading entities from scenes and converts the GameObjects and MonoBehaviour components into entities and ECS components.
You can think of subscenes as scenes that are nested inside others and are processed by baking, which re-runs every time you edit a subscene. For every GameObject in a subscene, baking creates an entity, the entities get serialized into a file, and it’s these entities that are loaded at runtime when the subscene is loaded, not the GameObjects themselves.
Left: inspecting a GameObject and right: inspecting an entity that was baked from the GameObject
Which components get added to the baked entities is determined by the “bakers” associated with the GameObject components. For example, bakers associated with the standard graphics components, like MeshRenderer, will add graphics-related components to the entity. For your own MonoBehaviour types, you can define bakers to control what additional components get added to the baked entities.
// This entity component type represents an energy shield with hit points,
// maximum hit points, recharge delay, and recharge rate.
public struct EnergyShield : IComponentData
{
public int HitPoints;
public int MaxHitPoints;
public float RechargeDelay;
public float RechargeRate;
}
// A simple example authoring component.
// An authoring component is just an ordinary MonoBehaviour
// that has a defined Baker class.
public class EnergyShieldAuthoring : MonoBehaviour
{
public int MaxHitPoints;
public float RechargeDelay;
public float RechargeRate;
// The baker for our EnergyShield authoring component.
// This baker is run once for every EnergyShieldAuthoring
// instance that's attached to any GameObject in a subscene.
class Baker : Baker<EnergyShieldAuthoring>
{
public override void Bake(EnergyShieldAuthoring authoring)
{
// The TransformUsageFlags specify which
// transform components the entity should have.
// The None flag means that it doesn't need transforms.
var entity = GetEntity(TransformUsageFlags.None);
// This simple baker adds just one component to the entity.
AddComponent(entity, new EnergyShield
{
HitPoints = authoring.MaxHitPoints,
MaxHitPoints = authoring.MaxHitPoints,
RechargeDelay = authoring.RechargeDelay,
RechargeRate = authoring.RechargeRate,
});
}
}
}
On the one hand, it’s inconvenient in simple cases to not be able to add entities directly in scenes, but on the other hand, the baking process can be useful in more advanced cases. Baking effectively separates authoring data (the GameObjects that you edit in the Editor) from runtime data (the baked entities), so what you directly edit and what gets loaded at runtime don’t have to match 1-to-1. For example, you could write code to procedurally generate data during baking, which would spare you from paying the cost at runtime.
Streaming
Particularly for large detailed environments, it’s important to be able to load and unload many elements efficiently and asynchronously as the player or camera moves around the environment. In a large open world, for example, many elements must be loaded in as they come into view, and many elements must be unloaded as they go out of view. This technique is also referred to as streaming.
Entities are far more suited for streaming than GameObjects because entities consume less memory and processing overhead, and they can be serialized and deserialized much more efficiently.
Learn more
We hope you find this post useful and that you’ll download the DOTS e-book. You can also go straight to the The EntityComponentSystemSamples Github repo, which includes many samples that introduce both basic and advanced DOTS features. The readme file for each sample collection provides further details but here’s a brief description of some selected samples.







