Solving a conceptual problem with Unity ECS. Messenger for System

Foreword

The author of this article is an adherent of data-oriented design and ECS frameworks. And so I decided to develop a game that at first glance does not quite fit the genre for ECS. Namely, a casual runner. This is where the problem we are going to discuss comes from.

The game implies the endless appearance of objects in front of the player, upon interaction (hitting them) with which an action is unique for the type of object. That is: we have many varieties of events (custom logic) and they do not happen all the time.

Sounds like a perfect job for a virtual method, doesn’t it? But there is a nuance. There are no abstractions in ECS (except for various crutches). Therefore, the problem must be solved with the help of composition.

Naive solution

When a player interacts with an object, we add a component to it Interacted. Thus, to implement each unique action, we need to create a regular system and a job that will look something like this:

[WithAll(typeof(Interacted))]
public partial struct NaiveJob : IJobEntity
{
    private void Execute(in SomeComponent component)
    {
        // Logic
    }
}

But what are the disadvantages of such a solution?

  • When interacting, we require structural changes (adding a component), which in itself limits the possibilities of exactly when such interactions can occur, and is also quite heavy in terms of performance.

  • Creating a (schedule) job is also not free and presumably the logic of the action itself will take less than the cost of the job itself.

  • The execution of logic inside a job also introduces a number of restrictions, since they are executed not on the main thread, but on worker threads.

Among other things, you will need to implement another system already to remove the component Interacted after the job is created, so that the action happens only once, and does not continue to be performed.

All this led to the fact that adding new logic required writing a huge amount of boilerplate code, and the additional performance load (albeit not significant) affected the entire game constantly, every frame, despite the fact that the interaction with objects itself usually occurs once a 2-3 second.

Variation of the naive solution

In order not to rewrite all existing code, I tried to fix what is possible: structural changes. I decided to use IEnablableComponentas an alternative to the structural Interacted. Thus, this component existed on all entities-objects constantly, and due to the peculiarities of code generation IJobEntity the code of systems and jobs remained the same.

However, instead of the expected improvement in performance – on the contrary, it has noticeably deteriorated. The reason why this happened lies in the features IEnablableComponent. Since enabling and disabling a component is not a structural change, the system cannot determine that query empty during its execution and is forced to create a job not only when the necessary entities exist, but every frame.

Finding a Solution

It is quite clear that the use of jobs for such a small amount of processed data is not very rational. Therefore, I decided to move the logic to the main thread.

The main problem with this solution is that executing the logic on the main thread results in a synchronization point (when all relevant jobs must be executed right now to avoid a race condition). And in order to avoid it, I created a special group for systems, inside which jobs are not created, and the group itself is located where either there is already a synchronization point (for example, next to the built-in command buffers), or jobs have not yet been created.

And a sample template for the object logic began to look like this.

The component itself Interacted returned to the original solution with structural changes.

[UpdateInGroup(typeof(SomeMainThreadGroup))]
public partial struct System : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var someComponent in SystemAPI.Query<SomeComponent>().WithAll<Interacted>())
        {
            // Logic
        }
    }
}

So: this partly solved the performance problem and significantly expanded the possibilities for executing logic (and also gives us the opportunity to use the SystemAPI). However, the implementation of structural changes within SystemAPI.Query everything is also impossible, and the use of alternative solutions in the form query.ToEntityArray and so on leads to boilerplate code and increased performance usage.

PS Of course, within the framework of the game being developed, all this was absolutely minor shortcomings, but I decided to find a concept that could be implemented in more serious projects.

Events and ECS

Events themselves are an anti-pattern within ECS, but the temptation to experiment won out and I found out the following:

Code generation allows you to subscribe to events inside SystemBase callbacks and use all the delights of SystemAPI inside. For example:

public partial class System : SystemBase
{
    protected override void OnCreate()
    {
        SomeClass.someCallback += Handler;
    }

    private void Handler(SomeParameter parameter)
    {
        // Logic
    }
}

Although this implementation is as simple as possible and allows you to use much less boilerplate code, creating jobs inside such callbacks is unsafe and using Burst is impossible / incredibly difficult.

climax

In the end, it dawned on me – why not re-create the bike?

Namely: why not execute all the logic inside the system OnUpdate, but at the same time determine when this system will be executed due to data not stored on entities?

How should it look?

We have a player that interacts with an object. During this, we will create a message with all the relevant data about the event.

For example, it will be only herself Entity объекта.

public struct InteractedMessage
{
    public Entity interactedEntity;
}

We have a specific system that needs to be executed when the player interacts with a specific object. And to implement this logic, it only needs to receive InteractedMessage.

public partial struct LogicSystem : ISystem, IEventSystem<InteractedMessage>
{
    private InteractedMessage _message;

    public void OnUpdate(ref SystemState state)
    {
        if (SystemAPI.HasComponent<SomeComponent>(_message.entity))
        {
            // Logic
        }
    }
}

