The safest way to use DOTS in your MonoBehaviour project

Before I begin with the article, I would like to let you know I’m giving away a free game by subscribing to my mailing list. With that out of the way, let’s get it on!

The problem is that if you want to leverage the speed of Burst, you have to use a subset of C# called High Performance C# (HPC#) where you can’t use reference types. Classes are reference types which means any C# feature that internally use them are out. Coroutines, delegates, events, out! Unity API that are classes are out so you can’t use MonoBehaviour. This also means that OOP as we know it is also out. You can only use structs and blittable types. The structs that you use should only be composed of blittable types or other structs that satisfy this requirement. Think of HPC# like C but without pointers while also having additional features like generics, properties, access modifiers etc. Aside from HPC#, Unity also provided a special library of containers (think System.Collections) that can be used to contain these HPC# legal types. The most commonly used are NativeArray, NativeList, NativeHashMap, and NativeMultiHashMap.

When I used DOTS for Academia: School Simulator back in 2018, it was already a one year project using MonoBehaviours and OOP. The code was already huge. It’s impossible at that point to turn the project into pure ECS. It would take a whole rewrite and that was untenable as the game was already released in Early Access. However, DOTS (HPC#) can still be used in a OOP heavy project. I’ll show you what I think is the cleanest and safest way.

Just Make Copies

Since we can’t use reference types, the most logical thing to do if want to harness Burst is to copy relevant data into HPC# legal types and use this copy to pass unto Burst compiled code. The key word is “relevant”. You don’t need deep copies of your humongous classes or components. You only copy what’s relevant to what you’re processing in Burst.

Let’s start with a contrived example. Say you have a list of Thing which are huge managed objects. Every frame, the Thing’s a, b, and c variables are used in a heavy calculation. Maybe something like this:

private List<Thing> things;

private void Update() {
    for (int i = 0; i < this.things.Count; ++i) {
        Thing thing = this.things[i];
        thing.computedValue = Mathf.Sqrt(thing.a) * Mathf.Sin(thing.b) * Mathf.Cos(thing.c);
    }
}

Let’s also say that this list of Things could get to thousands. You’d want to run the computation as fast as it can be. So you turn to Burst. First you create the HPC# legal struct that will hold the values needed in the computation:

private struct ThingCopy {
    public readonly float a;
    public readonly float b;
    public readonly float c;

    public float computedValue;

    public ThingCopy(float a, float b, float c) {
        this.a = a;
        this.b = b;
        this.c = c;
        this.computedValue = 0;
    }
}

Then we write the Burst compiled job that will execute the computation. It accepts a NativeArray<ThingCopy>:

[BurstCompile]
private struct ComputeJob : IJobFor {
    [NativeDisableParallelForRestriction]
    public NativeArray<ThingCopy> things;
    
    public void Execute(int index) {
        ThingCopy thing = this.things[index];
        thing.computedValue = math.sqrt(thing.a) * math.sin(thing.b) * math.cos(thing.c);
        this.things[index] = thing; // Modify
    }
}

The [NativeDisableParallelForRestriction] is needed so that the job can run in parallel. It’s safe to do so here since for every index, we are only writing to the element at that index. We don’t write to any other element.

Then we use these on our Update() method:

private void Update() {
    // Container of copies
    NativeArray<ThingCopy> copies = new NativeArray<ThingCopy>(this.things.Count, Allocator.TempJob);
    CopyThingsTo(ref copies);

    // Do heavy computation in a job
    ComputeJob job = new ComputeJob() {
        things = copies
    };
    job.ScheduleParallel(copies.Length, 64, default).Complete();

    AssignComputedValues(copies);
    
    // Don't forget to dispose
    copies.Dispose();
}

private void CopyThingsTo(ref NativeArray<ThingCopy> copiesArray) {
    for (int i = 0; i < this.things.Count; ++i) {
        Thing thing = this.things[i];
        copiesArray[i] = new ThingCopy(thing.a, thing.b, thing.c);
    }
}

private void AssignComputedValues(in NativeArray<ThingCopy> copiesArray) {
    for (int i = 0; i < this.things.Count; ++i) {
        this.things[i].computedValue = copiesArray[i].computedValue;
    }
}

The new code is not that hard to read. First, we create the NativeArray for the copies. We use the method CopyThingsTo() to copy the variables a, b, and c to ThingCopy. Then we schedule the ComputeJob passing the array of copies. We also call Complete() on the job right away so we can assign back the computed values by calling AssignComputedValues(). The job zooms through this array of copies and executes them in multiple threads, too. This is would be way faster than the original implementation.

An example of copying Transforms

Here’s a real world example. In our 2D rendering library, we have a system that applies the transformation of an object’s Transform to our custom sprite. We are using a Burst compiled job to do this so it would run significantly faster. We can’t directly reference Transform but we can copy its translation, rotation, and scale.

We are using GameObjectEntity in our setup since our game elements are originally GameObjects with lots of components. What this does is it automatically creates an Entity that is associated with the GameObject. This allows us to use TransformAccessArray and IJobParallelForTransform to iterate through the Transform instances that can be Burst compiled. (I don’t recommend using GameObjectEntity, though, as it is planned to be deprecated. We used it when it came out and still works for us today, thus the continued usage. The copying example is relevant, though.)

So first, we have a struct that will hold the copy:

/// <summary>
/// Used for temporarily copying values from Transform or TransformAccess
/// </summary>
public struct TransformStash {
    public quaternion rotation;
    public float3 position;
    public float3 localScale;
}

This is used to copy the value of position, scale and rotation of a Transform. Then we have this common job:

[BurstCompile]
public struct StashTransformsJob : IJobParallelForTransform {
    public NativeArray<TransformStash> stashes;

    public void Execute(int index, TransformAccess transform) {
        this.stashes[index] = new TransformStash {
            position = transform.position,
            localScale = transform.localScale,
            rotation = transform.rotation, 
        };
    }
}

This is a commonly used job that merely copies the transform values from TransformAccess to TransformStash. The next code is a system that makes use of this.

public class TransformGameObjectSpriteVerticesSystem : JobComponentSystem {
    private EntityQuery query;

    protected override void OnCreate() {
        // All entities with Sprite and Transform, but without Static (non Static sprites)
        this.query = GetEntityQuery(typeof(Sprite), typeof(Transform), ComponentType.Exclude<Static>());
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps) {
        TransformAccessArray transforms = this.query.GetTransformAccessArray();
        NativeArray<TransformStash> stashes = new NativeArray<TransformStash>(transforms.length, Allocator.TempJob);
        
        // Job for copying to stashes
        StashTransformsJob stashTransforms = new StashTransformsJob() {
            stashes = stashes
        };
        JobHandle stashHandle = stashTransforms.Schedule(transforms, inputDeps);
        
        // Job for applying to sprites
        ApplyTransformsJob applyTransformsJob = new ApplyTransformsJob() {
            spriteType = GetComponentTypeHandle<Sprite>(),
            stashes = stashes
        };

        return applyTransformsJob.ScheduleParallel(this.query, 1, stashHandle);
    }

    [BurstCompile]
    private struct ApplyTransformsJob : IJobEntityBatchWithIndex {
        public ComponentTypeHandle<Sprite> spriteType;
    
        [ReadOnly]
        [DeallocateOnJobCompletion]
        public NativeArray<TransformStash> stashes;

        public void Execute(ArchetypeChunk batchInChunk, int batchIndex, int indexOfFirstEntityInQuery) {
            NativeArray<Sprite> sprites = batchInChunk.GetNativeArray(this.spriteType);

            for (int i = 0; i < batchInChunk.Count; ++i) {
                Sprite sprite = sprites[i];
                int index = indexOfFirstEntityInQuery + i;
                
                TransformStash stash = this.stashes[index];
                float4x4 rotationTranslationMatrix = new float4x4(stash.rotation, stash.position);
                float4x4 scaleMatrix = float4x4.Scale(stash.localScale);
                float4x4 finalMatrix = math.mul(rotationTranslationMatrix, scaleMatrix);
                sprite.Transform(finalMatrix);
                
                // Modify
                sprites[i] = sprite;
            }
        }
    }
}

It is the system that transforms our custom Sprite but the transform values are coming from GameObjects (from Transform component). The meat is on OnUpdate(). We first create the NativeArray of TransformStash. This will hold the transform values when processed by StashTransformsJob which does the copying. StashTransformsJob is scheduled then after that the job ApplyTransformsJob is then chained scheduled.

ApplyTransformsJob uses the same NativeArray<TransformStash> which now holds the values copied from Transform instances. We can easily index into this array using “indexOfFirstEntityInQuery + i”. Now that we have access to the Transform’s position, scale, and rotation, we do the rest of the math which is to prepare the matrices, multiply them and pass to Sprite.Transform(). That’s it. We copied values from Transform instances into an HPC# struct that can be used in a Burst compiled job. The concept is the same from the previous example only that we have help from IJobParallelForTransform which makes the copying also run Burst compiled and in parallel.

Maintained Copies

The examples so far show creating the array of copies right before the processing. This might not be the best in some times. There may be cases where the values that you need don’t really change all the time. Making copies every time would be a complete waste. One way to mitigate this is to maintain the copies along with the original items. The copies can be stored in a persistent native collection (by passing Allocator.Persistent) or be maintained in ECS. Yes, as in entities and components. This is what I did when I replaced our OOP A* with Burst compiled A*. Our Tile class is already too big to be ported to HPC#. I maintained a copy of the tiles instead containing only the data that is relevant to path finding. Whenever a Tile instance is modified, I also do the same to the HPC# tile equivalent.

Maintaining the copies is easy by using an observer pattern. Whenever the original item is created, modified, or destroyed, you also do that to your copy. Maintaining the copies in ECS is great here because it’s already like a database that you can access in any system. The Entity already acts like an ID that you can associate to the original item. Creating a copy of an item is just creating an entity with the component that will hold the copied values. When the original item is destroyed, you can just destroy the entity copy. There’s no need to maintain a container. An added benefit of maintaining your copies in ECS is you now have access to DOTS features like Entities.ForEach() and IJobEntityBatch. You can now easily create Burst compiled jobs that act on the copied data.

That’s all I have for now.

If you like my posts, please subscribe to my mailing list and be among the first to know what I’m up to. I’ll also send you a free game upon subscription. 🙂

One thought on “The safest way to use DOTS in your MonoBehaviour project

  1. Dealing with struct is a huge pain in HPC#, you have to write the data back every time(a different behavior than class), and it was a source of pain in our development.

    HPC# does have pointers, the problem with current HPC# is that is extremely difficult to develop a complex data structure or behavior without running into using pointers, and the there is not a lot of tools and safety check we can use(smart pointers, data race prevention). What Unity.Collections provides is pretty underwhelming, due to their runtime safety checks, AtomicSafetyhandle and DisposalSentinel.

    It is really often for us that we need to make a copy of our data for preprocessing due to synchronization issue, unfortunately, most of the game object stuffs are designed to be single-threaded. I see the point why copying is necessary here, but I failed to see the point at the end to maintain a copy for anything else does not related to Unity’s old and single threaded stuff. It seems far too much work just to keep a reference of that data.

    Maybe I am looking at this from a biased perspective, but over all, I do not think there is any methodology to make development in DOTS safe, well, not as safe as a language like Rust could provide, and I have just established a workflow for doing that, and It has showed more potential than what Unity’s DOTS could provides.

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s