Storing arbitrary object type per entity

Unity ECS is not all about high performance subset of C# and struct works. Knowing about how it deals with reference types can help you…

Storing arbitrary object type per entity

Unity ECS is not all about high performance subset of C# and struct works. Knowing about how it deals with reference types can help you assemble a complete system where you need it.

Adding just the managed component type without data

When a type is a class, it is considered as managed component by the type manager. Therefore instead of "arbitrary object component type", "unblittable type", "reference type", or "class type", let's use the official wording "managed component".

Take a trip for a bit to these methods on EntityManager :

public bool AddComponent(Entity entity, ComponentType componentType)
public bool AddComponent<T>(Entity entity)
public void AddComponent(EntityQuery entityQuery, ComponentType componentType)

All three versions, even the one with generic T, says nothing about T having to be IComponentData. (Unlike AddComponentData<T>) That means you can add whatever type like LineRenderer! However it is just the type added as component. There is no data...

Adding/getting back managed data

Then, let's talk about these on EntityManager :

public T GetComponentObject<T>(Entity entity)
public void AddComponentObject(Entity entity, object componentData)

Now you can add actual data as object. There is no restriction, I think inside has a check that it must be a class.

You can get it back. There is no constraint at all on the T generic type on the Get. The thing you added is a reference type. You are supposed to update the inside of it from the result of GetComponentObject.

Where is Set ? If you go into the source code you will see SetComponentObject as internal. But actually AddComponentObject will call that anyways. So since adding the same component is a no-op, you may as well keep using AddComponentObject to set a new data.

Secret?

In the source code, I see that if you use AddComponentObject to replace the previous object, the old one will get called .Dispose if it is IDisposable before being replaced.

Singleton

public static T GetSingleton<T>(this EntityQuery query) where T : class, IComponentData
public static void SetSingleton<T>(this EntityQuery query, T value) where T : class, IComponentData

Go read about ECS singleton if you have no idea. Notice the T constraint that is class and IComponentData. These are extension method so you are allowed to easily hook up managed data to your singleton.

Getting managed data back via a query

See these too on the EntityQuery :

public NativeArray<T> ToComponentDataArrayAsync<T>(Allocator allocator, out JobHandle jobhandle) where T : struct, IComponentData
public NativeArray<T> ToComponentDataArray<T>(Allocator allocator) where T : struct, IComponentData
public T[] ToComponentDataArray<T>() where T : class, IComponentData

Note that the first 2 are the usual struct data linearizing.

However the 3rd overload which is named exactly the same (as opposed to something like ToComponentObjectArray), is selected by omitting Allocator argument. It now returns classic C# array instead of NativeArray, capable of holding anything and is reference counted + garbage collected.

Interestingly, there are some constraint on the T this time. You may have heard that IComponentData must be struct. It is not true, here it request that it must be both IComponentData and class. I guess you can just stick IComponentData interface on the type you want to use to make it a managed component. I believe managed component is a subset of component object that we are talking here which we can use any type when we use Add/SetComponentObject. By it being IComponentData seems like it enters a more sophisticated typing system and allowed it to be queried.

If you include Entities.Hybrid package, there is also :

public unsafe static T[] ToComponentArray<T>(this EntityQuery group) where T : Component

public static unsafe T GetComponentObject<T>(this EntityManager entityManager, Entity entity) where T : Component

In that package as an extension method.

The first one seemed to be a proper counterpart of Add/GetComponentObject, now T is Component constrained instead of class, IComponentData.

The second one seemed redundant with GetComponentObject we already have in the main package? Maybe this is getting removed. And that made me think will ToComponentArray also be removed? It looks like the concept of component object and managed component will be made available in the main package. ("Hybrid" is then refer to other GameObject things, but not the typing system.)

VS ISharedComponentData

You can also stick ISharedComponentData to anything and add that as a shared component. Did you achieve just the same thing? But the principle is completely different. It is called "shared" for a reason, because you are storing plus associating that to the chunk. It is not on any Entity. The GetSharedComponentData that take in Entity is an illusion, it ask the chunk of that Entity and get you the component that is being shared on the chunk.

It has chunk partitioning effect, which maybe good as it allows shared component filtering for EntityQuery, or bad because your chunk heavily fragments to how many unique values of shared component data you have. You may want to read up about ISharedComponentData in full here.

Inner workings

Now that you know ISharedComponentData is stored in different concept as component object / managed component. How the component object being stored per Entity then?

Managed components do live in the chunk.. as an integer

By looking at TypeManager.cs at BuildComponentType, the sizeInChunk = sizeof(int); hints that it stores an index as the data per Entity. So it did cost some capacity. No matter if you add Button or CanvasGroup to the Entity as a managed component, they take 4 byte each per entity.

When you use :

public bool AddComponent(Entity entity, ComponentType componentType)
public bool AddComponent<T>(Entity entity)
public void AddComponent(EntityQuery entityQuery, ComponentType componentType)

I think you are storing integer 0 as the data when you add only the type. Then the Entities library assumes 0 as invalid index, no component object data added yet.

ManagedComponentStore

internal object[] m_ManagedComponentData = new object[64];

Yep, things you added with AddComponentObject aren't in any chunk. Deep inside the source code, Unity made this "cheating" array that expands as needed as you add more objects to any Entity. Everything are added to this array, any type, jumbled together. Only integer index that could be use with this array is in the chunk.

This ManagedComponentStore is also the place where it keep all the real deal of ISharedComponentData (the unshared one), and share just the index to it to chunks. But it is not in the same object[], it is in (equally cheating) List<object> nearby.

public T GetComponentObject<T>(Entity entity)
public void AddComponentObject(Entity entity, object componentData)

When you use AddComponentObject, the object will be placed in the m_ManagedComponentData after finding a place for it. Then the index of the array of that object will be returned to store in the chunk. It's like we have our own mini-pointer as a simple int. Integer 1 may point to m_ManagedComponentData[1] which contains my CanvasRenderer. Integer 2 m_ManagedComponentData[2] maybe RigidBody.

Unlike ISharedComponentData, there is no effort to find existing object in that array and attempt to "share" the index. If you add the same object to an another Entity, it simply put that same object in the next slot of m_ManagedComponentData and return the next index number. We now have 2 different index, that do point to different slot in object[], but is actually the same object.

The reason that this is an array instead of List which is used for ISharedComponentData is now clear, since you remember these integers to the magic array inside the chunk, it is impractical to touch it and update all the chunks everywhere that you have already store the indexes. List's removal will ripple move items back and mess with the index. It is better that Unity manages a plain array.