The different lists allowed in IComponentData

Unity’s ECS is very different in that it requires a subset of C# if you want to take advantage of the Burst compiler. This subset was termed High Performance C# (HPC#). For the components part of ECS, only structs are allowed that are composed of blittable types. You can’t have reference types like classes.

public struct Character : IComponentData {
    public string name; // This is not allowed. string is a class.
}

This implies that you can’t use the containers from System.Collections which most of us Unity programmers are familiar with. Let me show you how to have lists that can be used with/inside components in 4 different ways.

DynamicBuffer

DynamicBuffer is like a list of things that is associated with an entity. It doesn’t technically live inside a component, but it can be used as if a component owns such list. Let’s say we are modeling a character with an inventory of items. Using the DynamicBuffer would look like this:

[TestFixture]
[Category("CoffeeBrain")]
public class ListAsDynamicBufferTest : ECSTestsFixture {
    private struct Character : IComponentData {
        public FixedString64 name;

        public Character(FixedString64 name) {
            this.name = name;
        }
    }

    private struct ItemElement : IBufferElementData {
        public readonly FixedString64 id;
        public int count;
        
        public ItemElement(FixedString64 id) {
            this.id = id;
            this.count = 1;
        }

        public ItemElement(FixedString64 id, int count) {
            this.id = id;
            this.count = count;
        }
    }

    [DisableAutoCreation]
    private class TraverseItemsSystem : SystemBase {
        protected override void OnUpdate() {
            this.Entities.ForEach(delegate(ref Character character, ref DynamicBuffer<ItemElement> items) {
                Debug.Log($"{character.name}'s Inventory: ");

                for (int i = 0; i < items.Length; ++i) {
                    ItemElement item = items[i];
                    Debug.Log($"{item.id} ({item.count})");
                }
            }).WithoutBurst().Run();
        }
    }

    [Test]
    public void UsageTest() {
        EntityManager entityManager = this.World.EntityManager;
        
        // Create an entity character with some items
        Entity entity = entityManager.CreateEntity(typeof(Character), typeof(ItemElement));
        entityManager.SetComponentData(entity, new Character("Marnel"));
        
        // Add some items
        DynamicBuffer<ItemElement> items = entityManager.GetBuffer<ItemElement>(entity);
        items.Add(new ItemElement("Sword"));
        items.Add(new ItemElement("Shield"));
        items.Add(new ItemElement("Coins", 50));
        items.Add(new ItemElement("Potion", 3));
        
        // Let system run
        this.World.GetOrCreateSystem<TraverseItemsSystem>().Update();
    }
}

Note that I’m using the test framework here just to show a quick usage. To use a DynamicBuffer as a list, the item to manage must implement the interface IBufferElementData. ItemElement is such item here. As been said, DynamicBuffer is associated with an entity. We add the type of ItemElement as an archetype when creating the character entity. In TraverseItemsSystem, we can get access to the list by including it in the ForEach() query. This results to the following in the test runner:

The drawback here is that since we can only specify one DynamicBuffer per type, multiple components can’t own their own list of such type. By continuing our character example, let’s say the game requires to manage another list of items called “broken” items. We can’t add another DynamicBuffer for this new list. There’s a workaround by using a different entity for the list instead then the character has the entity value that maps to the DynamicBuffer. It just adds another indirection and complexity.

However, DynamicBuffer is the most recommended solution out of the four here. If you just need a list of something, DynamicBuffer should probably be your first go-to solution.

FixedListX

Some versions ago, Unity introduced these Fixed* containers under the Unity.Collections package. One of these are the FixedListX types. X here is the amount of bytes that the list can hold. There’s FixedList32, FixedList64, FixedList128, FixedList512, and FixedList4096. The capacity of the list would be determined by the byte size of the specified type. For example, if you use FixedList128<int>, the max capacity would be 31. An int contains 4 bytes so (128 – 4) / 4 = 31. We deduct 4 from 128 because these bytes are reserved to hold the count of the list.

This is how it is used:

