# 5. Spatial Hashing: Separation and Damage Part of [[DOTS]]. Previous: [[4. Worked Example - Swarm Enemies]]. Next: [[6. Bridging ECS with GameObjects UI and VFX]]. Seek-only swarms collapse into one blob. **Separation** pushes neighbours apart, but checking every enemy against every other enemy is 10,000² = 100 million checks. A **spatial hash** (uniform grid) cuts that to "my cell plus the 8 around it". This is **Spatial Partition** ([[Game Programming Patterns (Review)]]). > [!note] Verification status > `NativeParallelMultiHashMap` (Collections 6.5): `AsParallelWriter()`, `Add`, `TryGetFirstValue`/`TryGetNextValue`, `Dispose(JobHandle)`. Also `IJobEntity.ScheduleParallel(EntityQuery, JobHandle)` and `SystemAPI.QueryBuilder()`. **Code not compiled.** ## 5.1 Steering system with a grid **Delete or disable `SwarmSeekSystem` from part 4.** This replaces it. ```csharp // Assets/Scripts/DOTS/Swarm/SwarmSteeringSystem.cs using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(SwarmAuraDamageSystem))] public partial struct SwarmSteeringSystem : ISystem { EntityQuery agentQuery; public static int Hash(int2 cell) => (int)math.hash(cell); public static int2 Cell(float3 p, float invCellSize) => (int2)math.floor(p.xz * invCellSize); [BurstCompile] public void OnCreate(ref SystemState state) { agentQuery = SystemAPI.QueryBuilder().WithAll<EnemyTag, SwarmAgent>().WithAllRW<LocalTransform>().Build(); state.RequireForUpdate(agentQuery); state.RequireForUpdate<SwarmSettings>(); state.RequireForUpdate<PlayerTarget>(); } [BurstCompile] public void OnUpdate(ref SystemState state) { SwarmSettings settings = SystemAPI.GetSingleton<SwarmSettings>(); float3 playerPos = SystemAPI.GetSingleton<PlayerTarget>().Position; float inv = 1f / settings.CellSize; int count = agentQuery.CalculateEntityCount(); var grid = new NativeParallelMultiHashMap<int, float3>(count, Allocator.TempJob); // 1) Everyone writes their position into their cell (parallel). JobHandle build = new BuildGridJob { Grid = grid.AsParallelWriter(), InvCellSize = inv, }.ScheduleParallel(agentQuery, state.Dependency); // 2) Everyone reads neighbours (parallel, read-only grid). JobHandle steer = new SteerJob { Grid = grid, InvCellSize = inv, Settings = settings, PlayerPos = playerPos, DeltaTime = SystemAPI.Time.DeltaTime, }.ScheduleParallel(agentQuery, build); // 3) Free the grid when steering finishes. Never Dispose() synchronously here. state.Dependency = grid.Dispose(steer); } } [BurstCompile] public partial struct BuildGridJob : IJobEntity { public NativeParallelMultiHashMap<int, float3>.ParallelWriter Grid; public float InvCellSize; void Execute(in LocalTransform transform) { Grid.Add(SwarmSteeringSystem.Hash(SwarmSteeringSystem.Cell(transform.Position, InvCellSize)), transform.Position); } } [BurstCompile] public partial struct SteerJob : IJobEntity { [ReadOnly] public NativeParallelMultiHashMap<int, float3> Grid; public float InvCellSize; public SwarmSettings Settings; public float3 PlayerPos; public float DeltaTime; void Execute(ref LocalTransform transform, ref SwarmAgent agent) { float3 pos = transform.Position; float3 toPlayer = PlayerPos - pos; toPlayer.y = 0f; float3 seek = math.normalizesafe(toPlayer); float r2 = Settings.SeparationRadius * Settings.SeparationRadius; float3 separation = float3.zero; int2 cell = SwarmSteeringSystem.Cell(pos, InvCellSize); for (int dz = -1; dz <= 1; dz++) for (int dx = -1; dx <= 1; dx++) { int key = SwarmSteeringSystem.Hash(cell + new int2(dx, dz)); if (!Grid.TryGetFirstValue(key, out float3 other, out var it)) continue; do { float3 d = pos - other; d.y = 0f; float d2 = math.lengthsq(d); // d2 > tiny skips ourselves. Hash collisions only add extra candidates; the distance test filters them. if (d2 > 1e-6f && d2 < r2) separation += d / d2; // stronger when closer } while (Grid.TryGetNextValue(out other, ref it)); } float3 desired = math.normalizesafe(seek * Settings.SeekWeight + separation * Settings.SeparationWeight) * Settings.MaxSpeed; agent.Velocity = math.lerp(agent.Velocity, desired, math.saturate(Settings.Responsiveness * DeltaTime)); transform.Position += agent.Velocity * DeltaTime; if (math.lengthsq(agent.Velocity) > 0.01f) transform.Rotation = quaternion.LookRotationSafe(math.normalize(agent.Velocity), math.up()); } } ``` ### Why the job ordering works - `BuildGridJob` reads `LocalTransform` and `SteerJob` writes it. Both are scheduled against the same query, and `SteerJob` depends on `build`, so they can't overlap. - `SteerJob` reads positions from the **grid snapshot**, not from other entities' live transforms. Every agent sees last-frame neighbours, which is consistent and race-free. ## 5.2 Cell size tuning - **cell size ≥ separation radius**. Otherwise the 3×3 neighbourhood misses neighbours. The baker in part 4 enforces this. - Too large means many candidates per cell (slow). Too small means many empty cells (wasted hashing). Start at about 1-1.5× the radius. - Capacity: `count` is a starting capacity, and the map grows if needed. Growth inside a parallel writer isn't allowed, which is why we pre-size it to the entity count. One value per entity fits exactly. ## 5.3 Area damage query (a "bomb") The grid also answers "who is near point P?" Here's a one-off area blast you can trigger from the player: ```csharp // Assets/Scripts/DOTS/Swarm/BlastRequest.cs using Unity.Entities; using Unity.Mathematics; /// Create an entity with this to request a blast; the system consumes it. public struct BlastRequest : IComponentData { public float3 Center; public float Radius; public float Damage; } ``` ```csharp // Assets/Scripts/DOTS/Swarm/BlastSystem.cs using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(SwarmSteeringSystem))] public partial struct BlastSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate<BlastRequest>(); state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>(); } [BurstCompile] public void OnUpdate(ref SystemState state) { var ecbSystem = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); // Two separate buffers: one written by jobs, one by the main thread. var jobEcb = ecbSystem.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter(); var mainEcb = ecbSystem.CreateCommandBuffer(state.WorldUnmanaged); // Usually 0-2 requests per frame: loop requests on the main thread, scan enemies in a job. foreach (var (request, requestEntity) in SystemAPI.Query<RefRO<BlastRequest>>().WithEntityAccess()) { new BlastJob { Center = request.ValueRO.Center, Radius2 = request.ValueRO.Radius * request.ValueRO.Radius, Damage = request.ValueRO.Damage, Ecb = jobEcb, }.ScheduleParallel(); // chained onto state.Dependency mainEcb.DestroyEntity(requestEntity); // consume the request } } } [BurstCompile] [WithAll(typeof(EnemyTag))] public partial struct BlastJob : IJobEntity { public float3 Center; public float Radius2; public float Damage; public EntityCommandBuffer.ParallelWriter Ecb; void Execute([ChunkIndexInQuery] int sortKey, Entity e, in LocalTransform t, ref Health h) { if (math.distancesq(t.Position, Center) > Radius2) return; h.Value -= Damage; if (h.Value <= 0f) Ecb.DestroyEntity(sortKey, e); } } ``` > [!tip] Grid or brute force? > The blast above is a brute-force scan over 10k entities. Burst makes that cheap, and it's simpler than sharing the grid between systems. Use the grid when **every** entity asks about its neighbours (N×N). For a few point queries, a linear Burst scan is often fine. Profile before you add complexity. Using **two** command buffers keeps the job-owned buffer separate from the one the main thread writes, so the safety system has nothing to complain about. Both play back at the end of simulation. The blast is an **Event Queue** in miniature ([[Game Programming Patterns (Review)]]): requests are entities, and a system drains them. ## Sources - Collections 6.5 (`NativeParallelMultiHashMap`): https://docs.unity3d.com/Packages/[email protected]/manual/index.html - Creating entity queries: https://docs.unity3d.com/Packages/[email protected]/manual/systems-entityquery-create.html - Job dependencies in systems: https://docs.unity3d.com/Packages/[email protected]/manual/scheduling-jobs-dependencies.html