Compute Shader Boids
A flock of thousands of boids that lives entirely on the GPU. A compute shader steers every boid against every other one each frame - keep apart, fly the same way, stay together, come home - and writes the world matrices straight into the buffers an instanced cone mesh is drawn from. The CPU fills the buffers once and never touches a boid again: one dispatch, one draw call, nothing per frame. Freeze the flock, scatter it, or switch between two, four and eight thousand boids.
The Program.cs file shows how to:
- Writing a compute shader in SDSL by inheriting ComputeShaderBase and overriding Compute()
- Running it with ComputeEffectShader from a scene renderer placed first in the compositor
- Unordered-access structured buffers, and two of them swapped each frame so reads never race writes
- Filling an InstancingUserBuffer's matrix buffers on the GPU, so the mesh is drawn from data the CPU never sees
- Building a world matrix and its inverse in a shader from a heading
- Using helpers: SetupBase3DScene
- Using helpers: AddSkybox
- Using helpers: Create3DPrimitive
- Using helpers: DebugOverlay

View on GitHub.
using E10_3D_ComputeBoids;
using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.Instancing;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
using Stride.CommunityToolkit.Scripts.Utilities;
using Stride.CommunityToolkit.Skyboxes;
using Stride.CommunityToolkit.Windows;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using Stride.Graphics;
using Stride.Input;
using Stride.Rendering;
using Stride.Rendering.Compositing;
using Stride.Rendering.ComputeEffect;
using Buffer = Stride.Graphics.Buffer;
// A flock of a few thousand boids that lives entirely on the GPU. A compute shader steers every
// boid against every other boid each frame and writes the world matrices straight into the
// buffers an instanced mesh is drawn from. The CPU sets up the buffers once and then never
// touches a boid again: no arrays, no upload, nothing per frame but a dispatch and a draw.
//
// Two engine pieces meet here. ComputeEffectShader runs any shader that inherits ComputeShaderBase
// and overrides Compute(); InstancingUserBuffer draws one model for every matrix in a buffer the
// caller owns. Point the first at the second and the simulation and the rendering never leave the
// card.
//
// SPACE freezes the flock, R scatters it again, 1-3 change how many boids there are.
int[] boidCounts = [2048, 4096, 8192];
WindowsDpiManager.EnablePerMonitorV2();
using var game = new Game();
// Compute shaders with more than one writable buffer need shader model 5, which is Direct3D
// feature level 11. A code-only game has no settings asset and starts at the engine's default,
// level 10, where the shader compiles for cs_4_0 - one UAV slot, so the second buffer is refused.
// UseGameSettings is how a code-only game asks for a profile.
game.UseGameSettings(settings => settings.GetOrCreateConfiguration<RenderingSettings>().DefaultGraphicsProfile = GraphicsProfile.Level_11_0);
BoidsSimulation? simulation = null;
BoidsComputeRenderer? computeRenderer = null;
Entity? flock = null;
Model? boidModel = null;
var countIndex = 1;
game.Run(start: Start, update: Update);
void Start(Scene scene)
{
game.Window.AllowUserResizing = true;
game.SetupBase3DScene();
game.AddSkybox();
game.AddProfiler();
// Code-only projects have to register the instancing render feature themselves; without it an
// InstancingComponent draws its model once, at the entity, and the flock is one cone
game.AddInstancingSupport();
// Look at the flock from a little way back; the base scene's controller takes over from here
var camera = game.GetCameraEntity();
camera.Transform.Position = new Vector3(0, 9f, 26f);
camera.Transform.Rotation = Quaternion.RotationX(MathUtil.DegreesToRadians(-8f));
// The boid: a small cone, tip along +Y, which the shader points along the heading. The entity
// is only a way to get the model; it never enters the scene.
boidModel = game.Create3DPrimitive(PrimitiveModelType.Cone, new Primitive3DEntityOptions
{
Size = new Vector3(0.14f, 0.4f, 0.14f),
Material = game.CreateMaterial(new Color(255, 150, 40)),
}).Get<ModelComponent>().Model;
Rebuild(scene);
// The compute pass goes first in the compositor's list, so the matrices are written before the
// scene that draws them is rendered - same command list, same frame
computeRenderer = new BoidsComputeRenderer { Simulation = () => simulation };
var compositor = game.SceneSystem.GraphicsCompositor!;
if (compositor.Game is SceneRendererCollection collection)
{
collection.Children.Insert(0, computeRenderer);
}
else
{
compositor.Game = new SceneRendererCollection { computeRenderer, compositor.Game };
}
var overlay = DebugOverlay.GetOrCreate(game);
overlay.AddSection("Boids", OverlayLines);
}
void Update(Scene scene, GameTime gameTime)
{
if (simulation is null) return;
if (game.Input.IsKeyPressed(Keys.Space)) simulation.Paused = !simulation.Paused;
if (game.Input.IsKeyPressed(Keys.R)) simulation.Scatter(game.GraphicsContext.CommandList);
for (var i = 0; i < boidCounts.Length; i++)
{
if (game.Input.IsKeyPressed(Keys.D1 + i) && i != countIndex)
{
countIndex = i;
Rebuild(scene);
}
}
}
/// <summary>Creates the flock at the chosen size: the buffers, and the one entity that draws them all.</summary>
void Rebuild(Scene scene)
{
flock?.Scene = null;
simulation?.Dispose();
simulation = new BoidsSimulation(game.GraphicsDevice, game.GraphicsContext.CommandList, boidCounts[countIndex]);
// One entity, one model, one draw call: the InstancingUserBuffer says how many copies to draw
// and hands over the matrix buffers the compute shader fills
flock = new Entity("Flock")
{
new ModelComponent(boidModel),
new InstancingComponent { Type = simulation.Instancing },
};
flock.Scene = scene;
}
IReadOnlyList<TextElement> OverlayLines()
{
if (simulation is null) return [];
var pairs = (long)simulation.Count * simulation.Count;
return
[
new("Space", simulation.Paused ? "Resume" : "Freeze", Color.Yellow),
new("R", "Scatter", Color.Yellow),
new(["1", "2", "3"], "Flock size", Color.Yellow),
new(""),
new($"{simulation.Count} boids, {pairs / 1_000_000f:0.0} million pairs a frame", Color.LightGreen),
new("All on the GPU: one dispatch, one instanced draw call,", Color.LightGray),
new("nothing per frame on the CPU", Color.LightGray),
];
}
/// <summary>
/// The flock's GPU state: two boid buffers that swap every frame, the two matrix buffers the mesh
/// renderer reads, and the instancing type that hands them over.
/// </summary>
public sealed class BoidsSimulation : IDisposable
{
// Where the flock lives and how it flies. All of it goes to the shader every dispatch, so
// any of it could be a slider.
public Vector3 Center = new(0, 6f, 0);
public float Bounds = 9f;
public float MaxSpeed = 6f;
public float NeighbourRadius = 2.0f;
public float SeparationRadius = 0.9f;
public float SeparationWeight = 14f;
public float AlignmentWeight = 1.6f;
public float CohesionWeight = 0.9f;
public float HomeWeight = 1.5f;
public bool Paused;
private readonly Buffer<Boid>[] _boids = new Buffer<Boid>[2];
private int _current;
public BoidsSimulation(GraphicsDevice device, CommandList commandList, int count)
{
Count = count;
// Unordered access: a compute shader writes them. The second one starts empty; it is
// written before it is ever read.
_boids[0] = Buffer.Structured.New(device, Scattered(count), unorderedAccess: true);
_boids[1] = Buffer.Structured.New<Boid>(device, count, unorderedAccess: true);
// Shader resource for the mesh renderer to read, unordered access for the compute shader
// to write - the same flags the engine gives its own instance buffers, plus the write
WorldBuffer = Buffer.New<Matrix>(device, count, BufferFlags.ShaderResource | BufferFlags.StructuredBuffer | BufferFlags.UnorderedAccess);
WorldInverseBuffer = Buffer.New<Matrix>(device, count, BufferFlags.ShaderResource | BufferFlags.StructuredBuffer | BufferFlags.UnorderedAccess);
// The bounding box is for culling: generous, so the flock is never culled at its edges.
// Ignore: the instance matrix is the whole transform, the entity's own is not applied.
Instancing = new InstancingUserBuffer
{
InstanceCount = count,
InstanceWorldBuffer = WorldBuffer,
InstanceWorldInverseBuffer = WorldInverseBuffer,
BoundingBox = new BoundingBox(Center - new Vector3(Bounds * 3f), Center + new Vector3(Bounds * 3f)),
ModelTransformUsage = ModelTransformUsage.Ignore,
};
// Matrices start as identity, so the first frame draws something sane even before the
// compute pass has run
var identity = new Matrix[count];
Array.Fill(identity, Matrix.Identity);
WorldBuffer.SetData(commandList, identity);
WorldInverseBuffer.SetData(commandList, identity);
}
public int Count { get; }
public InstancingUserBuffer Instancing { get; }
public Buffer<Matrix> WorldBuffer { get; }
public Buffer<Matrix> WorldInverseBuffer { get; }
/// <summary>Last frame's flock, read by every thread this frame.</summary>
public Buffer<Boid> Input => _boids[_current];
/// <summary>This frame's flock, written once per thread.</summary>
public Buffer<Boid> Output => _boids[1 - _current];
/// <summary>After a dispatch: what was written becomes what is read.</summary>
public void Swap() => _current = 1 - _current;
/// <summary>Throws every boid to a new random place with a new random heading.</summary>
public void Scatter(CommandList commandList) => Input.SetData(commandList, Scattered(Count));
private Boid[] Scattered(int count)
{
var random = new Random(1234);
var boids = new Boid[count];
for (var i = 0; i < count; i++)
{
var position = Center + RandomInSphere(random) * Bounds;
var velocity = RandomInSphere(random) * MaxSpeed;
boids[i] = new Boid(position, velocity);
}
return boids;
}
private static Vector3 RandomInSphere(Random random)
{
while (true)
{
var candidate = new Vector3(random.NextSingle() * 2f - 1f, random.NextSingle() * 2f - 1f, random.NextSingle() * 2f - 1f);
if (candidate.LengthSquared() <= 1f && candidate.LengthSquared() > 0.01f) return candidate;
}
}
public void Dispose()
{
_boids[0].Dispose();
_boids[1].Dispose();
WorldBuffer.Dispose();
WorldInverseBuffer.Dispose();
}
}
/// <summary>One boid as the shader sees it: two float4s, the fourth components unused.</summary>
public readonly record struct Boid(Vector4 Position, Vector4 Velocity)
{
public Boid(Vector3 position, Vector3 velocity) : this(new Vector4(position, 0f), new Vector4(velocity, 0f))
{
}
}
/// <summary>
/// The compute pass: a scene renderer that runs first in the compositor and dispatches the boids
/// shader once per frame. ComputeEffectShader wraps the effect, the thread counts and the pipeline
/// state; all this does is bind the buffers, set the numbers and dispatch.
/// </summary>
public sealed class BoidsComputeRenderer : SceneRendererBase
{
// Set by the example; a scene renderer is a data contract and needs a parameterless constructor
public Func<BoidsSimulation?> Simulation { private get; set; } = () => null;
// One thread per boid, in groups of this many; the shader ignores the threads past the end
private const int ThreadsPerGroup = 256;
private ComputeEffectShader? _compute;
protected override void InitializeCore()
{
base.InitializeCore();
_compute = new ComputeEffectShader(Context)
{
ShaderSourceName = "BoidsShader",
ThreadNumbers = new Int3(ThreadsPerGroup, 1, 1),
};
}
protected override void DrawCore(RenderContext context, RenderDrawContext drawContext)
{
if (_compute is null || Simulation() is not { Paused: false } flock) return;
_compute.ThreadGroupCounts = new Int3((flock.Count + ThreadsPerGroup - 1) / ThreadsPerGroup, 1, 1);
// Clamped so a hitch does not fling the flock apart
var deltaTime = MathF.Min((float)context.Time.Elapsed.TotalSeconds, 1f / 30f);
var parameters = _compute.Parameters;
parameters.Set(BoidsShaderKeys.DeltaTime, deltaTime);
parameters.Set(BoidsShaderKeys.BoidCount, (uint)flock.Count);
parameters.Set(BoidsShaderKeys.FlockCenter, flock.Center);
parameters.Set(BoidsShaderKeys.Bounds, flock.Bounds);
parameters.Set(BoidsShaderKeys.MaxSpeed, flock.MaxSpeed);
parameters.Set(BoidsShaderKeys.NeighbourRadius, flock.NeighbourRadius);
parameters.Set(BoidsShaderKeys.SeparationRadius, flock.SeparationRadius);
parameters.Set(BoidsShaderKeys.SeparationWeight, flock.SeparationWeight);
parameters.Set(BoidsShaderKeys.AlignmentWeight, flock.AlignmentWeight);
parameters.Set(BoidsShaderKeys.CohesionWeight, flock.CohesionWeight);
parameters.Set(BoidsShaderKeys.HomeWeight, flock.HomeWeight);
parameters.Set(BoidsShaderKeys.BoidsIn, flock.Input);
parameters.Set(BoidsShaderKeys.BoidsOut, flock.Output);
parameters.Set(BoidsShaderKeys.InstanceWorld, flock.WorldBuffer);
parameters.Set(BoidsShaderKeys.InstanceWorldInverse, flock.WorldInverseBuffer);
_compute.Draw(drawContext);
flock.Swap();
}
protected override void Destroy()
{
_compute?.Dispose();
base.Destroy();
}
}