// From hereon, this will be the item that we will maintain
public struct Item {
    public readonly FixedString64 id;
    public int count;
        
    public Item(FixedString64 id) {
        this.id = id;
        this.count = 1;
    }

    public Item(FixedString64 id, int count) {
        this.id = id;
        this.count = count;
    }
}
[TestFixture]
[Category("CoffeeBrain")]
public class FixedListTest : ECSTestsFixture {
    private struct Character : IComponentData {
        public FixedString64 name;
        public FixedList512<Item> items;

        public Character(FixedString64 name) {
            this.name = name;
            this.items = new FixedList512<Item>();
        }
    }

    [DisableAutoCreation]
    private class TraverseItemsSystem : SystemBase {
        protected override void OnUpdate() {
            this.Entities.ForEach(delegate(in Character character) {
                Debug.Log($"{character.name}'s Inventory: ");

                for (int i = 0; i < character.items.Length; ++i) {
                    Item item = character.items[i];
                    Debug.Log($"{item.id} ({item.count})");
                }
            }).WithoutBurst().Run();
        }
    }

    [Test]
    public void UsageTest() {
        EntityManager entityManager = this.World.EntityManager;
        
        // Create an entity character with some items
        Entity entity = entityManager.CreateEntity(typeof(Character));

        // Prepare character
        Character character = new Character("Marnel");
        character.items.Add(new Item("Sword"));
        character.items.Add(new Item("Shield"));
        character.items.Add(new Item("Coins", 50));
        character.items.Add(new Item("Potion", 3));
        
        entityManager.SetComponentData(entity, character);
        
        // Let system run
        this.World.GetOrCreateSystem<TraverseItemsSystem>().Update();
    }
}

The main difference here is that the list of items as FixedList512<Item> is a member variable of the Character component itself. It’s part of the component and not of the entity. This implies that it can be used inside other components and they, too, can have their own list of Item instances. The query in Entities.ForEach() is simplified as the list is already contained inside the Character component.

The drawback is that the max length of the list varies depending on the type. You have to be careful that the number of items do not exceed this length. If you add some more variables to the type then the max length would be reduced. This is not very evident and you may forget this.

SmallBuffer

I got this technique from Jackson Dunstan when FixedList was not yet a thing. The idea is you generate a struct that will represent the list with a fixed amount of items implemented as member variables. For example, you can represent IntList5 as:

[StructLayout(LayoutKind.Sequential)]
public struct IntList5 {
    private readonly int m_Element0;
    private readonly int m_Element1;
    private readonly int m_Element2;
    private readonly int m_Element3;
    private readonly int m_Element4;
}

With some code magic, you can access the elements using integer indexes like you would in an array or list.

Following our inventory example, let’s say the inventory only has a maximum of 10 items. So we generate this list as a struct called ItemList10. In the linked article, Jackson provided the generator. This is what I generated using the tool (I stripped off IEnumerable implementation here):

[StructLayout(LayoutKind.Sequential)]
public unsafe struct ItemList10 {
    private readonly Item m_Element0;
    private readonly Item m_Element1;
    private readonly Item m_Element2;
    private readonly Item m_Element3;
    private readonly Item m_Element4;
    private readonly Item m_Element5;
    private readonly Item m_Element6;
    private readonly Item m_Element7;
    private readonly Item m_Element8;
    private readonly Item m_Element9;

    private int m_Version;

    public ref Item this[int index] {
        get {
            RequireIndexInBounds(index);

            return ref GetElement(index);
        }
    }

    private ref Item GetElement(int index) {
        fixed (Item* elements = &this.m_Element0) {
            return ref elements[index];
        }
    }

    private void SetElement(int index, Item value) {
        fixed (Item* elements = &this.m_Element0) {
            elements[index] = value;
        }
    }

    public int Count { get; private set; }

    public const int Capacity = 10;

    public void Add(Item item) {
        RequireNotFull();
        SetElement(this.Count, item);
        this.Count++;
        this.m_Version++;
    }

