About Me 👈

Getting Started with Unity DOTS

Learn Unity DOTS—ECS, Jobs, and Burst—by reimplementing an infamous feature from The Oregon Trail in five minutes.

unitycsharptutorial

Created on January 20, 2020. Last updated on January 30, 2020.

Introduction

There are too many outdated, overcomplicated, and frankly misleading resources on Unity DOTS. You want to make multi-threaded games, but you can't find a single reliable, understandable, and idiomatic resource to learn how. If that's the case, then let's begin. But know that, by reading this guide, you are now complicit in shamelessly creating a "feature" where our players can get dysentery and die.

Screenshot via Offbeat Oregon History.
Citing this image is the high point of my video game career.

...Uh, anyway, DOTS stands for Data-Oriented Technology Stack. It consists of three ingredients:

  1. Unity's Entity Component System (ECS), mapping entities to sets of data called components that are queried and processed by systems.
  2. Unity's C# Job System, for writing multi-threaded code as "jobs" with built-in thread safety guarantees by default.
  3. The Burst compiler, for converting job bytecode into native (machine) code to drastically improve performance.

But Wait!

Before we get ahead of ourselves, if you don't feel like manually configuring a Unity project to follow along, clone my DOTS-ready demo repo instead:

git clone https://github.com/reeseschultz/ReeseUnityDemos.git

I've already ensured all the DOTS-related packages are compatible. There's example scenes and code in there too, supporting my other tutorials and articles. Go ahead, delete and rename stuff at your leisure. Now let's get back to it.

Entities & Their Components

Entities are glorified IDs. Each entity may map to multiple components. While there are other types, here we'll focus on IComponentData since 1) it's the most common, and 2) it can be used in Burst-compilable jobs. Here's an example:

struct Player : IComponentData
{
    public float Hygiene;
}

Each entity with a Player component would have its own Player data, hence IComponentData. Here we're naively framing dysentery as a function of Hygiene for demonstration. You know, personal cleanliness. We'll assume it's a floating point number ranging from zero to one.

Systems

Great. Now, how do we process entities and their components?

Through systems! There are two major types:

  1. ComponentSystem, for single-threaded processing (meaning it's restricted to the main thread).
  2. JobComponentSystem, for multi-threaded processing.

Here's an example ComponentSystem:

class PlayerSystem : ComponentSystem
{
    protected override void OnCreate() // Optional.
    {
        // Stuff you want to happen when the system starts.
    }

    protected override void OnDestroy() // Optional.
    {
        // Stuff you want to happen when the system ends.
    }

    protected override void OnUpdate() // Required.
    {
        Entities
            .ForEach((ref Player player) =>
            {
                // Process the player!
            });
    }
}

Note that we only process entities that have a Player component, hence ref Player player. That is one way for us to query and mutate the entities we want from a system (but not the only way). Now, rarely will you ever use the ComponentSystem. Most of the time you'll want the JobComponentSystem instead (it's multi-threaded!):

class PlayerSystem : JobComponentSystem
{
    // May have the same optional OnCreate and OnDestroy methods.

    protected override JobHandle OnUpdate(JobHandle inputDeps) // Required.
    {
        return Entities
            .ForEach((ref Player player) =>
            {
                // Process the player!
            })
            .WithName("PlayerJob")
            .Schedule(inputDeps);
    }
}

No longer a void method, we're expected to return a JobHandle. The Entities.ForEach convention remains the same, but we additionally call WithName, passing the name of the job for the convenience of possible debugging. Finally, Schedule is called to, well, schedule the job, resulting in a JobHandle. To it we pass the inputDeps, or "input dependencies," a combination of prior job handles managed by Unity.

Now, what if we want to process multiple jobs in one system? Let's tackle that with a less trivial example, but first, how about another component:

struct Dysentery : IComponentData
{
    public float ContractedSeconds; // The *point* in time when the player contracted dysentery.
    public float DeathSeconds; // The *duration* it takes for the player to die from dysentery.
}

You might have noticed that components are ideal for applying temporal status effects and modifiers to game characters. In order to take advantage of this, we have to understand how to add and remove components and entities within jobs at runtime. The concept we have to digest is the memory barrier, and while it's not easy, fortunately Unity does most of the heavy-lifting for us.

Orchestrating Jobs & Memory Barriers

We'll need a reference to an EntityCommandBufferSystem, which queues up commands (operations) performed during parallel-executing jobs. We'll name this reference barrier. What's that mean? Well, operations performed with the memory barrier execute deterministically, so they're played back in order to ensure thread safety. Pretty much any time you add or remove entities and components, you'll want such a barrier. Normally we only want its commands played back at the end of a frame, so we'll opt for the built-in system that accomplishes that.

Here's our glorious job system called DysenterySystem:

