# 5. Worked Example Compute Swarm
Part of [[Compute Shaders and GPU Instancing]]. Prev: [[4. Indirect Draws with GraphicsBuffer]]. Next: [[6. GPU Frustum Culling with Append Buffers]].
## 5.1 The behaviour (boids)
Each agent looks at neighbours within a radius and steers by:
| Rule | Steering | Feel |
|---|---|---|
| **Separation** | away from neighbours that are too close (weighted by 1/distance^2) | no clumping |
| **Alignment** | towards the neighbours' average velocity | schooling |
| **Cohesion** | towards the neighbours' average position | the group holds together |
| **Seek** | towards a target transform | gameplay control: lure the swarm |
| **Containment** | back towards the centre when outside a sphere | keeps the bounds valid for culling |
## 5.2 Why ping-pong buffers?
If agents read neighbours from the same buffer they write to, some neighbours are already this frame's values and some are last frame's, depending on which GPU groups ran first. That is a data race, and it shows as jitter. The **Double Buffer** pattern ([[Game Programming Patterns (Review)]]) fixes it: read `_AgentsIn`, write `_AgentsOut`, swap references in C# each frame.
## 5.3 Why groupshared tiles?
Brute force is O(N^2): every agent reads every other agent. Reading each neighbour from global memory N times is the slow part. Instead, each group of 256 threads cooperatively loads 256 agents into fast `groupshared` memory, synchronises, and every thread tests against that tile. Global reads drop by a factor of the group size. It is still O(N^2) arithmetic (fine for ~10-30k agents on a desktop GPU; measure), and the exercise turns it into a grid.
## 5.4 `Swarm.compute`
```hlsl
#pragma kernel CSSimulate
#pragma kernel CSCull
#define GROUP_SIZE 256
struct Agent { float3 position; float3 velocity; };
StructuredBuffer<Agent> _AgentsIn;
RWStructuredBuffer<Agent> _AgentsOut;
uint _Count;
float _DeltaTime;
float _NeighborRadius;
float _SeparationRadius;
float _SeparationWeight;
float _AlignmentWeight;
float _CohesionWeight;
float _SeekWeight;
float _MaxSpeed;
float _MaxForce;
float3 _Target;
float3 _Center;
float _ContainRadius;
groupshared Agent sTile[GROUP_SIZE];
float3 Limit(float3 v, float maxLen)
{
float l = length(v);
return l > maxLen ? v * (maxLen / l) : v;
}
[numthreads(GROUP_SIZE, 1, 1)]
void CSSimulate(uint3 id : SV_DispatchThreadID, uint gi : SV_GroupIndex)
{
uint i = id.x;
bool active = i < _Count;
Agent self = active ? _AgentsIn[i] : (Agent)0;
float r2 = _NeighborRadius * _NeighborRadius;
float s2 = _SeparationRadius * _SeparationRadius;
float3 sep = 0, ali = 0, coh = 0;
uint neighbors = 0;
// Every thread in the group must reach every barrier, so no early return before this loop.
for (uint tileStart = 0; tileStart < _Count; tileStart += GROUP_SIZE)
{
uint j = tileStart + gi;
sTile[gi] = (j < _Count) ? _AgentsIn[j] : (Agent)0;
GroupMemoryBarrierWithGroupSync();
if (active)
{
uint tileCount = min(GROUP_SIZE, _Count - tileStart);
for (uint k = 0; k < tileCount; k++)
{
if (tileStart + k == i) continue;
float3 d = sTile[k].position - self.position;
float dist2 = dot(d, d);
if (dist2 < r2)
{
ali += sTile[k].velocity;
coh += sTile[k].position;
neighbors++;
if (dist2 < s2) sep -= d / max(dist2, 1e-4);
}
}
}
GroupMemoryBarrierWithGroupSync();
}
if (!active) return;
float3 steer = 0;
if (neighbors > 0)
{
float inv = 1.0 / neighbors;
float3 desiredAli = normalize(ali * inv + 1e-5) * _MaxSpeed;
float3 desiredCoh = normalize(coh * inv - self.position + 1e-5) * _MaxSpeed;
steer += Limit(desiredAli - self.velocity, _MaxForce) * _AlignmentWeight;
steer += Limit(desiredCoh - self.velocity, _MaxForce) * _CohesionWeight;
steer += Limit(sep * _MaxSpeed, _MaxForce * 2) * _SeparationWeight;
}
float3 toTarget = _Target - self.position;
steer += Limit(normalize(toTarget + 1e-5) * _MaxSpeed - self.velocity, _MaxForce) * _SeekWeight;
float3 fromCenter = self.position - _Center;
float over = length(fromCenter) - _ContainRadius;
if (over > 0) steer -= normalize(fromCenter) * over * _MaxForce;
float3 v = Limit(self.velocity + steer * _DeltaTime, _MaxSpeed);
Agent o;
o.velocity = v;
o.position = self.position + v * _DeltaTime;
_AgentsOut[i] = o;
}
// ---- Part 6: frustum culling into an append buffer ----
AppendStructuredBuffer<uint> _Visible;
float4 _FrustumPlanes[6]; // xyz = normal, w = distance (Unity Plane convention)
float _CullRadius;
[numthreads(GROUP_SIZE, 1, 1)]
void CSCull(uint3 id : SV_DispatchThreadID)
{
uint i = id.x;
if (i >= _Count) return;
float3 p = _AgentsOut[i].position;
[unroll]
for (uint k = 0; k < 6; k++)
{
if (dot(_FrustumPlanes[k].xyz, p) + _FrustumPlanes[k].w < -_CullRadius) return;
}
_Visible.Append(i);
}
```
### Kernel walkthrough
1. **Load self** (or a zero agent for threads past `_Count`; they still help load tiles).
2. **Tile loop**: each thread loads one agent into `sTile[gi]`, then `GroupMemoryBarrierWithGroupSync()` waits until the whole tile is loaded. Every thread then scans the tile. A second barrier stops anyone overwriting the tile while others still read it.
3. **No early return before the loop**: a thread that returns early never reaches the barrier, which is undefined behaviour on many GPUs. That is why `active` is a flag.
4. **Steering**: Craig Reynolds-style "desired velocity minus current velocity", clamped to `_MaxForce`, weighted per rule.
5. **Containment** pushes agents back inside `_ContainRadius`, which keeps the renderer's bounds honest.
6. **Integrate** with `_DeltaTime` and write to the *other* buffer.
`CSCull` is used in part 6. It reads `_AgentsOut` (this frame's result), which is why the C# binds the output buffer to it.
## 5.5 `ComputeSwarm.cs`
```csharp
using UnityEngine;
/// <summary>
/// Owns the agent buffers, runs Swarm.compute each frame, and hands the result to an IndirectInstanceRenderer.
/// Optional: AudioFrameSource (Music Visualization) boosts speed with loudness. NOT compiled.
/// </summary>
public sealed class ComputeSwarm : MonoBehaviour
{
[SerializeField] ComputeShader swarmShader;
[SerializeField] IndirectInstanceRenderer instanceRenderer;
[SerializeField] Transform target;
[Header("Population")]
[SerializeField] int count = 16384;
[SerializeField] float spawnRadius = 30f;
[SerializeField] float containRadius = 60f;
[Header("Boids")]
[SerializeField] float neighborRadius = 3f;
[SerializeField] float separationRadius = 1f;
[SerializeField] float separationWeight = 1.5f;
[SerializeField] float alignmentWeight = 1f;
[SerializeField] float cohesionWeight = 0.8f;
[SerializeField] float seekWeight = 0.4f;
[SerializeField] float maxSpeed = 8f;
[SerializeField] float maxForce = 12f;
[Header("Audio (optional; from Music Visualization)")]
[SerializeField] AudioFrameSource audioSource;
[SerializeField] float audioSpeedBoost = 2f;
static readonly int InId = Shader.PropertyToID("_AgentsIn");
static readonly int OutId = Shader.PropertyToID("_AgentsOut");
static readonly int CountId = Shader.PropertyToID("_Count");
static readonly int DtId = Shader.PropertyToID("_DeltaTime");
static readonly int NeighborId = Shader.PropertyToID("_NeighborRadius");
static readonly int SepRadId = Shader.PropertyToID("_SeparationRadius");
static readonly int SepWId = Shader.PropertyToID("_SeparationWeight");
static readonly int AliWId = Shader.PropertyToID("_AlignmentWeight");
static readonly int CohWId = Shader.PropertyToID("_CohesionWeight");
static readonly int SeekWId = Shader.PropertyToID("_SeekWeight");
static readonly int MaxSpeedId = Shader.PropertyToID("_MaxSpeed");
static readonly int MaxForceId = Shader.PropertyToID("_MaxForce");
static readonly int TargetId = Shader.PropertyToID("_Target");
static readonly int CenterId = Shader.PropertyToID("_Center");
static readonly int ContainId = Shader.PropertyToID("_ContainRadius");
public GraphicsBuffer Current => _b; // this frame's output (after Update)
public int Count => count;
public int CullKernel { get; private set; }
public ComputeShader SwarmShader => swarmShader;
GraphicsBuffer _a, _b;
int _simKernel;
uint _groupSize;
void OnEnable()
{
if (!SystemInfo.supportsComputeShaders) { Debug.LogError("No compute support."); enabled = false; return; }
_simKernel = swarmShader.FindKernel("CSSimulate");
CullKernel = swarmShader.FindKernel("CSCull");
swarmShader.GetKernelThreadGroupSizes(_simKernel, out _groupSize, out _, out _);
var data = new SwarmAgent[count];
for (int i = 0; i < count; i++)
{
data[i].position = transform.position + Random.insideUnitSphere * spawnRadius;
data[i].velocity = Random.onUnitSphere * maxSpeed * 0.5f;
}
_a = new GraphicsBuffer(GraphicsBuffer.Target.Structured, count, SwarmAgent.Stride);
_b = new GraphicsBuffer(GraphicsBuffer.Target.Structured, count, SwarmAgent.Stride);
_a.SetData(data);
_b.SetData(data);
}
void OnDisable()
{
_a?.Release(); _a = null;
_b?.Release(); _b = null;
}
void Update()
{
float speed = maxSpeed;
if (audioSource != null && audioSource.HasData) speed *= 1f + audioSource.Rms * audioSpeedBoost;
var cs = swarmShader;
cs.SetInt(CountId, count);
cs.SetFloat(DtId, Mathf.Min(Time.deltaTime, 1f / 30f)); // clamp so a hitch does not explode the flock
cs.SetFloat(NeighborId, neighborRadius);
cs.SetFloat(SepRadId, separationRadius);
cs.SetFloat(SepWId, separationWeight);
cs.SetFloat(AliWId, alignmentWeight);
cs.SetFloat(CohWId, cohesionWeight);
cs.SetFloat(SeekWId, target != null ? seekWeight : 0f);
cs.SetFloat(MaxSpeedId, speed);
cs.SetFloat(MaxForceId, maxForce);
cs.SetVector(TargetId, target != null ? target.position : transform.position);
cs.SetVector(CenterId, transform.position);
cs.SetFloat(ContainId, containRadius);
// Ping-pong: read last frame's result (_b), write into _a, then swap so _b is always "latest".
(_a, _b) = (_b, _a);
cs.SetBuffer(_simKernel, InId, _a);
cs.SetBuffer(_simKernel, OutId, _b);
cs.Dispatch(_simKernel, Mathf.CeilToInt(count / (float)_groupSize), 1, 1);
instanceRenderer.SetSource(_b, count);
}
}
```
> [!note] Swap order
> After the swap, `_a` holds last frame's data (input) and `_b` receives this frame's (output). `Current` therefore always returns the freshest buffer, which the renderer and the culling pass use. Calling `SetSource` every frame re-binds the buffer that just changed identity and rewrites the CPU instance count; with culling (part 6) the GPU overwrites that count afterwards.
`AudioFrameSource` (`HasData`, `Rms`) is the shared audio source from [[Music Visualization]] section 2. Delete that field if you are not using it. Point `AudioFrameSource` at live input and your Mother-32 sets the swarm's pace.
## 5.6 Scene setup
1. Create `Swarm.compute` and `SwarmIndirectURP.shader` from this and the previous part; make a material from the shader.
2. Empty GameObject **Swarm** at the arena centre: `IndirectInstanceRenderer` (mesh: a small cone or a low-poly fish, material: the swarm material, bounds 2 x `containRadius` plus margin) and `ComputeSwarm`.
3. A **Lure** object (child of the player, or a glowing orb) as `target`.
4. Press Play. Tune: raise `separationWeight` if they clump, raise `alignmentWeight` for schooling, lower `seekWeight` for a lazier swarm.
## 5.7 Gameplay hooks (Level Up!)
- **Lure and release:** the player carries the lure; dropping it into a trap is a puzzle (Level 12 mechanics, [[Hazards, Traps and Key-Door Puzzles]]).
- **Telegraph:** before the swarm attacks, set `seekWeight` high for a moment and shift `_FastColor`; the colour change is the tell (Level 11).
- **Damage:** do not read 16k positions back. Count agents inside a sphere around the player in a tiny kernel with `InterlockedAdd` and `AsyncGPUReadback` the single `uint` ([[3. Compute Shader Fundamentals]] 3.5). A count above a threshold applies damage through `IDamageable` from [[Combat Feel and Hit Feedback]].
## 5.8 Exercises
1. **Spatial grid.** Replace the O(N^2) tile loop with a uniform grid: kernel 1 computes each agent's cell hash, a sort (bitonic) orders agents by cell, kernel 2 finds cell start/end, kernel 3 scans only the 27 neighbouring cells. Compare with the CPU spatial hash in [[DOTS]] part 5.
2. **Obstacles.** Pass up to 8 sphere obstacles as a `float4` array and add an avoidance term.
3. **VFX Graph trails.** Bind `Current` to a VFX Graph with `SetGraphicsBuffer` and spawn short trails per agent ([[VFX Graph]] part 5).
## Sources
- https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ComputeShader.Dispatch.html
- https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ComputeShader.SetBuffer.html
- https://docs.unity3d.com/6000.5/Documentation/ScriptReference/GraphicsBuffer.html
- HLSL `GroupMemoryBarrierWithGroupSync` (Microsoft): https://learn.microsoft.com/windows/win32/direct3dhlsl/groupmemorybarrierwithgroupsync
- Craig Reynolds, "Boids": https://www.red3d.com/cwr/boids/