Why slow access to NativeArray results and ColliderHit in multithreading scenario?

I’ve been working on a project that involves managing multiple combatants in a scene, and I wanted to take advantage of multithreading to improve the performance. However, I’ve run into an issue where accessing the NativeArray results and ColliderHit objects after the job is complete seems to be slow, which seems to offset any benefits I would gain from using Jobs.

In my CombatantManager class, I’m using the OverlapSphereCommand.ScheduleBatch() method to perform a batched overlap sphere query for each combatant. After that, I loop through the NativeArray results to check for collisions and update the combatants’ lists of nearby combatants.

Here’s the code I’m using:

using System.Collections.Generic;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
using UnityEngine.Jobs;
public class CombatantManager : MonoBehaviour {
    public static CombatantManager instance;

    public Dictionary<Collider, Combatant> combatantsInScene = new Dictionary<Collider, Combatant> ();
    private void Awake () {
        if (instance == null) {
            instance = this;
        } else {
            Destroy (gameObject);
            return;
        }
    }

    private void Update () {
        UpdateCombatantDistances ();
    }

    public void RegisterCombatant (Combatant combatant) {
        combatantsInScene.Add (combatant.collider, combatant);
    }

    public void UnregisterCombatant (Combatant combatant) {
        combatantsInScene.Remove (combatant.collider, out combatant);
    }

    public void UpdateCombatantDistances () {
        int combatantCount = combatantsInScene.Count;
        int maxHits = 1000;

        var commands = new NativeArray<OverlapSphereCommand> (combatantCount, Allocator.TempJob);
        var results = new NativeArray<ColliderHit> (combatantCount * maxHits, Allocator.TempJob);

        int index = 0;
        foreach (var combatant in combatantsInScene) {
            QueryParameters qParam = new QueryParameters {
                layerMask = combatant.Value.combatantDetectionLayerMask,
            };
            commands[index] = new OverlapSphereCommand (combatant.Value.position, combatant.Value.detectionRadius, qParam);
            index++;
        }
        int numCombatants = combatantsInScene.Count;
        int minCommandsPerJob = Mathf.Max (1, numCombatants / SystemInfo.processorCount);
        JobHandle handle = OverlapSphereCommand.ScheduleBatch (commands, results, minCommandsPerJob, maxHits);
        handle.Complete ();

        index = 0;
        foreach (var combatant in combatantsInScene) {
            List<Combatant> combatantsInRange = new List<Combatant> ();

            for (int i = index * maxHits; i < (index + 1) * maxHits; i++) {
                Collider col = results[i].collider;
                if (col) {
                    combatantsInScene.TryGetValue (col, out Combatant com);
                    if (com) {
                        combatantsInRange.Add (com);
                    }
                }
            }

            combatant.Value.UpdateCombatantsInRange (combatantsInRange);
            index++;
        }

        commands.Dispose ();
        results.Dispose ();
    }
}

The problem is that iterating through the results array and also accessing the ColliderHit objects after the job has completed are both taking a significant amount of time. This is causing the performance of the whole system to suffer, and the multithreading benefits I was hoping to achieve are being negated.

I was wondering if anyone else has encountered this issue or has any suggestions for improving the performance of this part of the code. Is there a more efficient way to access the results and ColliderHit objects?

Any help or suggestions would be greatly appreciated. Thank you!

I’m happy to provide some help, but I have some questions first:
Have you profiled the code (using Profiler.BeginSample/EndSample for instance) and made sure that it’s the for loops at the end of the code that is slow, just so that you’re not attacking the wrong parts of the code? :slight_smile:

Also, how many combatants in general do you usually have in the scene, and how many of them do you expect to be in range of each other? Depending on this number, it might be that you’re better off with some spatial partitioning algorithm to find everything in range rather than some sphere casting.

