ECS Singleton Data

ECS Singleton Data

You have these API to work with singleton data but how do they really works?

  • system.GetSingleton<T>
  • system.SetSingleton<T>(T data)
  • entityQuery.GetSingleton<T>
  • entityQuery.SetSingleton<T>(T data)

It's a singleton Entity

In the docs it says "singleton data", but actually you are accessing singleton Entity with a component data of the type you want.

The singleton entity is not attached to any system or entity queries as you might have misunderstood from the API. It directly get a single Entity from the World. And yes, both system and entity query must belong in a world. So the API make sense.

And that means just calling SetSingleton without creating any entity first (by the usual way) is an error. It can't create + set, even though the SetSingleton looks very inviting that it doesn't need any Entity argument. (unlike EntityManager.SetComponentData)

In fact this is highlighted by this method you can use : componentSystem.GetSingletonEntity<T> / entityQuery.GetSingletonEntity where you get the Entity instead of the component data.

How it works : System API

You are indirectly registering a new entity query with a single type to the system (type of the singleton you want). Then it goes to the entity query-based API.

So in effect you are indirectly adding reader dependency to the system. Granting a new OnUpdate criteria, and also job completion criteria.

So think twice before doing it all over the place even though in the end the singleton's result goes to the same Entity. (It's per world, remember.)

Make it required

Having the EntityQuery registered to the system is not always bad, in fact maybe that's what you want because rather than only want a data, you want to design a system that it only works when those singletons are present. Adding reader dependency is appropriate.

Except, if that's not all you want to make the system works but also requires something more. You can use RequireForUpdate(eq) so the EntityQuery are being AND instead of OR to determine if the system should update.

For singleton, they have also added the special RequireSingletonForUpdate<T>() (no argument) because they think you might be too lazy to get the underlying EQ of the singleton and use RequireForUpdate.

How it works : EntityQuery API

If you are coming from System API then you already have a correct EQ with 1 type. If you try to Get/SetSingleton on your own on EQ with multiple types it results in InvalidOperationException!

Then it's straightforward. EntityQuery was born to get chunks. After several checks you are guaranteed to get 1 chunk (with 1 type) that contains 1 Entity. Then it get you the first data of the first entity of that chunk with MemCpy. Done! Note that query for the first chunk has a bonus fast data path if you don't have any filters in your EntityQuery.

If you happen to have more than 1 Entity of the singleton type you requested in the World you get InvalidOperationException again.

Cons

On GetSingleton for example, it will immediately complete all job dependency on that type (on the main thread) and get you the data.

If you took the trouble to :

  • CreateArchetypeChunkArray<T>  (you would get an array with 1 chunk, that chunk with 1 entity lol)
  • GetComponentTypeHandle<T>
  • Bring both to the job.
  • Iterate to the chunk array in job, which is of length 1 and also contains 1 entity inside lol.
  • Maybe you care enough to check if it is really of length 1 inside the job. (both chunk count in the ArchetypeChunkArray, and entity count in that chunk). However if you do care you have to do it in every jobs you use.
  • Get data and use it.

Then you can have a job that properly wait for any remaining writes to your singleton! (The system knows your ComponentTypeHandle when you use GetComponentTypeHandle which is a system class method, and the job know its public fields to work together.)

But will you do this for potentially negligible performance gain? Maybe just for singletons you may sacrifice parallelism for a hugely more readable code. Also you got some checks that the singleton is still really singleton. And more importantly, is it that often that there is some writers writing to the singleton when you want to use it that it had to force job completion? In my game, the singleton stays quite stable after a few frames. The main thread job completion is not worrying at all.

Things this API can't do

  • Remember that it is for making singleton easier. Still "single component type" (but many entities may exist) is still annoying to set up if you got many such entity queries in the system.
  • I keep wanting a singleton component object to be usable, unfortunately the generic wants IComponentData. Usually in the scene there are quite a lot of "manager" that is only one GameObject in the scene. It would work nicely with this API.

Fun tests

That's all about ECS singleton, but do you think you got what it takes to not be confused about ECS singleton anymore? This is our definitions for all tests :

public class Sys1 : ComponentSystem
{
	protected override void OnUpdate() { }
}

public class Sys2 : ComponentSystem
{
	protected override void OnUpdate() { }
}

public struct SingletonData : IComponentData
{
	public int singletonInt;
}

public struct NormalData : IComponentData
{
	public int normalInt;
}
[Test]
public void SingletonIsNotPerSystem()
{
    World w = new World("test");

    var s1 = w.GetOrCreateSystem<Sys1>();
    var s2 = w.GetOrCreateSystem<Sys2>();

    w.EntityManager().CreateEntity(ComponentType.ReadOnly<SingletonData>());

    s1.SetSingleton(new SingletonData { singletonInt = 555 });
    s2.SetSingleton(new SingletonData { singletonInt = 666 });

    Assert.That(s1.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(666), "Not 555");
    Assert.That(s2.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(666));
}

Here you see that even if I told the other system to set the singleton, it affects the one I asked from the first system since they are all going to get the same Entity.

[Test]
public void AllGoesToTheSameChunk()
{
    World w = new World("test");
    var s1 = w.GetOrCreateSystem<Sys1>();
    var em = w.EntityManager;

    em.CreateEntity(ComponentType.ReadOnly<SingletonData>());

    s1.SetSingleton(new SingletonData { singletonInt = 555 });
    Assert.That(s1.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(555));

    var eq = em.CreateEntityQuery( ComponentType.ReadOnly<SingletonData>());
    Assert.That(eq.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(555), "The same thing");
}

Here I show that getting the singleton from EQ works the same way.