    public void Clear() {
        for (int i = 0; i < this.Count; ++i) {
            SetElement(i, default);
        }

        this.Count = 0;
        this.m_Version++;
    }

    public void Insert(int index, Item value) {
        RequireNotFull();
        RequireIndexInBounds(index);
        for (int i = this.Count; i > index; --i) {
            SetElement(i, GetElement(i - 1));
        }

        SetElement(index, value);
        this.Count++;
        this.m_Version++;
    }

    public void RemoveAt(int index) {
        RequireIndexInBounds(index);
        for (int i = index; i < this.Count - 1; ++i) {
            SetElement(i, GetElement(i + 1));
        }

        this.Count--;
        this.m_Version++;
    }

    public void RemoveRange(int index, int count) {
        RequireIndexInBounds(index);
        if (count < 0) {
            throw new ArgumentOutOfRangeException("count", "Count must be positive: " + count);
        }

        RequireIndexInBounds(index + count - 1);
        int indexAfter = index + count;
        int indexEndCopy = indexAfter + count;
        if (indexEndCopy >= this.Count) {
            indexEndCopy = this.Count;
        }

        int numCopies = indexEndCopy - indexAfter;
        for (int i = 0; i < numCopies; ++i) {
            SetElement(index + i, GetElement(index + count + i));
        }

        for (int i = indexAfter; i < this.Count - 1; ++i) {
            SetElement(i, GetElement(i + 1));
        }

        this.Count -= count;
        this.m_Version++;
    }

    [BurstDiscard]
    public void RequireNotFull() {
        if (this.Count == 10) {
            throw new InvalidOperationException("Buffer overflow");
        }
    }

    [BurstDiscard]
    public void RequireIndexInBounds(int index) {
        if (index < 0 || index >= this.Count) {
            throw new InvalidOperationException("Index out of bounds: " + index);
        }
    }
}

This is then how it is used. Mostly, it’s the same with FixedList:

[TestFixture]
[Category("CoffeeBrain")]
public class ListAsSmallBufferTest : ECSTestsFixture {
    private struct Character : IComponentData {
        public FixedString64 name;
        public ItemList10 items;

        public Character(FixedString64 name) {
            this.name = name;
            this.items = new ItemList10();
        }
    }
    
    [DisableAutoCreation]
    private class TraverseItemsSystem : SystemBase {
        protected override void OnUpdate() {
            this.Entities.ForEach(delegate(in Character character) {
                Debug.Log($"{character.name}'s Inventory: ");

                for (int i = 0; i < character.items.Count; ++i) {
                    Item item = character.items[i];
                    Debug.Log($"{item.id} ({item.count})");
                }
            }).WithoutBurst().Run();
        }
    }
    
    [Test]
    public void UsageTest() {
        EntityManager entityManager = this.World.EntityManager;
        
        // Create an entity character with some items
        Entity entity = entityManager.CreateEntity(typeof(Character));

        // Prepare character
        Character character = new Character("Marnel");
        character.items.Add(new Item("Sword"));
        character.items.Add(new Item("Shield"));
        character.items.Add(new Item("Coins", 50));
        character.items.Add(new Item("Potion", 3));
        
        entityManager.SetComponentData(entity, character);
        
        // Let system run
        this.World.GetOrCreateSystem<TraverseItemsSystem>().Update();
    }
}

Like FixedList, the generated ItemList10 struct can be specified under the Character component. The drawback is that the max length is baked in into the struct. You’d have to make another struct type to support another length like ItemList20. While it’s a drawback, it can also be a piece of mind. You are assured of the max length of the list even when the type of the item to manage changes. We’ve used this technique in Academia.

UnsafeList

I just discovered this days ago in the forums. I haven’t really used it yet or know its quirks. I can only speculate. I’ve managed to come up with a simple usage, though. It’s mostly the same:

[TestFixture]
[Category("CoffeeBrain")]
public class UnsafeListTest : ECSTestsFixture {
    private struct Character : IComponentData {
        public FixedString64 name;
        public UnsafeList<Item> items;