I also notice that in your loop, you go from index * maxHits to (index + 1) * maxHits, and check that the collider isn’t null for each iteration. In the documentation of OverlapSphereCommand it says that after the first hit with a collider == null (or collider instance id of 0) all hits will be invalid. So from my understanding you can break out of your inner index loop if you find a result with a collider == null.

I think ColliderHit is also a struct that can be used in Jobs and burst, so if you can make your code burstable, then you’ll probably get some performance benefits there.

1 Like

I did but I think my brain was off. I was attacking the wrong parts of the code, as you suggested. It turns out there were other issues with my code that were much bigger concerns performance wise and addressing those seemed to help.

At the time there were about 100-200 combatants onscreen before performance dipped below 60 frames in a build. Couldnt tell you how many were expected to be within range of each other but the answer is certainly ‘a lot’ or ‘over 50-100’ if the max was 200. I actually decided to forgo overlap sphere and went with a spatial partition, as you suggested. I made a grid that keeps track of combatants per cell and I only pull from cells within range of a combatant to do the checks. This process also happens within a Burst Compiled job. This allows me to get up to 500-1000 combatants on screen fighting each other before it dips below 60 frames in a build.

So this thread is pretty much solved. Here’s my CombatantManager now, now renamed to CombatantSpatialPartition, and is significantly longer than before, but provides much faster performance:

using System.Collections.Generic;
using System.Linq;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

#if UNITY_EDITOR
using UnityEditor;
#endif

using UnityEngine;

public class CombatantSpatialPartition : MonoBehaviour {
    public static CombatantSpatialPartition Instance;
    public int gridSize = 10;
    public float cellSize = 10f;
    public List<Combatant> combatantsInScene;
    public List<CombatantInfo>[] grid;

    float3 gridPosition;
    public static int FloorToInt (float f) => f >= 0 ? (int) f : ((int) f == f ? (int) f : (int) f - 1);
    public static int CeilToInt (float f) => f < 0 ? (int) f : ((int) f + ((f - (int) f != 0) ? 1 : 0));
    public static int count;

    void Awake () {
        if (Instance != null) {
            Destroy (gameObject);
        }
        Instance = this;
        combatantsInScene = new List<Combatant> ();
        // Initialize the grids dictionary for each team
        grid = new List<CombatantInfo>[gridSize * gridSize * 2];
        for (int i = 0; i < gridSize * gridSize * 2; i++) {
            grid[i] = new List<CombatantInfo> ();
        }
    }

    void Update () {
        gridPosition = transform.position;
        ManageCombatants ();
    }

    bool equals (int2 x, int2 y) {
        return x.x == y.x && x.y == y.y;
    }

    public void AddCombatant (CombatantInfo combatantInfo) {
        int2 cell = WorldToGrid (combatantInfo.position);
        int linearIndex = combatantInfo.teamID * (gridSize * gridSize) + cell.x * gridSize + cell.y;
        grid[linearIndex].Add (combatantInfo);
        count++;
    }

    public void UpdateCombatant (CombatantInfo combatantInfo, float3 oldPosition) {
        int2 oldCell = WorldToGrid (oldPosition);
        RemoveCombatant (combatantInfo, oldCell);
        AddCombatant (combatantInfo);
    }

    public void RemoveCombatant (CombatantInfo combatantInfo, int2 cell) {
        int linearIndex = combatantInfo.teamID * (gridSize * gridSize) + cell.x * gridSize + cell.y;
        grid[linearIndex].Remove (combatantInfo);
        count--;
    }

    public void RemoveCombatantDeep (CombatantInfo combatantInfo) {
        for (int x = 0; x < gridSize; x++) {
            for (int y = 0; y < gridSize; y++) {
                int linearIndex = combatantInfo.teamID * (gridSize * gridSize) + x * gridSize + y;
                if (grid[linearIndex].Contains (combatantInfo)) {
                    grid[linearIndex].Remove (combatantInfo);
                    count--;
                }
            }
        }
    }

    public void ManageCombatants () {
        int count = combatantsInScene.Count;

        // Perform a single sphere cast using SpherecastCommand and wait for it to complete
        // Set up the command and result buffers
        var results = new NativeArray<RaycastHit> (combatantsInScene.Count, Allocator.TempJob);
        var commands = new NativeArray<SpherecastCommand> (combatantsInScene.Count, Allocator.TempJob);


        for (int i = 0; i < count; i++) {
            Combatant combatant = combatantsInScene[i];
            CombatantInfo info = combatant.GetInfo ();
            //combatant.nearbyCombatants = ManageNearbyCells (info);
            NativeList<int2> nearbyCells = new NativeList<int2> (Allocator.TempJob);

            GetNearbyCellsJob job = new GetNearbyCellsJob {
                combatantInfo = info,
                gridSize = gridSize,
                cellSize = cellSize,
                gridPosition = gridPosition,
                nearbyCells = nearbyCells
            };

            JobHandle jobHandle = job.Schedule ();
            jobHandle.Complete ();

            List<int2> nearbyCellsList = new List<int2> ();
            for (int j = 0; j < nearbyCells.Length; j++) {
                nearbyCellsList.Add (nearbyCells[j]);
            }
            nearbyCells.Dispose ();
            combatant.nearbyCells = nearbyCellsList;




             if (!combatant.seekingEnemy) {
                continue;
            }
            // Set the data of the first command
            Vector3 origin = combatant.position;
            Vector3 direction = (combatant.closestEnemyPosition - (Vector3) combatant.position).normalized;
            float radius = combatant.spherecastRadius;
            QueryParameters queryParameters = new QueryParameters {
                layerMask =combatant.detectionMask,
            };

            commands[i] = new SpherecastCommand (origin, radius, direction, queryParameters, Vector3.Distance (combatant.position, combatant.closestEnemyPosition)) {
                queryParameters = queryParameters,
            };
        }

        // Schedule the batch of sphere casts
        var sphereCastHandle = SpherecastCommand.ScheduleBatch (commands, results, count / SystemInfo.processorCount, default (JobHandle));

        // Wait for the batch processing job to complete
        sphereCastHandle.Complete ();

        for (int i = 0; i < count; i++) {
            combatantsInScene[i].lastSphereCastHit = results[i];
        }

        // Dispose the buffers
        results.Dispose ();
        commands.Dispose ();
    }

    public float3 GridToWorld (int2 gridPosition) {
        float3 min = this.gridPosition - new float3 (gridSize * cellSize, 0, gridSize * cellSize) * 0.5f;
        return new float3 (min.x + gridPosition.x * cellSize + cellSize / 2, 0, min.z + gridPosition.y * cellSize + cellSize / 2);
    }

    public int2 WorldToGrid (float3 pos) {
        float3 min = gridPosition - new float3 (gridSize * cellSize, 0, gridSize * cellSize) * 0.5f;
        int2 gridCoordinates = new int2 (
            Mathf.FloorToInt ((pos.x - min.x) / cellSize),
            Mathf.FloorToInt ((pos.z - min.z) / cellSize)
        );

        // Clamp the grid coordinates within the grid bounds
        gridCoordinates.x = Mathf.Clamp (gridCoordinates.x, 0, gridSize - 1);
        gridCoordinates.y = Mathf.Clamp (gridCoordinates.y, 0, gridSize - 1);

        return gridCoordinates;
    }

    [BurstCompile]
    public struct GetNearbyCellsJob : IJob {
        [ReadOnly] public CombatantInfo combatantInfo;
        [ReadOnly] public int gridSize;
        [ReadOnly] public float cellSize;
        [ReadOnly] public float3 gridPosition;
        [WriteOnly] public NativeList<int2> nearbyCells;

        public void Execute () {
            CombatantInfo combatant = combatantInfo;
            float radius = combatant.combatantCheckRadius;
            float3 pos = combatant.position;
            int2 centerCell = WorldToGrid (pos);

            int combatantTeamID = combatant.teamID;
            int enemyTeamID = combatantTeamID == 0 ? 1 : 0;
            int cellsToCheck = CeilToInt (radius / cellSize);
            int2 minCell = new int2 (math.max (0, centerCell.x - cellsToCheck), math.max (0, centerCell.y - cellsToCheck));
            int2 maxCell = new int2 (math.min (gridSize - 1, centerCell.x + cellsToCheck), math.min (gridSize - 1, centerCell.y + cellsToCheck));
            int totalCells = (maxCell.x - minCell.x + 1) * (maxCell.y - minCell.y + 1);
            for (int i = 0; i < totalCells; i++) {
                int x = minCell.x + (i % (maxCell.x - minCell.x + 1));
                int z = minCell.y + (i / (maxCell.x - minCell.x + 1));
                int2 cell = new int2 (x, z);

                // Check if the cell is within the grid boundaries
                if (x >= 0 && x < gridSize && z >= 0 && z < gridSize) {
                    // Calculate the cell's center position in world coordinates
                    float3 cellCenter = GridToWorld (cell);
                    // Check if the cell is within the combatant's radius
                    if (math.distance (pos, cellCenter) <= radius + math.sqrt (2 * cellSize * cellSize) / 2) {
                        int linearIndex = enemyTeamID * (gridSize * gridSize) + x * gridSize + z;
                        nearbyCells.Add (cell);
                    }
                }
            }
        }

        public float3 GridToWorld (int2 gridPosition) {
            float3 min = this.gridPosition - new float3 (gridSize * cellSize, 0, gridSize * cellSize) * 0.5f;
            return new float3 (min.x + gridPosition.x * cellSize + cellSize / 2, 0, min.z + gridPosition.y * cellSize + cellSize / 2);
        }

        public int2 WorldToGrid (float3 pos) {
            float3 min = gridPosition - new float3 (gridSize * cellSize, 0, gridSize * cellSize) * 0.5f;
            int2 gridCoordinates = new int2 (
                Mathf.FloorToInt ((pos.x - min.x) / cellSize),
                Mathf.FloorToInt ((pos.z - min.z) / cellSize)
            );

            // Clamp the grid coordinates within the grid bounds
            gridCoordinates.x = Mathf.Clamp (gridCoordinates.x, 0, gridSize - 1);
            gridCoordinates.y = Mathf.Clamp (gridCoordinates.y, 0, gridSize - 1);

            return gridCoordinates;
        }
    }

    void OnDrawGizmos () {
        if (!Application.isPlaying) {
            return;
        }
        Vector3 min = transform.position - new Vector3 (gridSize * cellSize, 0, gridSize * cellSize) * 0.5f;
        for (int x = 0; x < gridSize; x++) {
            for (int z = 0; z < gridSize; z++) {
                int2 cellCoord = new int2 (x, z);
                Vector3 cellCenter = new Vector3 (min.x + x * cellSize + cellSize / 2, 0, min.z + z * cellSize + cellSize / 2);

                int combatantCount = 0;
                if (grid != null) {
                    for (int i = 0; i < 2; i++) {
                        int linearIndex = i * (gridSize * gridSize) + x * gridSize + z;
                        foreach (var teamGrids in grid[linearIndex]) {
                            combatantCount++;
                        }
                    }
                }
                // Fill cell with color based on combatant count
                Gizmos.color = combatantCount > 0 ? Color.green : Color.white;
                Gizmos.DrawWireCube (cellCenter, new Vector3 (cellSize, 0.1f, cellSize));
                // Draw combatant count label on cell

                GUIStyle style = new GUIStyle ();
                style.normal.textColor = Color.black;
                style.alignment = TextAnchor.MiddleCenter;
                style.fontSize = 14;
#if UNITY_EDITOR
                Handles.Label (cellCenter, combatantCount.ToString (), style);
#endif
            }
        }
        Gizmos.color = Color.white;
    }
}

Note: Some comments are artifacts from previous script versions