Skip to main content

henad_models/gpu_boids/
mod.rs

1//! GPU boids, [`crate::boids`] with its population in GPU buffers.
2//!
3//! Tick 0 is bit identical since seeding uses [`BoidsModel::init`]. After that, the
4//! neighbour index does not fix the order within a cell, so trajectories are likely different.
5
6use henad_compute::cpu::agent_engine::{
7    NUM_AGENTS, WORLD_HEIGHT, WORLD_WIDTH, agent_init_rng, agent_model_param_descriptors, split_params,
8};
9use henad_core::action::ActionDescriptor;
10use henad_core::authoring::model::agent_model::{AgentLanes as _, AgentModel as _};
11use henad_core::authoring::model::field::Extent;
12use henad_core::authoring::model::gpu_agent_model::{
13    BufferSpec, Domain, Geometry, GpuAgentAction, GpuAgentModel, PassCtx, PassId, PassSpec, ReduceSpec,
14};
15use henad_core::helpers::{extract_f32, extract_u32};
16use henad_core::params::{ParamDescriptor, ParamValue};
17use henad_core::view::{StatDescriptor, StatValue};
18
19use crate::boids::{BoidLanes, BoidsModel, HEADING_PALETTE};
20use crate::shader_bindings::gpu_boids::randomise_headings::Params as ActionParams;
21use crate::shader_bindings::gpu_boids::reduce::Params as ReduceParams;
22use crate::shader_bindings::gpu_boids::step::Params as StepParams;
23
24// The param list is [`agent_model_param_descriptors`] for [`BoidsModel`] verbatim, so both
25// backends take the same vector. Only the three engine parameters are read here, under the names
26// that `cpu::agent_engine` assigns them. The other parameters come from [`BoidsModel::from_params`].
27
28// --8<-- [start:buffers]
29henad_core::buffers! {
30    const POS = "pos" double_buffered drawable;
31    const VEL = "vel" double_buffered;
32    const COLOR = "color" double_buffered drawable;
33}
34// --8<-- [end:buffers]
35
36/// Boids flocking as a [`GpuAgentModel`], seeded through [`BoidsModel::init`].
37#[derive(Debug)]
38pub struct GpuBoids;
39
40impl GpuAgentModel for GpuBoids {
41    const NAME: &'static str = "Boids Flocking (GPU)";
42    const ID: &'static str = "gpu_boids";
43    const DESCRIPTION: &'static str =
44        "A simulation of flocking behavior in a group of boids, stepped entirely on the GPU";
45
46    const STATS: &'static [StatDescriptor] = BoidsModel::STATS;
47
48    /// Position, velocity and colour, each double buffered, since a boid reads its neighbours' current values while
49    /// writing its own next values.
50    const BUFFERS: &'static [BufferSpec] = BUFFER_SPECS;
51    const POS_BUFFER: usize = POS;
52    const COLOR_BUFFER: usize = COLOR;
53
54    const INDEX: bool = true;
55
56    /// The neighbour index leaves the order of boids within a cell to the GPU.
57    const REPLAYS_EXACTLY: bool = false;
58
59    // --8<-- [start:passes]
60    const STEP_PASSES: &'static [PassSpec] = &[PassSpec {
61        label: "step",
62        shader: crate::shader_bindings::gpu_boids::step::SHADER_STRING,
63        bindings: crate::binding_decls::bindings::GPU_BOIDS_STEP,
64        domain: Domain::Agents,
65    }];
66    // --8<-- [end:passes]
67
68    const ACTIONS: &'static [GpuAgentAction] = &[GpuAgentAction {
69        desc: ActionDescriptor::new("randomise_headings", "Randomise headings"),
70        pass: PassSpec {
71            label: "randomise_headings",
72            shader: crate::shader_bindings::gpu_boids::randomise_headings::SHADER_STRING,
73            bindings: crate::binding_decls::bindings::GPU_BOIDS_RANDOMISE_HEADINGS,
74            domain: Domain::Agents,
75        },
76    }];
77
78    /// Reduction of three lanes: speed, x velocity and y velocity.
79    const REDUCE: ReduceSpec = ReduceSpec {
80        shader: crate::shader_bindings::gpu_boids::reduce::SHADER_STRING,
81        bindings: crate::binding_decls::bindings::GPU_BOIDS_REDUCE,
82        // Speed, x velocity, y velocity.
83        lanes: 3,
84        domain: Domain::Agents,
85    };
86
87    fn param_descriptors() -> Vec<ParamDescriptor> {
88        agent_model_param_descriptors::<BoidsModel>()
89    }
90
91    fn dims(params: &[ParamValue]) -> (u32, Extent) {
92        (
93            extract_u32(params, NUM_AGENTS, BoidsModel::DEFAULT_AGENTS),
94            Extent {
95                w: extract_f32(params, WORLD_WIDTH, BoidsModel::DEFAULT_EXTENT.w),
96                h: extract_f32(params, WORLD_HEIGHT, BoidsModel::DEFAULT_EXTENT.h),
97            },
98        )
99    }
100
101    fn buffer_lens(geom: &Geometry) -> Vec<usize> {
102        let n = geom.num_agents as usize;
103        vec![n * 2, n * 2, n]
104    }
105
106    fn seed_buffers(geom: &Geometry, params: &[ParamValue], seed: Option<u64>) -> Vec<Vec<u8>> {
107        let n = geom.num_agents as usize;
108
109        // Seeding through the model's own `init` keeps tick 0 bit identical with the CPU model.
110        let mut lanes = BoidLanes::alloc(n);
111        let mut rng = agent_init_rng(seed);
112        BoidsModel::init(&mut lanes, geom.extent, split_params::<BoidsModel>(params).0, &mut rng);
113
114        // The CPU lane holds palette indices. The GPU draws this buffer directly, and it holds colours.
115        let colors: Vec<u32> = lanes.color.iter().map(|&c| packed_palette_color(c)).collect();
116        vec![
117            bytemuck::cast_slice(&interleave(&lanes.pos_x, &lanes.pos_y)).to_vec(),
118            bytemuck::cast_slice(&interleave(&lanes.vel_x, &lanes.vel_y)).to_vec(),
119            bytemuck::cast_slice(&colors).to_vec(),
120        ]
121    }
122
123    fn index_cell_size(params: &[ParamValue]) -> f32 {
124        BoidsModel::index_cell_size(&BoidsModel::from_params(
125            split_params::<BoidsModel>(params).0,
126            Self::dims(params).1,
127        ))
128    }
129
130    fn pass_params_bytes(pass: PassId, ctx: PassCtx<'_>, params: &[ParamValue]) -> Vec<u8> {
131        if let PassId::Action(_) = pass {
132            let hot = BoidsModel::from_params(split_params::<BoidsModel>(params).0, Self::dims(params).1);
133            return bytemuck::bytes_of(&ActionParams {
134                num_agents: ctx.geom.num_agents,
135                groups_x: ctx.groups_x,
136                seed: ctx.seed,
137                stationary: hot.min_speed.midpoint(hot.max_speed),
138                palette: packed_heading_palette(),
139            })
140            .to_vec();
141        }
142        if pass == PassId::Reduce {
143            return bytemuck::bytes_of(&ReduceParams {
144                n: ctx.invocations,
145                lanes: Self::REDUCE.lanes as u32,
146                groups_x: ctx.groups_x,
147                _pad: 0,
148            })
149            .to_vec();
150        }
151
152        let hot = BoidsModel::from_params(split_params::<BoidsModel>(params).0, Self::dims(params).1);
153        let grid = ctx.geom.index.expect("INDEX is declared");
154        bytemuck::bytes_of(&StepParams {
155            num_agents: ctx.geom.num_agents,
156            groups_x: ctx.groups_x,
157            grid_w: grid.grid_w,
158            grid_h: grid.grid_h,
159            cell_w: grid.cell_w,
160            cell_h: grid.cell_h,
161            cell_w_inv: 1.0 / grid.cell_w,
162            cell_h_inv: 1.0 / grid.cell_h,
163            world_w: hot.world_w,
164            world_h: hot.world_h,
165            half_w: hot.half_w,
166            half_h: hot.half_h,
167            visual_range: hot.visual_range,
168            visual_sq: hot.visual_sq,
169            protected_sq: hot.protected_sq,
170            separation: hot.separation_factor,
171            alignment: hot.alignment_factor,
172            cohesion: hot.cohesion_factor,
173            max_speed: hot.max_speed,
174            min_speed: hot.min_speed,
175            palette: packed_heading_palette(),
176        })
177        .to_vec()
178    }
179
180    fn stats(sums: &[f32], _counters: &[u32], geom: &Geometry) -> Vec<StatValue> {
181        let inv = 1.0 / f64::from(geom.num_agents.max(1));
182        vec![
183            StatValue::Scalar(f64::from(sums[0]) * inv),
184            StatValue::Vector2D {
185                x: f64::from(sums[1]) * inv,
186                y: f64::from(sums[2]) * inv,
187            },
188        ]
189    }
190}
191
192/// Interleaves `xs` and `ys` into the `vec2` layout the shaders read.
193fn interleave(xs: &[f32], ys: &[f32]) -> Vec<f32> {
194    xs.iter().zip(ys).flat_map(|(&x, &y)| [x, y]).collect()
195}
196
197/// Packs [`HEADING_PALETTE`] for the step uniform, keeping both backends on one palette.
198fn packed_heading_palette() -> [[u32; 4]; 2] {
199    let mut packed = [[0u32; 4]; 2];
200    for (i, rgba) in HEADING_PALETTE.iter().enumerate() {
201        packed[i / 4][i % 4] = u32::from_le_bytes(*rgba);
202    }
203    packed
204}
205
206/// Returns palette entry `index` packed into a `u32` as the CPU agent upload packs it, or entry 0 past the end.
207fn packed_palette_color(index: u8) -> u32 {
208    let rgba = HEADING_PALETTE
209        .get(index as usize)
210        .copied()
211        .unwrap_or(HEADING_PALETTE[0]);
212    u32::from_le_bytes(rgba)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use henad_compute::cpu::agent_engine::AgentModelState;
219    use henad_compute::gpu::{GpuAgentState, GpuContext};
220    use henad_core::model::SimState as _;
221    use henad_explore::testing::{TestDeviceRequest, headless_test_device};
222
223    type State = GpuAgentState<GpuBoids>;
224
225    fn headless_context() -> Option<GpuContext> {
226        headless_test_device(&TestDeviceRequest::baseline())
227    }
228
229    fn params(num_agents: u32, world: f32) -> Vec<ParamValue> {
230        let mut values: Vec<ParamValue> = GpuBoids::param_descriptors()
231            .iter()
232            .map(|desc| desc.kind.default_value())
233            .collect();
234        values[NUM_AGENTS] = ParamValue::U32(num_agents);
235        values[WORLD_WIDTH] = ParamValue::F32(world);
236        values[WORLD_HEIGHT] = ParamValue::F32(world);
237        values
238    }
239
240    /// Splits an interleaved buffer into the two scalar lanes the CPU model keeps.
241    fn split(interleaved: &[u32]) -> (Vec<f32>, Vec<f32>) {
242        let floats: Vec<f32> = interleaved.iter().map(|&w| f32::from_bits(w)).collect();
243        (
244            floats.iter().step_by(2).copied().collect(),
245            floats.iter().skip(1).step_by(2).copied().collect(),
246        )
247    }
248
249    /// Returns the current-side positions and velocities as `(pos_x, pos_y, vel_x, vel_y)`.
250    fn lanes(state: &State) -> (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) {
251        let (pos_x, pos_y) = split(&state.read_buffer(POS));
252        let (vel_x, vel_y) = split(&state.read_buffer(VEL));
253        (pos_x, pos_y, vel_x, vel_y)
254    }
255
256    /// A boid drifting out of the world would break both the renderer and the index.
257    #[test]
258    fn boids_stay_inside_the_toroidal_world() {
259        let Some(ctx) = headless_context() else {
260            log::warn!("skipping boids_stay_inside_the_toroidal_world: no adapter");
261            return;
262        };
263
264        let world = 800.0f32;
265        let mut state = State::new(&ctx, &params(4_000, world));
266        state.run_batched(60);
267
268        let (pos_x, pos_y, _, _) = lanes(&state);
269        for (i, (&x, &y)) in pos_x.iter().zip(&pos_y).enumerate() {
270            assert!(
271                (0.0..world).contains(&x) && (0.0..world).contains(&y),
272                "boid {i} left the world at ({x}, {y})"
273            );
274        }
275    }
276
277    /// The speed clamp divides by a value that can be zero.
278    #[test]
279    fn speeds_stay_within_the_configured_band() {
280        let Some(ctx) = headless_context() else {
281            log::warn!("skipping speeds_stay_within_the_configured_band: no adapter");
282            return;
283        };
284
285        let values = params(4_000, 800.0);
286        // The bounds come from the model's own `from_params`, so the test cannot disagree with the kernel.
287        let hot = BoidsModel::from_params(split_params::<BoidsModel>(&values).0, GpuBoids::dims(&values).1);
288        let (max_speed, min_speed) = (hot.max_speed, hot.min_speed);
289
290        let mut state = State::new(&ctx, &values);
291        state.run_batched(60);
292
293        let (_, _, vel_x, vel_y) = lanes(&state);
294        for (i, (&vx, &vy)) in vel_x.iter().zip(&vel_y).enumerate() {
295            let speed = vx.hypot(vy);
296            // The clamp is applied in `f32`, so allow a last-bit tolerance on both ends.
297            assert!(
298                speed <= max_speed * 1.001 && speed >= min_speed * 0.999,
299                "boid {i} has speed {speed}, outside [{min_speed}, {max_speed}]"
300            );
301        }
302    }
303
304    /// Both backends seed through `BoidsModel::init`, by default and from a seed, so any later divergence comes from
305    /// the step.
306    #[test]
307    fn initial_flock_matches_the_cpu_model() {
308        let Some(ctx) = headless_context() else {
309            log::warn!("skipping initial_flock_matches_the_cpu_model: no adapter");
310            return;
311        };
312
313        let values = params(2_000, 800.0);
314        for seed in [None, Some(7)] {
315            let gpu = State::new_seeded(&ctx, &values, seed);
316            let cpu = AgentModelState::<BoidsModel>::from_params_seeded(&values, seed);
317
318            let (pos_x, pos_y, vel_x, vel_y) = lanes(&gpu);
319            let cpu_lanes = cpu.lanes();
320            assert_eq!(pos_x, cpu_lanes.pos_x, "initial x positions differ for seed {seed:?}");
321            assert_eq!(pos_y, cpu_lanes.pos_y, "initial y positions differ for seed {seed:?}");
322            assert_eq!(vel_x, cpu_lanes.vel_x, "initial x velocities differ for seed {seed:?}");
323            assert_eq!(vel_y, cpu_lanes.vel_y, "initial y velocities differ for seed {seed:?}");
324        }
325    }
326
327    /// Catches a reduction that lost or double counted a workgroup.
328    #[test]
329    fn average_speed_is_consistent_with_the_population() {
330        let Some(ctx) = headless_context() else {
331            log::warn!("skipping average_speed_is_consistent_with_the_population: no adapter");
332            return;
333        };
334
335        let values = params(10_000, 1_000.0);
336        // The bounds come from the model's own `from_params`, so the test cannot disagree with the kernel.
337        let hot = BoidsModel::from_params(split_params::<BoidsModel>(&values).0, GpuBoids::dims(&values).1);
338        let (max_speed, min_speed) = (hot.max_speed, hot.min_speed);
339
340        let mut state = State::new(&ctx, &values);
341        state.run_batched(30);
342        state.refresh_stats();
343
344        let stats = state.stats();
345        let StatValue::Scalar(avg_speed) = stats[0].value else {
346            panic!("average speed is a scalar");
347        };
348        assert!(
349            avg_speed >= f64::from(min_speed) * 0.999 && avg_speed <= f64::from(max_speed) * 1.001,
350            "average speed {avg_speed} is outside [{min_speed}, {max_speed}]"
351        );
352
353        // Cross-check against the lanes, which a stride bug would not survive.
354        let (_, _, vel_x, vel_y) = lanes(&state);
355        let reference: f64 = vel_x
356            .iter()
357            .zip(&vel_y)
358            .map(|(&x, &y)| f64::from(x.hypot(y)))
359            .sum::<f64>()
360            / vel_x.len() as f64;
361        assert!(
362            (avg_speed - reference).abs() <= 1e-3 * reference.abs().max(1.0),
363            "reduced average speed {avg_speed} disagrees with the lanes: {reference}"
364        );
365    }
366
367    /// Exercises the 2D dispatch fold in every kernel at once.
368    #[test]
369    fn a_population_past_one_workgroup_row_still_steps() {
370        let Some(ctx) = headless_context() else {
371            log::warn!("skipping a_population_past_one_workgroup_row_still_steps: no adapter");
372            return;
373        };
374
375        // The count is not a multiple of the workgroup width, so the ragged tail is covered.
376        let world = 4_000.0f32;
377        let mut state = State::new(&ctx, &params(300_037, world));
378        state.run_batched(5);
379
380        let (pos_x, pos_y, _, _) = lanes(&state);
381        assert_eq!(pos_x.len(), 300_037);
382        for (i, (&x, &y)) in pos_x.iter().zip(&pos_y).enumerate() {
383            assert!(
384                (0.0..world).contains(&x) && (0.0..world).contains(&y),
385                "boid {i} left the world at ({x}, {y}); the dispatch fold probably missed it"
386            );
387        }
388    }
389}