Patterns for entities with N:1 relationships (e.g. spawner & CPU particles)

A common pattern in ECS seems to have entities that store data to manage the lives of other entities. (E.g. spawner & particles, gun & bullets, etc.) Many database systems, which is what ECS really is, support data locality for these kinds of relationships. For example, Spanner has interleaved tables. I wonder if ECS has some set of best practices or even code to enable working with N:1 relationships like these?

Here’s a real example, because the above might be a bit abstract:

I’m using Entities to write a simple particle system, mainly as a learning exercise. Each particle has the Entity number (foreign key, really) of the entity that acts as its spawner.

This seemed like a good way to structure the data:

public struct SpawnerData : IComponentData {
  public int MaxParticles;
  public int LiveParticles;
}

public struct ParticleData : IComponentData {
 public Entity Spawner;
}

Writing the spawner system is easy:

public class SpawnerSystem : SystemBase
{
protected override void OnUpdate()
{
 var ecb = _endSimulationEcbSystem.CreateCommandBuffer();
 Entities.ForEach((ref SpawnerData spawner, in Translation translation) => {
  // .. Obvious spawning logic using an EntityCommandBuffer
 }
}
}

Destroying particles, though, is not as nice. In order to keep the LiveCount property up to date, you have to look up the Spawner, like so:

if (HasComponent<SpawnerData>(particleData.Spawner))
{
  var spawnerData = GetComponent<SpawnerData>(particleData.Spawner);
  spawnerData.LiveParticles--;
  ecb.SetComponent(particleData.Spawner, spawnerData);
}

Now the thing with this code is that you have to keep calling GetComponent in the ForEach. Of course, it’s not a big deal - data will still be reasonably local and GetComponent is probably cheap enough. But this type of thing seems to come up often enough that having a builtin way to ensure the lookups are optimalized would probably help bunches.

3 Likes

I’m not going to make a comment on the general case here because it is a bit of a complicated discussion. And seeing that your third code example wouldn’t actually work, I am assuming you are stumbling getting into a working state.

There are a few ways of solving this problem, and all of them involve counting either the entities destroyed or the entities still alive for each spawner after the job that checks for and conditionally destroys the particles.

One option is to modify the particle prefab at runtime to have a SharedComponent referencing the spawner. Then you can simply calculate the spawner particle count with an EntityQuery. This works really well with a low spawner count and high particle count because the counting is done at chunk level rather than entity level. I use this in my game here: lsss-wip/Assets/_Code/SubSystems/Gameplay/SpawnShipsEnqueueSystem.cs at master · Dreaming381/lsss-wip · GitHub

Another option is to simply record the spawner of each particle you destroy, and then sum up the number of each spawner entity references as that gives you the number of particles destroyed belonging to that spawner.

A third option would be to use GetComponent and SetComponent in a Schedule instead of ScheduleParallel.

1 Like

I’m not sure why you’d say the third example doesn’t work. I simplified it a bit for clarity, yes, but this is a working system.

Sorry for not being clearer: I don’t need help getting my code to work. I am pointing out that ECS lacks interleaving, and using this (rudimentary) example to point out how it’s limiting.

(Your sample code is helpful, by the way, thanks for that. Counting with an EntityQuery hadn’t occurred to me, and I think I like it.)

I guess what I am saying is that, left to its own devices, ECS will arrange my memory like this:

Chunk 1:
SPAWNER B
SPAWNER A

Chunk 2:
PARTICLE A
PARTICLE B
PARTICLE A
PARTICLE B
PARTICLE A
PARTICLE B

What I want, in a perfect world, is to arrange my memory like this:

SPAWNER A
PARTICLE A
PARTICLE A
PARTICLE A
SPAWNER B
PARTICLE B
PARTICLE B
PARTICLE B

What shared components enable is effectively this:

Chunk 1:
SPAWNER A
SPAWNER B

Chunk 2:
PARTICLE A
PARTICLE A
PARTICLE A
PARTICLE B
PARTICLE B
PARTICLE B

Now, I haven’t benchmarked this, but I have worked with databases for a long time (and wrote one about 5 years ago). I am, like, 80% sure that my way of arranging the memory (what I call interleaving) scales a lot better for hierarchical data. Not only that, but it generally makes for nicer algorithms. Especially if you consider data that’s more hierarchical, arranging the memory with interleaving will mean you have fewer pages in L1 for the same data, because you don’t need to look at multiple different chunks, each of which will only have 1 row you might care about.

Caveat / why I’m only 80%: it occurs to me that it’s pretty uncommon for databases to scan all the data in one go, so maybe ECS will amortise the extra memory as it scans over all the data each frame.

The simplification must have broken it then. Lets say you have 3 particles being destroyed out of 10 in one and only chunk (so that code runs effectively single-threaded). You’ll get this:

  • Copy spawnerData to local variable, particleCount = 10.
  • Set local particleCount = 9.
  • Write ECB particleCount = 9.
  • Copy spawnerData to local variable, particleCount = 10.
  • Set local particleCount = 9.
  • Write ECB particleCount = 9.
  • Copy spawnerData to local variable, particleCount = 10.
  • Set local particleCount = 9.
  • Write ECB particleCount = 9.
  • Playback ECB command, particleCount in ECS memory = 9.
  • Playback ECB command, particleCount in ECS memory = 9.
  • Playback ECB command, particleCount in ECS memory = 9.

Interleaved data isn’t really feasible in a tuple-based ECS architecture without other more severe issues. Unity’s ECS does not support it.

As a side note, there’s not really a concept of “pages” in the same way for cache as there is for persistent storage. With cache, you have individual cache lines of typically 64 bytes which can map to anywhere in RAM. Every cache line in L1 and L2 can map to a completely different location in RAM following set associativity rules.

If, to make an example, we imagine the particles being elements in a DynamicBuffer attached to a ‘spawner’ entity then we should get a memory layout that looks very similar to the layout you describe. The drawback is that DynamicBuffers have a fixed size. Spawners with either many more or many less particles than that size will either place elements on the heap, as is the case in the former, or leave many elements empty, as in the latter. This is not to mention that the hybrid rendering system probably doesn’t support rendering elements of a buffer (at least not to my knowledge)

A heads up, Spawners A and B would only end up in the same chunk if their SharedComponentData has the same value. As the manual page on SCD notes: [quote]
ECS groups entities with the same SharedComponentData together in the same chunks.
[/quote]

That being said, one way you could make both Spawners and Particles inhabit the same chunk would be by placing the Spawners inside the SCD data itself. In this scenario the Particles would be in the chunks regular component data. This does come with some caveats though. Firstly, you can’t control the order of entities within the chunk so you would still have a iterate over the entire chunk to run any logic on all the Particles belonging to a specific Spawner. Secondly, updating the Spawner data would be much more complex as you would need to update the SCD either on the main thread or by EntityCommandBuffer while also making sure to only call .SetSharedComponentData() on the chunk, and not per-particle, to keep things performant. Perhaps worst is that you would need to define logic for moving Particles and their Spawners once the number of particles for all the Spawners exceeds the capacity of the chunk.

All in all this route has a lot of overhead for an arguably modest reward. I still wanted to put it out there just so you know the options available