ECS for RTS - A few questions and feedback

Hello,

I am current working on an RTS game using the ECS.
I came across a few interesting topics which i solved more or less hacky and i’d like to hear if there are better ways to do it.

The first thing is gaining fine-grained control of the world updates, in my game i got 2 worlds - one for simulation and one for rendering.
The issue i am facing however is that i want to invoke the ECS updates from inside my own deterministic fixed lockstep implementation, which also should support being paused (stop invoking the ECS, in order to for example resync the game after a hash mismatched, more on hashing later) and be executed at fixed framerates.

What i am doing right now is creating my simulation world and keeping track of all created managers like this:

            systems.Add(this.simulationWorld.CreateManager<UnitMovementCS>());
            systems.Add(this.simulationWorld.CreateManager<UnitCommandMoveCS>());

I disabled the player loop update for my simulation world only by calling ScriptBehaviourUpdateOrder.UpdatePlayerLoop(new World[ ] { renderingWorld });

At the point where i want to update the ECS i then call ScriptBehaviourManager.Update() on all previously created managers.

            foreach (var system in this.systems)
                system.Update();

This works, but it doesnt feel quite right and i cant imagine thats the intended way of keeping manual update control.
It would be very nice to have some World.Update() method that updates / ticks the world and also properly handles UpdateAfter / UpdateBefore / UpdateInGroup without being forced to execute the ECS updates in the player loop. Is something like this planned or are we supposed to update the systems ourselves?

Another topic i came across is verifying multiple game states are in sync.
In order to do this im hashing my game state into one hash that is exchanged between sessions and used to test if the own hash matches the others.

Right now im hashing quite some management stuff outside of ECS, but thats not an issue. The issue is hashing the ECS data (which is a lot, that was the point i looked into ECS after all :P) is quite inefficient this way:

            var em = ECSUtil.simulationEntityManager;
            ComponentDataFromEntity<UnitData> ud = em.GetComponentDataFromEntity<UnitData>(true);
            ComponentDataFromEntity<UnitMovementData> umd = em.GetComponentDataFromEntity<UnitMovementData>(true);
            ComponentDataFromEntity<DeterministicRenderTransformData> dtd = em.GetComponentDataFromEntity<DeterministicRenderTransformData>(true);

            foreach (var unit in this._units)
            {
                unsafe
                {
                    var entity = unit.entity;
                    if (ud.Exists(entity))
                    {
                        var _ud = ud[unit.entity];
                        context.HashCRC32("UnitData", (byte*)&_ud, sizeof(UnitData));
                    }
                    if (umd.Exists(entity))
                    {
                        var _umd = umd[unit.entity];
                        context.HashCRC32("UnitMovementData", (byte*)&_umd, sizeof(UnitMovementData));
                    }
                    if (ud.Exists(entity))
                    {
                        var _dtd = ud[unit.entity];
                        context.HashCRC32("UnitTransformData", (byte*)&_dtd, sizeof(DeterministicRenderTransformData));
                    }
                }
            }

This results in pretty bad timings however, see this profiler screenshot:

It would be very nice if we could either directly recieve byte array references or pointers on the locations where ECS stores its data, or even better if the ECS itself could hash all data for us.
Is something like this planned or is there a better approach?

I have been using the ECS intensively now for some days and checked out most of the features and i have to say, so far it has been a really interesting way of solving problems.
Apart from those 2 things i explained above i didnt encounter any issues at all with the ECS (I would just be happy about a bit more documentation, even if just C# summaries). It works, performance is insane and it seems perfect for projects like RTS games :slight_smile:

  • update order: for now you need to update manually each system, as the UpdateBefore/After calculations are done inside UpdatePlayerLoop (and technically you can UpdateBefore(PlayerLoop.FixedUpdate) and it syncs with physics, so it’s not simply an ordering)

  • hashing: world should be able to serialize/deserialize into binary format. you could get the resulting byte[ ] and hash that

The problem about the update order is that i will have situations in which i dont want ECS to be updated at all, which is decided in one of my highlevel logic gameobjects.
An example reason for the ECS simulation to stop would be that in MP a client didnt submit the commands he wants to execute in a lockstep yet. In that case i have to wait for the client to submit the command before simulation can go on.

For now this workaround seems to be working just fine, im just fearing this will be broken at a later stage of ECS since i dont think this is the intended way for us to gain control over when ECS is updated.

The serialization approach seems interesting, i’ve quickly hacked together a test for this.
BinaryWriter for recieving serialization callbacks:

        class StreamWriter : Unity.Entities.Serialization.BinaryWriter
        {
            public static StreamWriter instance = new StreamWriter();
            public byte[] data = new byte[4096];
            public int ptr = 0;

            public void Dispose()
            {

            }

            public void Reset()
            {
                this.ptr = 0;
            }

            private void ExpandIfNeeded(int bytes)
            {
                int spaceLeft = (this.data.Length - this.ptr);
                if (spaceLeft < bytes)
                {
                    int spaceNeededTotal = this.data.Length - (spaceLeft - bytes);
                    int needMoreBytes = spaceNeededTotal - this.data.Length;
                   
                    int growSteps = Mathf.CeilToInt((float)needMoreBytes / 4096f);
                   
                    Array.Resize(ref this.data, this.data.Length + (4096 * growSteps));
                }
            }

            public unsafe void WriteBytes(void* data, int bytes)
            {
                ExpandIfNeeded(bytes);
                byte* dataB = (byte*)data;
               
                fixed (byte* _data = this.data)
                {
                    for (int i = 0; i < bytes; i++)
                        _data[this.ptr++] = dataB[i];
                }
            }
        }

Actual serialization test code:

            int[] serializedSharedComponents;
            Unity.Entities.Serialization.SerializeUtility.SerializeWorld(ECSUtil.simulationEntityManager, StreamWriter.instance, out serializedSharedComponents);
            byte[] d1 = new byte[StreamWriter.instance.ptr];
            Array.Copy(StreamWriter.instance.data, d1, d1.Length);
            StreamWriter.instance.Reset();
            Unity.Entities.Serialization.SerializeUtility.SerializeWorld(ECSUtil.simulationEntityManager, StreamWriter.instance, out serializedSharedComponents);
            byte[] d2 = new byte[StreamWriter.instance.ptr];
            Array.Copy(StreamWriter.instance.data, d2, d2.Length);

            for (int i = 0; i < d2.Length; i++)
            {
                if (d1[i] != d2[i])
                    Debug.LogError("Difference detected at " + i + " - " + d1[i] + "|" + d2[i]);
            }

It looks as if the written serialized data is not deterministic, the error is triggered on every frame:

The serialization also doesnt seem to be optimized for runtime usage. The memory allocation is rather large (which is understandable, serializing stuff at runtime isnt a very common thing :P):

I think the only way to achieve the performance needed to hash large amounts of entities is avoiding any memory copies at all and directly hash the data that is stored by the ECS using some cheap algorithm like CRC32 or xxHash.

I think you should be able get good the hashing performance using the new component versioning using ‘ChangeFilter’:

This way you could only recalculate and update hashes of entities that actually changed and update a world hash.

Very nice talk, thanks for linking it. I didnt see that one yet :slight_smile:

The idea to use change filters seems to be pretty good, but the attribute is not yet in the current version retrievable by the package manager (0.0.12-preview8).

However i was able to speed up hashing performance a fair bit, up to a point that is usable for me at the moment.
In order to do so i added a component that keeps track of the hashes of every component data (this could be decreased up to only a hash per entity, but im keeping them atm to be able to better track down desyncs):

    /// <summary>
    /// Hashing data for units.
    /// </summary>
    public struct UnitHashData : IComponentData
    {
        /// <summary>
        /// Hash of <see cref="UnitData"/>
        /// </summary>
        public int unitDataHash;

        /// <summary>
        /// Hash of <see cref="UnitMovementData"/>
        /// </summary>
        public int unitMovementDataHash;

        /// <summary>
        /// Hash of <see cref="UnitCommandMoveData"/>
        /// </summary>
        public int unitCommandMoveDataHash;

        /// <summary>
        /// All hashes combined
        /// </summary>
        public int unitHash;
    }

And in a job component system i hash the component data:

    /// <summary>
    /// Hashing component system.
    /// Hashes the ECS data that represents the game state.
    /// </summary>
    public class HashingCS : JobComponentSystem
    {
        /// <summary>
        /// Hashes <see cref="UnitData"/>
        /// </summary>
        private struct JobUnitData : IJobProcessComponentData<UnitData, UnitHashData>
        {
            public unsafe void Execute(ref UnitData data, ref UnitHashData hashData)
            {
                UnitData ud = data;
                uint crc = CRC32.Compute(0, (byte*)&ud, sizeof(UnitData));
                unchecked
                {
                    hashData.unitDataHash = (int)crc;
                }
            }
        }

        /// <summary>
        /// Hashes <see cref="UnitMovementData"/>
        /// </summary>
        private struct JobUnitMovementData : IJobProcessComponentData<UnitMovementData, UnitHashData>
        {
            public unsafe void Execute(ref UnitMovementData data, ref UnitHashData hashData)
            {
                UnitMovementData ud = data;
                uint crc = CRC32.Compute(0, (byte*)&ud, sizeof(UnitMovementData));
                unchecked
                {
                    hashData.unitMovementDataHash = (int)crc;
                }
            }
        }

        /// <summary>
        /// Hashes <see cref="DeterministicTransformData"/>
        /// </summary>
        private struct JobTransformData : IJobProcessComponentData<DeterministicTransformData, UnitHashData>
        {
            public unsafe void Execute(ref DeterministicTransformData data, ref UnitHashData hashData)
            {
                DeterministicTransformData ud = data;
                uint crc = CRC32.Compute(0, (byte*)&ud, sizeof(DeterministicTransformData));
                unchecked
                {
                    hashData.unitCommandMoveDataHash = (int)crc;
                }
            }
        }

        /// <summary>
        /// Combines all hashes on <see cref="UnitHashData"/>
        /// </summary>
        private struct JobCombineHashes : IJobProcessComponentData<UnitHashData>
        {
            public void Execute(ref UnitHashData data)
            {
                data.unitHash = Essentials.CombineHashCodes(Essentials.CombineHashCodes(data.unitMovementDataHash, data.unitDataHash), data.unitCommandMoveDataHash);
            }
        }

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            // First hash all components
            var job1 = new JobUnitData().Schedule(this, inputDeps);
            var job2 = new JobUnitMovementData().Schedule(this, job1);
            var job3 = new JobTransformData().Schedule(this, job2);

            // And lastly combine all hashes
            var job4 = new JobCombineHashes().Schedule(this, job3);

            return job4;
        }
    }

This seems to be a lot more efficient than the previous approach (it can handle ~5k units + all other game simulation at stable 60fps in editor, with the ChangeFilter attribute most likely even more in most use-cases), however i think an ECS internal hashing method with direct access to the data will beat the performance of this by a wide margin.

Actually it won’t. Your code generates quite optimal code. The overhead of IJobProcessComponentData is exactly zero on a per component basis, the method gets inlined and we essentially iterate over perfectly linearly laid out memory. There is some overhead for iteration per chunk but that is very very low.

That said, you do not have burst enabled on your jobs. This will give you massive performance boosts.

[ChangeFilter] attribute should be in the latest release.

Thanks for looking into this, Joachim!
I’ve checked again but i cant find ChangeFilter in the current release:

Is there another source for getting even earlier test packages of ECS outside of the package manager or am i doing something wrong (Im using AssemblyDefinition files, maybe a missing namespace i need to add manually)?

I have extensively profiled the hashing code and i see now that it actually is quite fast, the way i combined all hashes on the main thread just was suboptimal (ComponentDataFromEntity over all unit entities).

I replaced this with this component system:

    /// <summary>
    /// The final hashing component system running after <see cref="HashingCS"/>, combining all hashes of:
    /// - <see cref="UnitHashData"/>
    /// </summary>
    public class HashingFinalCS : ComponentSystem
    {
        struct Group
        {
            public ComponentDataArray<UnitHashData> hashData;
            public readonly int Length;
        }

        [Inject] Group group;

        /// <summary>
        /// Combined <see cref="UnitHashData.unitHash"/>
        /// </summary>
        public int unitHash;

        protected override void OnUpdate()
        {
            int hash = 0;
            for (int i = 0; i < group.Length; i++)
            {
                hash = Essentials.CombineHashCodes(hash, group.hashData[i].unitHash);
            }

            this.unitHash = hash;
        }
    }

And performance is a lot better! (I assume ComponentDataFromEntity has some overhead / non-linear memory access due to the order look up data for my unit entities).

I also tested burst and the results are absolutely insane!
Before (without [BurstCompile):

After (with [BurstCompile]):

On JobMovementData that is an acceleration of 13,6x. Absolutely amazing :open_mouth:
I attached the full hashing system code file if anyone is interested in the code.

Last thing i would like to know is if unity is planning anything to change the manual update thing i mentioned above. The current solution clearly works very well (i saw it being suggested by some unity dev here in this forum but cant find the thread anymore), but something like World.Active.Tick() would be amazing (taking into account system orders based on UpdateBefore / UpdateAfter).

As final statement i can just say im blown away by the ECS, Jobs and the Burst Compiler. Very nice work :slight_smile:

3574419–288532–HashingCS.cs (8.64 KB)

1 Like

In our RTS (fully refactored to ECS, on first ECS versions, couple months ago) we also use own Update wrapper for manually call update on some systems (created manually and marked DisableAutoCreation), I think now is only one “right” way.

It seems that the ChangeFilter attribute has been renamed to ChangedFilter at some point.

1 Like

@kennux I’m working on a simple RTS as well using a deterministic lock-step approach. This is how I have implemented it. Let me know what you think:

I derive all my systems from a base class

class BaseSystem
{
override OnUpdate()
{
if (TickManager.ReadyToMoveToNextTick())
   OnTick();
}

protected virtual OnTick()
{
}
}
class MovementSystem : BaseSystem
{
protected override OnTick()
{
}
}

In this approach, all systems inherit from BaseSystem and they all override the OnTick() method rather than OnUpdate(). OnUpdate() gets called every frame, but OnTick() gets called only when it’s actually a new tick. You can use TickManager.ReadyToMoveToNextTick() to pause or slow down the tick frequency. The advantage of this is that you don’t have to maintain your own list of systems, and this honours the UpdateBefore, UpdateAfter attributes.

@EvansT
The problems I’ve experienced with UpdateBefore / UpdateAfter were some inconsistencies about when they were called compared to regular monobehaviour methods (i tried to debug this but gave up at some point, in an empty project it seemed to work fine just not in my game simulation for some reason).

My code was split into GameObjects (for user input, ai, levels, …) and ECS (for units, projectiles and game sim in general).
So i absolutely needed a way to control the exact time at which the game simulation was updated. If you can manage to keep your simulation in sync with the above approach and by only using ComponentSystems this seems fine to me

However i personally like ECS very much, but i find GameObjects for stuff like Actors (Player / AI), Levels, … a lot more comfortable so i do not want to work in ECS only :stuck_out_tongue:

So what i ended up with is (roughly) this running on a MonoBehaviour:

public FixedUpdate()
{
    if (this._justFinishedLockstep)
    {
        // Check for advancement
        if (!this.canAdvanceLockstep.Check())
        {
            Debug.Log("Lag detected! Cant advance!");
            return;
        }

        this._justFinishedLockstep = false;
    }

    // New lockstep
    if (this._currentLockstepFrame == 0)
        this.StartLockstep();

    // Frame update
    FixedTimestep();
    this._currentLockstepFrame++;

    // End lockstep
    if (this._currentLockstepFrame == this.gameFramesPerLockstep)
        this.FinishLockstep();

    // Update ECS
    this.UpdateECS();
}

private Dictionary<int, ScriptBehaviourManager> systems = new Dictionary<int, ScriptBehaviourManager>();
protected virtual void UpdateECS()
{
    // Sort systems (TODO: cache)
    List<int> orders = ListPool<int>.Get();

    // Write all orders into list
    foreach (var order in this.systems.Keys)
        orders.Add(order);

    // Sort list
    Utils.InsertionSort(orders);

    // Execute systems in order
    foreach (var order in orders)
        this.systems[order].Update();

    ListPool<int>.Return(orders);
}

So i think it depends on your game what way you should go, but i think giving people the ability to update their systems manually in a more “official” way would be a good idea :slight_smile:

Do you get the system information in the inspector (showing how long each system took in the frame [in ms]) using this method? I experimented with manually calling systems and did not see this information, which is a shame. Hopefully whenever they add a standard procedure for manually calling systems, this information will be available.

In case you mean the entity debugger stuff, no unfortuantely that doesnt seem to be available when using this kind of updating method.

This actually is another good reason to get an official way for manual update calls :slight_smile:

We could at least have a view where you can see all systems, even those that are manually updated by user code.
I logged an issue for that.

2 Likes

I don’t mean to necro an old post; however, I needed a similar system for rollback networking (checksums for verifying state divergence), and I’ve made a slightly more generalized, but still hardcoded solution to this problem. It makes use of the built in XXHash class in the ECS system for speed and uses full 64-bit hashes for both speed and lower rate of collisions. It disables a good number of safety checks and uses raw pointers everywhere, but it should be both stable and safe to use.

This solution utilizes a generic IJobChunk to generalize queries for specific components, and includes a entity ID based sort to ensure that reshuffling of entities via destruction or archetype changes will not affect the resultant hash.

My full implementation can be found here: FantasyCrescendoECS/Assets/Code/src/Runtime/Match/Systems/HashWorldSystem.cs at 04b7e9e0645b93c21060a676c604b154dc1db727 · HouraiTeahouse/FantasyCrescendoECS · GitHub

2 Likes

Interesting thanks.