[Test]
public void MultiSingleton()
{
    World w = new World("test");
    var s1 = w.GetOrCreateSystem<Sys1>();
    var em = w.EntityManager;

    Entity e = em.CreateEntity(ComponentType.ReadOnly<SingletonData>(), ComponentType.ReadOnly<NormalData>());
    em.SetComponentData(e, new SingletonData { singletonInt = 555 });
    em.SetComponentData(e, new NormalData { normalInt = 666 });

    Assert.That(s1.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(555));
    Assert.That(s1.GetSingleton<NormalData>().normalInt, Is.EqualTo(666));
}

How about a single Entity with two components? Apparently it still counts as a singleton of 2 types at the same time! Two in one! (You don't have to separate to its own Entity just to make it "real" single.)

[Test]
public void MultiSingletonWithNonSingleton()
{
    World w = new World("test");
    var s1 = w.GetOrCreateSystem<Sys1>();
    var em = w.EntityManager;

    Entity e = em.CreateEntity(ComponentType.ReadOnly<SingletonData>(), ComponentType.ReadOnly<NormalData>());
    em.SetComponentData(e, new SingletonData { singletonInt = 555 });
    em.SetComponentData(e, new NormalData { normalInt = 666 });

    Entity e2 = em.CreateEntity(ComponentType.ReadOnly<NormalData>());
    em.SetComponentData(e2, new NormalData { normalInt = 777 });

    Assert.That(s1.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(555));
    Assert.Throws<InvalidOperationException>(() => s1.GetSingleton<NormalData>(), "No longer counts as a singleton");

    var query = new EntityQueryDesc
    {
        All = new ComponentType[]{
        },
        Any = new ComponentType[]{
            ComponentType.ReadOnly<NormalData>()
        },
        None = new ComponentType[]{
        },
    };
    var eq = em.CreateEntityQuery(query);
    using(var aca = eq.CreateArchetypeChunkArray(Allocator.TempJob))
    {
        Assert.That(aca.Length, Is.EqualTo(2), "They all still exists normally!");
        Assert.That(aca[0].Count, Is.EqualTo(1), "They all still exists normally!");
        Assert.That(aca[1].Count, Is.EqualTo(1), "They all still exists normally!");
    }
}

This time I add an another entity with half of the same data as the first entity. Turns out the NormalData is now not counting as a singleton anymore, now that it returns 2 entities upon query.

I try to do sanity check by query all of them with EntityQueryDesc and yes, they are still usable as long as not via Singleton API. (It's actually great that it helps you check about singleton status in this vast world of entities)

[Test]
public void NonSingletonEQ()
{
    World w = new World("test");
    var s1 = w.GetOrCreateSystem<Sys1>();
    var em = w.EntityManager;

    Entity e = em.CreateEntity(ComponentType.ReadOnly<SingletonData>(), ComponentType.ReadOnly<NormalData>());
    em.SetComponentData(e, new SingletonData { singletonInt = 555 });
    em.SetComponentData(e, new NormalData { normalInt = 666 });

    var eq = em.CreateEntityQuery(ComponentType.ReadOnly<SingletonData>(), ComponentType.ReadOnly<NormalData>());

    Assert.Throws<InvalidOperationException>(() => eq.GetSingleton<NormalData>());
    Assert.That(eq.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(555));
    Assert.Throws<InvalidOperationException>(() => eq.GetSingleton<NormalData>());

    var eq2 = em.CreateEntityQuery(ComponentType.ReadOnly<NormalData>(), ComponentType.ReadOnly<SingletonData>());

    Assert.Throws<InvalidOperationException>(() => eq2.GetSingleton<NormalData>(), "EQ order doesn't matter");
    Assert.That(eq2.GetSingleton<SingletonData>().singletonInt, Is.EqualTo(555), "EQ order doesn't matter");
    Assert.Throws<InvalidOperationException>(() => eq2.GetSingleton<NormalData>(), "EQ order doesn't matter");
}

Next I try to use EQ with 2 types intentionally to see the error. Turns out it doesn't always error out, it still works if used to ask about one of the type and error on the other. I don't know how it determines which type works, but you better use a EQ with 1 type or just ask the system.

[Test]
public void DontConfuseItWithEAQ()
{
    World w = new World("test");
    var s1 = w.GetOrCreateSystem<Sys1>();
    var em = w.EntityManager;

    Entity e = em.CreateEntity(ComponentType.ReadOnly<SingletonData>(), ComponentType.ReadOnly<NormalData>());
    em.SetComponentData(e, new SingletonData { singletonInt = 555 });
    em.SetComponentData(e, new NormalData { normalInt = 666 });

    Entity e2 = em.CreateEntity(ComponentType.ReadOnly<NormalData>());
    em.SetComponentData(e2, new NormalData { normalInt = 777 });

    var query = new EntityQueryDesc
    {
        All = new ComponentType[]{
            ComponentType.ReadOnly<NormalData>(),
        },
        Any = new ComponentType[]{
        },
        None = new ComponentType[]{
            ComponentType.ReadOnly<SingletonData>()
        },
    };
    var eq = em.CreateEntityQuery(query);
    Assert.Throws<InvalidOperationException>(() => eq.GetSingleton<SingletonData>(), "Now it think there is no entity at all");
}

Remember that EntityQuery can be made from EntityQueryDesc. Chaos ensues! For example I trick it with None query that results in no entity matched. By using None it bypassed the check about EQ containing 1 type, because it is technically still 1 type. (But with 1 more exclude... ha!)

The GetSingleton now doesn't work. It is understandable that it doesn't work, but the error message will says it found "0 entity" of the singleton that you asked. But in reality there is one, as per multi-singleton behaviour I demonstrated earlier. Just that the query that made the EQ is strange. Anyways, you better not mess with EntityQueryDesc when trying to do singleton.