Getting Started with Unity DOTS

A beginner-friendly guide to Unity DOTS in 2020—learn ECS, Jobs and Burst.

Last updated on July 27, 2020. Created on January 20, 2020.

Image via Offbeat Oregon History.

Introduction

In this Unity DOTS guide, we'll shamelessly recreate a "feature" from the The Oregon Trail where our players can get dysentery and die for educational purposes only. The goal of this tutorial is to teach you DOTS, not to create a game—yet. So don't worry, at the end of this tutorial there are a variety of resources provided to help you complete a game. Rome wasn't built in a day.

What is DOTS? It stands for Data-Oriented Technology Stack, comprised of three ingredients:

  1. Unity's Entity Component System (ECS), mapping entities to sets of data called components that can be queried and processed by systems.
  2. Jobs, for creating multi-threaded code with built-in thread safety guarantees by default.
  3. The Burst compiler, which converts 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 GitHub repository instead:

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

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

Entities & 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 type.
  2. It can be used in Burst-compiled 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.

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

Through systems!

Systems

Generally you'll want your systems to extend the SystemBase, which supports multi-threaded processing and automated job dependency management. Less common now is extension of the JobComponentSystem, which is much more error prone since it requires manual dependency management. Even far less common is extension of the ComponentSystem, which is restricted to the main thread—there are few use cases for either of the latter two system types anymore.

Thus, let's start by extending the SystemBase:

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

    protected override void OnDestroy() // Optional.
    {
        // Stuff you want to happen on the main thread when the system terminates.
    }

    protected override void OnUpdate() // Required.
    {
        // Main thread operations here.

        Entities
            .ForEach((ref Player player) =>
            {
                // Multi-threaded operations here.
            })
            .WithName("PlayerJob")
            .ScheduleParallel();

        // Potentially more main thread operations here.
    }
}

The Entities.ForEach convention is syntactic sugar for:

  1. Querying entities with certain components.
  2. Defining a multi-threaded job.

For a while it was common to create an EntityQuery and pass it to a job, which was long and tedious work. You even had to use a [BurstCompile] annotation above the job definition, but this syntactic sugar eliminates the need for that. It's implied you opt for Burst compilation unless you call WithoutBurst() before scheduling the job.

And what's ScheduleParallel() doing, anyway? It automatically chunks the entities queried in our multi-threaded job. This means we want the job to batch entities so it doesn't try to process too many within the same frame, leading to lag. Back in the old days, chunking was all manual, but not anymore.

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

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

You might have noticed 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 performed during parallel-executing jobs. We'll name this reference barrier. What's that mean? Well, commands executed with the memory barrier do so 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 appropriate system type.

Now, here's our glorious job system called DysenterySystem:

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

    protected override void OnUpdate()
    {
        var currentSeconds = (float)Time.ElapsedTime;
        var commandBuffer = barrier.CreateCommandBuffer().AsParallelWriter();

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

Note 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 shortly. There's also a command buffer. ToConcurrent is called on the command buffer since the concurrent flavor is required when operating in parallel jobs, defined 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.DeathSecondsPoint, is equal to or exceeds Dysentery.DeathSecondsDuration. Here's our dysentery-contracting job:

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

        commandBuffer.AddComponent(entityInQueryIndex, entity, new Dysentery
        {
            DeathSecondsPoint = currentSeconds
        });
    })
    .WithName("DysenteryContractJob")
    .ScheduleParallel();

barrier.AddJobHandleForProducer(Dependency);

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 the job handle to the memory barrier via AddJobHandleForProducer. How? With the current Dependency inherited from SystemBase. Each time a job is added to a SystemBase, the Dependency is updated automatically, but we still have to inform memory barriers and sometimes other systems about it.

Another note: you should not use Debug.Log in Burst-compiled jobs, so one way to deal with that is to call WithoutBurst() before scheduling the job.

Onto the death-by-dysentery job:

Entities // For death-by-dysentery.
    .WithAny<Player>()
    .ForEach((Entity entity, int entityInQueryIndex, in Dysentery dysentery) =>
    {
        if (currentSeconds - dysentery.DeathSecondsPoint < dysentery.DeathSecondsDuration) return;
        commandBuffer.DestroyEntity(entityInQueryIndex, entity);
    })
    .WithName("DysenteryDeathJob")
    .ScheduleParallel();

barrier.AddJobHandleForProducer(deathJob);

Here's a couple more new things to note:

  • WithAny lets us massage our query by fetching entities that have any component listed within the brackets. Keep in mind there's also WithAll, meaning all of the provided components must be there, and WithNone, meaning none of the components may exist on a given entity.
  • Again, we don't have to chain jobs by passing job dependencies around unlike the JobComponentSystem, which is swiftly falling out of favor. Here, jobs should be defined in the order they should run, and the SystemBase updates the built-in Dependency for you.

Whew. We did it—kind of.

Remember, our original goal was not to make a game, but rather to recreate a feature to acquaint you with DOTS.

To make a game, you have to understand how to spawn entities. Further, 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... ailment?

Well then, have I got more tutorials for you:

And all of these tutorials have working demos in my GitHub repo. Technically it's a Unity package monorepo compatible with Unity Package Manager and published to OpenUPM, since it includes a DOTS navigation package, a generic DOTS spawning package, and a helper package for randomization. You can use these to build your game quicker!

Advanced Topics

We skipped some important concepts, such as the many implementations of the NativeContainer; allocating memory to and disposing it from said containers; EntityQuery usage; system update order; combining job dependencies; etc. You will pick up these concepts as you go. That's why, in addition to my other tutorials, I would recommend you read Unity's docs. After all, I'm just some random guy on the Internet. I have no idea why you trust me.

Anyway, for a more advanced source of demos, strongly consider perusing Unity's official DOTS samples on GitHub called EntityComponentSystemSamples.

And here are even more advanced and official demos for you to sink your teeth into:

Hope this helps.

© Reese Schultz

My code is released under the MIT license.