class DysenterySystem : JobComponentSystem
{
    EntityCommandBufferSystem barrier => World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var currentSeconds = (float)Time.ElapsedTime;

        var contractCommandBuffer = barrier.CreateCommandBuffer().ToConcurrent();

        # TODO : Schedule job for contracting dysentery.

        var deathCommandBuffer = barrier.CreateCommandBuffer().ToConcurrent();

        # TODO : Schedule job for death-by-dysentery.

        # TODO : Return a JobHandle.
    }
}

Note that things like Time.ElapsedTime must be retrieved from the main thread. That's why we set a variable, currentSeconds, in the main thread first, and then we will use that variable in the scope of a different thread. There's also two separate command buffers, one for a dysentery-contracting job, and another for death-by-dysentery. ToCurrent is called on each command buffer since the concurrent flavor is required when operating in parallel jobs, which we will define momentarily.

But before we implement our jobs, here's our "business" logic: players can contract dysentery if their Hygiene is less than 0.6. Then they can die of dysentery if the duration they've had it, Dysentery.ContractedSeconds, is equal to or exceeds Dysentery.DeathSeconds. Sound good? No? Too bad. Here's our dysentery-contracting job:

var contractJob = Entities // For contracting dysentery.
    .ForEach((Entity entity, int entityInQueryIndex, in Player player) =>
    {
        if (player.Hygiene >= 0.6) return;

        contractCommandBuffer.AddComponent(entityInQueryIndex, entity, new Dysentery
        {
            ContractedSeconds = currentSeconds
        });
    })
    .WithName("DysenteryContractJob")
    .Schedule(inputDeps);

barrier.AddJobHandleForProducer(contractJob);

So there's some new stuff we have to familiarize ourselves with:

  • The entityInQueryIndex is magic, being that it's automatically set for you. What is it? It's an entity's index in a given query.
  • The in keyword qualifies a queried component to be read-only, such as in Player player. We only use ref when we intend on writing data to a given component without a command buffer.
  • We needed the entityInQueryIndex so we could pass it to the command buffer for buffering any commands, and specifically here we pass it to AddComponent.
  • Any time a command buffer is used in a job to write data, we need to add said job to the memory barrier via AddJobHandleForProducer.
  • The Burst compiler is used by default! But the Burst compiler can only be used in certain conditions, so occasionally you have to turn it off using WithoutBurst. For instance, if you try to do something unsafe like modifying a public static member from a job, then Burst will inform you that it doesn't love you anymore. Likewise, you're not supposed to call Debug.Log from a Burst-compiled job.

Onto the death-by-dysentery job:

var deathJob = Entities // For death-by-dysentery.
    .WithAny<Player>()
    .ForEach((Entity entity, int entityInQueryIndex, in Dysentery dysentery) =>
    {
        if (currentSeconds - dysentery.ContractedSeconds < dysentery.DeathSeconds) return;

        deathCommandBuffer.DestroyEntity(entityInQueryIndex, entity);
    })
    .WithName("DysenteryDeathJob")
    .Schedule(contractJob);

barrier.AddJobHandleForProducer(deathJob);

return deathJob;

Here's a couple more new things we need to understand:

  • WithAny lets us massage our query by fetching entities that have any component listed within the brackets. Keep in mind that there's also WithAll, meaning all of the provided components must be there, and WithNone, meaning that none of the components may exist on a given entity.
  • When we schedule multiple jobs in the same system, we can chain them by passing the last job's handle to the next job's Schedule call.

Whew. We did it—kind of.

You might be wondering, how do we spawn entities, or players, to start with? How do we point and click on the meshes of entities to extract information, like hygiene? How do we generate random numbers to make dysentery more probabalistic, or chance-based? How can we launch entities around due to their explosive diarrhea?

Well then, have I got more tutorials for you:

And all of these tutorials have working demos in my GitHub repo, which also has drag-and-droppable DOTS navigation code featuring auto-jumping agents and movable surfaces. There's even a user guide for said navigation code!

Advanced Topics

We skipped some important concepts, such as the many implementations of the NativeContainer; allocating memory to and disposing it from said containers; the manual EntityQuery; system update order; combining job dependencies; etc. You will grock these concepts as you go. That's why, in addition to my other tutorials, I would recommend that you get the information straight from the horse's mouth. That is, Unity's docs. After all, I'm just some guy telling you stuff, probably lies, in no official capacity.

To expand on the lambda functions we're using, you know, the Entities.ForEach syntax, read these specific docs. It's actually syntactic sugar! Yep, creating jobs used to require so much more code and pain. And you don't even have to experience it. What a shame.

Finally, for a more advanced source of demos, strongly consider perusing Unity's official DOTS samples on GitHub called EntityComponentSystemSamples. After you get through that, here are even more advanced and official demos for you to sink your teeth into:

Have fun.