        public Character(FixedString64 name) {
            this.name = name;
            this.items = new UnsafeList<Item>(2, Allocator.Persistent);
        }
    }
    
    [DisableAutoCreation]
    private class TraverseItemsSystem : SystemBase {
        protected override void OnUpdate() {
            this.Entities.ForEach(delegate(in Character character) {
                Debug.Log($"{character.name}'s Inventory: ");

                for (int i = 0; i < character.items.Length; ++i) {
                    Item item = character.items[i];
                    Debug.Log($"{item.id} ({item.count})");
                }
            }).WithoutBurst().Run();
        }
    }
    
    [Test]
    public void UsageTest() {
        EntityManager entityManager = this.World.EntityManager;
        
        // Create an entity character with some items
        Entity entity = entityManager.CreateEntity(typeof(Character));

        // Prepare character
        Character character = new Character("Marnel");
        character.items.Add(new Item("Sword"));
        character.items.Add(new Item("Shield"));
        character.items.Add(new Item("Coins", 50));
        character.items.Add(new Item("Potion", 3));
        
        entityManager.SetComponentData(entity, character);
        
        // Let system run
        this.World.GetOrCreateSystem<TraverseItemsSystem>().Update();
        
        // Don't forget to dispose
        character.items.Dispose();
    }
}

The only difference is you have to dispose the list (if using an allocator other than Allocator.Temp) once you’re done with it or when the entity is going to be destroyed. Not doing so would cause a memory leak.

Internally, it is implemented as a raw pointer like how the native collections are made. I surmise that this solution is not cache friendly. What it has though is it’s capacity can grow like how a normal List would. It doesn’t have a fixed length.

Conclusion

I already ordered the precedence of when to use these different techniques. Use DynamicBuffer as the first go-to solution. You might not encounter its drawback in your game at all. In fact, it is rare. If you do encounter the drawback, then FixedList is your next bet. Pick the right byte size for your game. Not too small and not too big either. SmallBuffer, I’d say, would have the same precedence as FixedList. I would use it if the size of the type to manage is odd or if the type is in constant flux that you wouldn’t know the correct size to use for FixedList. I’m really not sure when is the best case to use UnsafeList. I’d guess you use it if you’re maintaining a very dynamic list that changes length dramatically throughout the game and if you’re maintaining a huge number of items that it won’t fit in a single archetype chunk.

That’s all for now.

3 thoughts on “The different lists allowed in IComponentData

  1. Good stuff!

    I guess you could live with a DynamicBuffer for multiple types of items if the ItemElement has an enum to identify itself as “normal, broken, equipped, etc.”

    With UnsafeList and Allocator.Persistent we probably get the lowest performance due to heap allocation and indirection to access that heap memory. Still that’s a minor performance hit if we rarely access the lists anyway.

    I bet FixedList and SmallBuffer are lightning fast and I’d say we only have this minor tradeoff that those two “inlined” types and DynamicBuffer may start to bloat your entities (ECS archetype chunks are limited to 16k, still I think having a few of them for some characters or lots of quest/mission data is not slow at all and tolerable). 😉

    Liked by 1 person

  2. You are forgetting one last “list” type. Well, it isnt really a list, there is no way to modify it once it’s created and it’s readonly. I guess it shouldn’t be in this article but should be mentioned as it’s a very specialised but often overlooked tool.

    BlobAssetReferences and BlobArrays.

    Intended for extremely large immutable arrays, it functions like a readonly UnsafeArray which you can then “safely” reference from multiple entities using BlobAssetReferences inside IComponentDatas and others.

    I believe Unity is intended for mesh data and other large arrays to slowly be converted to this format and reduce the cache combinatoric fracturing using shared component data and placing mesh data in SCDs. Unfortunately, it cant be changed and if it needs modification, it’ll have to be destroyed and recreated. Very expensive.

    I personally use it for storing tens of thousands of color swap data and a singleton with a small dynamic buffer to store temporary changes to that array.

    Otherwise great article. I’ll be keeping this in my reference folder for all things DOTS.

    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