Thus I created bike ECS without Entities.

Implementation

All that remains for us is to link this system and the message and ensure that the system only executes when the relevant message has been sent.

To do this, we will implement a special component that will contain a byte buffer that will store messages and their signature (hash).

public struct Messenger : IComponentData
{
    internal NativeList<byte>.ParallelWriter data;

    public unsafe void Send<T>(T message) where T : unmanaged, INativeMessage
    {
        var size = sizeof(T);
        var hash = BurstRuntime.GetHashCode32<T>();
        const int hashSize = sizeof(int);

        var length = hashSize + size;
        var idx = Interlocked.Add(ref data.ListData->m_length, length) - length;
        var ptr = (byte*)data.Ptr + idx;
        UnsafeUtility.MemCpy(ptr, UnsafeUtility.AddressOf(ref hash), hashSize);
        UnsafeUtility.MemCpy(ptr + hashSize, UnsafeUtility.AddressOf(ref message), size);
    }
}

How it works:

  1. We get the hash of the type using the built-in method Burst.

  2. We get the size of the hash and the message itself.

  3. With the help of an atomic operation, we increase Length buffer and save a pointer to the memory we reserved.

  4. We write the hash as a header and the message into the buffer.

PS the specific implementation is not the best solution in terms of performance and can be a bottleneck in massive usage, and also has a write limit determined by the buffer capacity. But for the time being, we will limit ourselves to this.

Thus, we have a simple thread-safe container for essentially any messages. That is, we have data. And now we just have to execute all the systems relevant to this message.

To fully integrate development into the Unity.Entities ecosystem, I created a special system group where all these systems will be updated. Having learned from previous solutions, the group is in a special position not to create a sync point.

    [UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)]
    [UpdateAfter(typeof(BeginSimulationEntityCommandBufferSystem))]
    public unsafe partial class NativeEventSystemGroup : ComponentSystemGroup

In order to correctly unpack the message buffer, we need to know all the messages being sent in advance. There are different options for how to do this through reflection. The most universal method seemed to me through a cycle through the systems created in the system group (and it doesn’t matter if they were created by a built-in or custom bootloader).

I’ll omit the specific implementation details (because it’s a lot of not-so-interesting code) and just describe how it works.

  1. We make a cycle through all the created systems in our group.

  2. Using reflection, we determine the type of message in the system.

  3. We provide the system with all the necessary data for injecting messages for systems.

In the end the result looks like this, where:

        private struct EventSystemHandle
        {
            public bool isManaged;
            public int messageHash;
            public bool isSingle;
            public TypeDataHandle typeDataHandle;
            public void* lastPtr;

            public SystemHandle handle;

            // Unmanaged
            public int fieldOffset;
        }

        private struct TypeDataHandle
        {
            public int size;
            public NativeList<byte> dataBuffer;
        }

        private NativeList<EventSystemHandle> _systemHandles;
        private NativeHashMap<int, TypeDataHandle> _dataMap;

And finally, let’s move on to unpacking:

where data is NativeList<byte> from Messenger

            var ptr = data.GetUnsafeReadOnlyPtr();
            var iterator = 0;
            while (iterator < data.Length)
            {
                var hash = UnsafeUtility.AsRef<int>(ptr + iterator);
                var dataPtr = ptr + iterator + sizeof(int);

                var handle = _dataMap[hash];
                handle.dataBuffer.AddRange(dataPtr, handle.size);

                iterator += sizeof(int) + handle.size;
            }

What’s happening:

  1. Reading the title from the buffer.

  2. We determine the size of the message and write the message to the appropriate buffer.

  3. Repeat until the buffer runs out.

Then we loop through _systemHandles:

If the message buffer associated with a system is not empty, then that system needs to be updated.

The system can read messages in different ways, but specifically my implementation is made in the likeness of Dependency Injection:

If it is ISystem, then during initialization we define the offset of the pointer to the field with the message (which is marked through the attribute) and then simply using memcpy we write the message/message array to this field.

If this is SystemBase, then we simply implement the abstract type EventSystemBase, where the required field will already be created, and the message can be obtained from properties (property).

You can get acquainted with the full source code of the implementation here.

conclusions

The developed implementation of the messenger solves the problem posed: the implementation of custom logic through messages. To create logic, a minimum of boilerplate code is required. Sending messages is possible from anywhere in the game logic. Custom logic can be used with Burst and can create jobs. In conditions of complete absence of messages, the system has almost zero overhead.

Apart from the game example, these interactions are very handy for handling various global game events, player input, or bridging between the ECS world and MonoBehaviour.

Reflections for the future

At the moment, the implementation allows you to subscribe only to specific messages and only ECS systems can do this.

Adding the ability to subscribe any object to messages from a specific ECS world will significantly expand the potential and ease of integration of hybrid solutions.

Also, in some cases, you may need several messengers at once, for example, if message handlers should be between the simulation and the presentation.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *