2
\$\begingroup\$

I'm trying to figure out how to implement a simple ECS with an Archetype approach, similar to what Unity's ECS uses.

An archetype is just a category for entities that have a specific set of components.

Let's say I had three components, A, B, and C, and I had the following systems:

  • System 1 (Archetype requires component A)
  • System 2 (Archetype requires components A and B)
  • System 3 (Archetype requires components A and C)
  • System 4 (Archetype requires components A, B, and C)

Because components could be added and removed from entities on the fly, an entity switches archetypes whenever a component is added or removed.

I'm not quite sure what these archetype collections are supposed to look like. They must be arrays of some kind, but I'm not sure how to store and organize components in them that belong to a specific entity.

My question is, what would this look like in pure, vanilla C# code?

And could generics also help with this?

\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

Those Archetypes are IEnumerable<TComponentKind>, where TComponentKind is what you use to identify components. You can, of course, do better than that. For example, they could be TComponentKind[], or HashSet<TComponentKind>. TComponentKind could be string, Type or even int (and you use bit flags to identify components, in which case the Archetype is also int). Or you might come with something else. That depends on how you implement your ECS.


I have a toy ECS that should give you some idea of how to go about this: https://github.com/theraot/Theraot.ECS - You may download the code an play with it. Please read design decisions on the readme. After all, you are going to have to make your own take on those decisions for your custom ECS.

My toy ECS has the particular quirk that it lets you provide an implementation for custom TComponentKind. Which is likely not something you want in a ECS you use in production. But it means that the code is rather agnostic to that decision, which - I hope - makes for a good case study.

\$\endgroup\$
3
\$\begingroup\$

Here is a sketch of how you can implement an archetype in C#:

class Archetype {
    System.Type[] _componentTypes;
    System.Array[] _components;

    int _capacity;
    public int Count { get; private set; }

    public Archetype(int capacity, params System.Type[] componentTypes) {

        _capacity = capacity;    
        _componentTypes = componentTypes;

        _components = new System.Array[componentTypes.Length];
        for( int i = 0; i < componentTypes.Length; i++) {
            _components[i] = System.Array.CreateInstance(componentTypes[i], _capacity);
        }
    }

    public T[] GetComponentsArray<T>() {
        int index = System.Array.IndexOf(_componentTypes, typeof(T));
        if (index < 0) return null;
        return (T[])_components[index];
    }
}

Note that adding and deleting entities or disabling them is elided from the sketch above - the chat thread I wrote it for has more discussion of the pros and cons of different strategies to use there.

So if I want to create an archetype for entities that have a Transform, SpriteRenderer, and AudioSource component, with room for 200 such entities at a time, I'd write:

var noisySprites = new Archetype(
                          200,
                          typeof(Transform),
                          typeof(SpriteRenderer),
                          typeof(AudioSource)
                   );

And it will create the necessary internal arrays. Here we're representing your elements in SoA or Structure of Arrays layout, so there's no one patch of memory that corresponds to the entity as a whole. Instead, each entity is an index. Entity #0 has its Transform component at noisySprites.GetComponentsArray<Transform>()[0], and its SpriteRenderer component at noisySprites.GetComponentsArray<SpriteRenderer>()[0], etc.

This might look strange if you're used to AoS or Array of Structures layout, but this approach can actually give you significant performance wins. If your components are all struct types, then the instances of each component type will be laid out contiguously in memory. So if you have a system that cares about only 2 of the 3 components in a given archetype, it can iterate over a densely-packed array of each of those types, without having to skip over every third item. Every bit of data that's pulled into cache gets used, so you make optimal use of the caching and prefetching mechanisms to hide memory latency.

A system in this form might look something like this:

public class SpriteRenderingSystem : System {

    // On start-up, query for the kinds of archetypes we need to work on.
    // This way any search/indexing can be done up-front and cached.
    public void Initialize(ArchetypeCollection collection) {
        _signature = collection.GetComponentSignature(
                        typeof(Transform),
                        typeof(SpriteRenderer)
                     );
    }

    public void Update(ArchetypeCollection collection) {
        // Iterate over each archetype matching the 
        // Transform + SpriteRenderer component signature.
        foreach (var archetype in collection.GetArchetypesWithSignature(_signature)) {
            // Get the packed arrays of components we care about.
            Transform[] transforms = archetype.GetComponentsArray<Transform>();
            SpriteRenderer[] sprites = archetype.GetComponentsArray<Sprite>();

            // Iterate over all active entity indices and do our work.
            for (int entity = 0; entity < archetype.Count; entity++) {
                RenderSprite(transforms[entity], sprites[entity]);
            }
        }
    }    
}

Note that we pay any search costs and cache misses for getting the right arrays just once per archetype that we want to iterate over, and then all memory access for each entity is linear and contiguous from there (again, assuming your components are struct types), so you get efficient iteration for the things you're doing hundreds or thousands of times, and the overhead in the archetype handling is more manageable since you likely have just dozens of those, or a small handful that any one system needs to act on.

\$\endgroup\$
3
  • \$\begingroup\$ I am responding here because I am not able to comment on the answer I deleted. To answer your question, I deleted my answer because when I benchmarked it against your approach, your approach was consistently much faster. In my view, that meant there was no value in either my approach or my answer--especially because mine relied on unsafe code. Best to not lead anyone down the wrong path. \$\endgroup\$
    – dpaz
    Commented May 5, 2022 at 17:59
  • \$\begingroup\$ Ah, fair enough. Thanks for doing the benchmarking! I'm glad this approach seems to have merit! \$\endgroup\$
    – DMGregory
    Commented May 5, 2022 at 18:30
  • \$\begingroup\$ It does! The problem with my method was my attempt to lock all of the unsafe code within the storage class. The compiler was unable to recognize we were accessing data sequentially, so yours was about 5-10x faster. I can make up that difference and pull ahead by about 30% in these particular benchmarks, but not without letting unsafe code leak into every system. I'd rather not. Anyway, cheers. \$\endgroup\$
    – dpaz
    Commented May 6, 2022 at 2:42

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .