JobSystem not working as expected

Hello, I’m attempting to create a simple sphere-sphere collision system, but it seems to be failing miserably when using jobs. When running everything on the main thread it appears to work fine:

One shooter (JobComponentSystem):
4573717--425548--K3PBGmf3EG.gif

Multiple shooters (JobComponentSystem):
4573717--425551--9gtw4MtTp2.gif

Multiple Shooters (Main Thread):
4573717--425554--wcWl89XilJ.gif

It seems to fail more and more in the JobComponentSystem as more bullets are created. I’ve tried testing parts of my system in isolation and it seems like the logic is sound, so I’m not sure if I’m just making a dumb error or misunderstanding something about how ECS should work.

This is the code for my CollisionSystem:
CollisionSystem

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

//[DisableAutoCreation]
public class CollisionSystem : JobComponentSystem
{
    NativeMultiHashMap<int, Entity> spatialMap_;
    NativeQueue<CollisionData> dataQueue_;
    NativeList<int> keysList_;

    static readonly int CellSize = 20;

    EndSimulationEntityCommandBufferSystem initBufferSystem_;

    [BurstCompile]
    struct BuildSpatialMap : IJobForEachWithEntity<Translation, ECSCollider>
    {
        public NativeMultiHashMap<int, Entity>.Concurrent spatialMap;

        public void Execute(Entity e, int chunkIndex, [ReadOnly] ref Translation translation, [ReadOnly] ref ECSCollider coll)
        {
            float2 p = translation.Value.xy;
          
            VisitGridIndices(p, coll.radius, e, spatialMap.Add, CellSize);
        }

        void VisitGridIndices<TValue>(
            float2 pos, float radius, TValue val,
            System.Action<int, TValue> callback, int cellSize)
        {
            float2 p = pos;
            int2 min = (int2)math.floor((p - radius) / cellSize);
            int2 max = (int2)math.ceil((p + radius) / cellSize);
            int count = max.x - min.x;

            for (int x = min.x; x < max.x; ++x)
            {
                for (int y = min.y; y < max.y; ++y)
                {
                    int2 cell = new int2(x, y);
                    int hash = cell.GetHashCode();

                    callback(hash, val);
                }
            }
        }
    }
  
    [BurstCompile]
    struct InitializeKeysList : IJob
    {
        public NativeList<int> list;

        [ReadOnly]
        public NativeMultiHashMap<int, Entity> spatialMap;

        public void Execute()
        {
            var keys = spatialMap.GetKeyArray(Allocator.Temp);
            list.ResizeUninitialized(spatialMap.Length);
            for (int i = 0; i < list.Length; ++i)
                list[i] = keys[i];
        }
    };

    [BurstCompile]
    struct GenerateCollisionData : IJobParallelForDefer
    {
        [ReadOnly]
        public NativeMultiHashMap<int, Entity> spatialMap;

        [ReadOnly]
        public NativeArray<int> keys;

        [ReadOnly]
        public ComponentDataFromEntity<ECSCollider> colliderFromEntity;

        [ReadOnly]
        public ComponentDataFromEntity<Translation> posFromEntity;
      
        [WriteOnly]
        public NativeQueue<CollisionData>.Concurrent dataQueue;
      
        public void Execute(int index)
        {
            NativeMultiHashMapIterator<int> it;

            Entity curr;
            if( spatialMap.TryGetFirstValue(keys[index], out curr, out it ))
            {
                Entity next;
                while( spatialMap.TryGetNextValue(out next, ref it))
                {
                    var aColl = colliderFromEntity[curr];
                    var bColl = colliderFromEntity[next];
                    var a = posFromEntity[curr].Value;
                    var b = posFromEntity[next].Value;
                  
                    // TODO: Collision flags
                  
                    if( CircleOverlap(a, aColl.radius, b, bColl.radius ))
                    {
                        dataQueue.Enqueue(new CollisionData { a = curr, b = next });
                    }

                    curr = next;
                }
            }

            bool CircleOverlap(float3 aPos, float aRadius, float3 bPos, float bRadius)
            {
                float r = aRadius + bRadius;
                float dx = bPos.x - aPos.x;
                float dy = aPos.y - bPos.y;

                return dx * dx + dy * dy < r * r;
            }
        }
    }

  
    //[BurstCompile]
    struct ProcessCollisionData : IJob
    {
        public NativeQueue<CollisionData> dataQueue;

        [ReadOnly]
        public EntityCommandBuffer commandBuffer;
      
        public void Execute()
        {
            CollisionData data;
            while(dataQueue.TryDequeue(out data))
            {
                commandBuffer.AddComponent<CollisionTag>(data.a, new CollisionTag());
                commandBuffer.AddComponent<CollisionTag>(data.b, new CollisionTag());
            }
        }
    }
  
    protected override void OnCreate()
    {
        spatialMap_ = new NativeMultiHashMap<int, Entity>(5000, Allocator.Persistent);
        dataQueue_ = new NativeQueue<CollisionData>(Allocator.Persistent);
        keysList_ = new NativeList<int>(Allocator.Persistent);
        initBufferSystem_ = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }

    protected override void OnDestroy()
    {
        spatialMap_.Dispose();
        dataQueue_.Dispose();
        keysList_.Dispose();
      
    }

    protected override JobHandle OnUpdate(JobHandle inputDependencies)
    {
        var job = inputDependencies;

        spatialMap_.Clear();
        dataQueue_.Clear();
        keysList_.Clear();

        // Build our spatial map
        job = new BuildSpatialMap
        {
            spatialMap = spatialMap_.ToConcurrent(),
        }.Schedule(this, job);
      
        // Initialize the size of our keys list. We can't know the size of our list
        // during schedule time so we need to use "DeferredJobArray"
        // Example in Packages/Jobs/Unity.Jobs.Test/NativeListDeferredArrayTests
        job = new InitializeKeysList
        {
            list = keysList_,
            spatialMap = spatialMap_,
        }.Schedule(job);
      
        // Create our collision data (which entities collided) and queue it for the next job
        // We don't want to process immediately since that could potentially lead to
        // multiple threads trying to write to one entity at the same time
        job = new GenerateCollisionData
        {
            spatialMap = spatialMap_,
            keys = keysList_.AsDeferredJobArray(),
            colliderFromEntity = GetComponentDataFromEntity<ECSCollider>(true),
            posFromEntity = GetComponentDataFromEntity<Translation>(true),
            dataQueue = dataQueue_.ToConcurrent(),
        }.Schedule(keysList_, 5, job);

        // Tags the appropriate entities for our collision handling system to deal with
        job = new ProcessCollisionData
        {
            dataQueue = dataQueue_,
            commandBuffer = initBufferSystem_.CreateCommandBuffer(),
        }.Schedule(job);
      
        return job;
    }
}

It’s pretty straightforward, it builds the map, runs through it and puts the collisions into a queue, then reads the collisions out and tags the entities so another system can deal with them:
CollisionHandling

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

public class BulletCollisionHandlingSystem : ComponentSystem
{
    EntityQuery bulletCollisionQuery_;

    protected override void OnCreate()
    {
        bulletCollisionQuery_ = GetEntityQuery(typeof(CollisionTag), typeof(Bullet));
    }

    protected override void OnUpdate()
    {
        EntityManager.DestroyEntity(bulletCollisionQuery_);
        EntityManager.RemoveComponent<CollisionTag>(bulletCollisionQuery_);
    }
}

I’m been banging my head against it for days and I can’t seem to figure out what I’m doing wrong. Any advice on mistakes I’m making or how I can change things to better understand what I’m doing wrong would be appreciated.

When you say running it on main thread everything is fine. How do you run it on main thread?

one note:

  • [ReadOnly]
    EntityCommandBuffer…

That one is wrong. I am not sure why it doesn’t give you an exception about it…

I would probably start by changing all the methods to use .Run instead of Schedule, see if that fixes it.
Also easier to debug when stepping through the code when everything is running on one thread.

Same logic but in a componentsystem:
MainThreadCollisionSystem

using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

[DisableAutoCreation]
public class MainThreadCollisionSystem : ComponentSystem
{
    NativeMultiHashMap<int, Entity> spatialMap_;

    public const int CellSize = 20;

    protected override void OnCreate()
    {
        spatialMap_ = new NativeMultiHashMap<int, Entity>(20000, Allocator.Persistent);
    }

    protected override void OnDestroy()
    {
        spatialMap_.Dispose();
    }

    protected override void OnUpdate()
    {
        spatialMap_.Clear();

        Entities.WithAllReadOnly<Translation>().WithAllReadOnly<ECSCollider>().ForEach(
            (Entity e, ref Translation translation, ref ECSCollider coll) =>
            {
                float2 p = translation.Value.xy;
                SpatialHashExt.VisitGridIndices(p, coll.radius, e, spatialMap_.Add, CellSize);
            });

        var keys = spatialMap_.GetKeyArray(Allocator.TempJob);

        NativeMultiHashMapIterator<int> it;
      
        for ( int keyIndex = 0; keyIndex < keys.Length; ++keyIndex )
        {
            Entity curr;
            if (spatialMap_.TryGetFirstValue(keys[keyIndex], out curr, out it))
            {
                Entity next;
                while (spatialMap_.TryGetNextValue(out next, ref it))
                {
                    var ar = EntityManager.GetComponentData<ECSCollider>(curr).radius;
                    var br = EntityManager.GetComponentData<ECSCollider>(next).radius;
                    var a = EntityManager.GetComponentData<Translation>(curr).Value;
                    var b = EntityManager.GetComponentData<Translation>(next).Value;

                    if( Utils.CirclesOverlap(a, ar, b, br))
                    {
                        EntityManager.AddComponent(curr, typeof(CollisionTag));
                        EntityManager.AddComponent(next, typeof(CollisionTag));
                    }
                }
            }
        }

        keys.Dispose();



    }


}

This produces the results I showed in he third gif with the same CollisionHandling system.

I will try this when I get off work and get back to you, thanks

Alright after a lot of debugging and help from sngdan on the discord I was able to resolve my problem. Of course it has nothing to do with jobs and was just a dumb error on my part.

For my collision system I want to test each entity in a cell against every other entity in a cell. What my code was originally doing instead is testing the FIRST entity in a cell against every other entity and then stopping. The reason my main thread version appeared to work is that by pure chance my large collider happened to be the first entity in the cell every frame, so it would always be tested against all bullets.

I will say that working with the NativeMultiHashmap is very awkward, at least in my case. All I want to do is access each set of values as a list. There is the “IJobNativeMultiHashMapVisitKeyValue” job but since it only accesses each individual element one at a time it wasn’t useful in my case. In fact I have a hard time imagining it’s super useful in most cases, but I’ll chalk that up to a lack of imagination/experience on my part. Eventually i just did it myself - for each cell I create a list, add the entities for that cell, then operate on them like normal.

It was suggested to me that just using dynamic buffers instead of a NativeMultiHashmap is probably a better solution. It will probably work faster and with the Hashmap you need to set a hard limit on the number of values when you create it. You could always re-create the Hashmap with a larger limit if needed, but a dynamic buffers solution would just do that for you under the hood. If I did this again I would probably use dynamic buffers instead.

In the end none of it was technically an ECS problem but here’s the fixed code anyways, it’s a working jobified collision system. It’s very simple and naive by design and uses a spatial map for the broad phase test.

CollisionSystem

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

//[DisableAutoCreation]
public class CollisionSystem : JobComponentSystem
{
    NativeMultiHashMap<int, Entity> spatialMap_;
    NativeList<int> keysList_;

    static readonly int CellSize = 20;

    EndSimulationEntityCommandBufferSystem initBufferSystem_;

    [BurstCompile]
    struct BuildSpatialMap : IJobForEachWithEntity<Translation, ECSCollider>
    {
        public NativeMultiHashMap<int, Entity>.Concurrent spatialMap;

        public void Execute(Entity e, int chunkIndex, [ReadOnly] ref Translation translation, [ReadOnly] ref ECSCollider coll)
        {
            float2 p = translation.Value.xy;
          
            Utils.VisitGridIndices(p, coll.radius, e, spatialMap.Add, CellSize);
        }
    }
  
    [BurstCompile]
    struct InitializeKeysList : IJob
    {
        public NativeList<int> list;

        [ReadOnly]
        public NativeMultiHashMap<int, Entity> spatialMap;

        public void Execute()
        {
            var keys = spatialMap.GetKeyArray(Allocator.Temp);

            if (keys.Length == 0)
                return;
          
            list.Add(keys[0]);
            int lastKey = keys[0];

            for ( int i = 0; i < keys.Length; ++i )
            {
                if (lastKey == keys[i] || list.Contains(keys[i]))
                    continue;
                lastKey = keys[i];
                list.Add(keys[i]);
            }
          
        }
    };

    //[BurstCompile]
    struct GenerateCollisionData : IJobParallelForDefer
    {
        [ReadOnly]
        public NativeMultiHashMap<int, Entity> spatialMap;

        [ReadOnly]
        public NativeArray<int> keys;

        [ReadOnly]
        public ComponentDataFromEntity<ECSCollider> colliderFromEntity;

        [ReadOnly]
        public ComponentDataFromEntity<Translation> posFromEntity;
      
        [WriteOnly]
        public NativeQueue<CollisionData>.Concurrent dataQueue;

        public EntityCommandBuffer.Concurrent commandBuffer;
      
        public void Execute(int index)
        {
            NativeList<Entity> entities = new NativeList<Entity>(Allocator.Temp);

            NativeMultiHashMapIterator<int> it;

            Entity curr;
            if( spatialMap.TryGetFirstValue(keys[index], out curr, out it ))
            {
                entities.Add(curr);
                Entity next;
                while (spatialMap.TryGetNextValue(out next, ref it))
                {
                    var aColl = colliderFromEntity[curr];
                    var bColl = colliderFromEntity[next];
                    var a = posFromEntity[curr].Value;
                    var b = posFromEntity[next].Value;

                    entities.Add(next);
                }
            }

            for( int i = 0; i < entities.Length; ++i )
            {
                for( int j = i + 1; j < entities.Length; ++j )
                {
                    var a = entities[i];
                    var b = entities[j];

                    var aColl = colliderFromEntity[a];
                    var bColl = colliderFromEntity[b];
                    var aPos = posFromEntity[a].Value;
                    var bPos = posFromEntity[b].Value;

                    if( Utils.CirclesOverlap(aPos, aColl.radius, bPos, bColl.radius) )
                    {
                        commandBuffer.AddComponent(index, a, new CollisionTag());
                        commandBuffer.AddComponent(index, b, new CollisionTag());
                    }
                }
            }
        }
    }
  
    protected override void OnCreate()
    {
        spatialMap_ = new NativeMultiHashMap<int, Entity>(5000, Allocator.Persistent);
        keysList_ = new NativeList<int>(Allocator.Persistent);
        initBufferSystem_ = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }

    protected override void OnDestroy()
    {
        spatialMap_.Dispose();
        keysList_.Dispose();
      
    }

    protected override JobHandle OnUpdate(JobHandle inputDependencies)
    {
        var job = inputDependencies;

        spatialMap_.Clear();
        keysList_.Clear();

        // Build our spatial map
        job = new BuildSpatialMap
        {
            spatialMap = spatialMap_.ToConcurrent(),
        }.Schedule(this, job);

        // Initialize the size of our keys list. We can't know the size of our list
        // during schedule time so we need to use "DeferredJobArray"
        // Example in Packages/Jobs/Unity.Jobs.Test/NativeListDeferredArrayTests
        job = new InitializeKeysList
        {
            list = keysList_,
            spatialMap = spatialMap_,
        }.Schedule(job);

        // Check for collisions and tag entities
        job = new GenerateCollisionData
        {
            spatialMap = spatialMap_,
            keys = keysList_.AsDeferredJobArray(),
            colliderFromEntity = GetComponentDataFromEntity<ECSCollider>(true),
            posFromEntity = GetComponentDataFromEntity<Translation>(true),
            commandBuffer = initBufferSystem_.CreateCommandBuffer().ToConcurrent(),
        }.Schedule(keysList_, 5, job);

        return job;
    }
}

And the results:
4579438--426244--ftQpUq4EH9.gif

Also I’m not sure if this is a bug or not but the keys returned by NativeMultiHashMap.GetKeyArray will contain duplicates if you’ve performed multiple adds with the same key. IE:

    [Test]
    public void GetKeysReturnsDuplicates()
    {
        NativeMultiHashMap<int, int> map = new NativeMultiHashMap<int, int>(100, Allocator.Temp);

        map.Add(0, 10);
        map.Add(0, 11);
        map.Add(0, 12);
        map.Add(0, 13);
        map.Add(0, 14);

        var keys = map.GetKeyArray(Allocator.Temp);

        Assert.AreEqual(keys.Length, 1); // Fails wth was 5 expected 1
    }

Glad you got it working - I had a quick look and well, it’s not the cleanest nor fastest implementation, but as long as it works, congrats !

.GetKeyArray is one version after the last API I used, but I looked into this in the release notes back then. If I recall correctly you have also something that gets you an array of values? Then those two arrays align, I think there is also a version without duplicates…

keyArray = 0, 0, 0, 0, 0, 1, 1, 2
valueArray = 10, 11, 12, 13, 14, 100, 101, 201

The red additions expand on your example to illustrate —

1 Like

Ahh okay, that makes sense. And you’re right, I never noticed it but there is NativeMultiHashMap.GetUniqueKeyArray. Good to know, thank you.

Edit: Just tested this and while GetUniqeKeyArray works it unfortunately returns a System.Tuple, which is a reference type, so it will prevent burst jobs from compiling. I’m not sure why it doesn’t return a ValueTuple.