1use 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
24henad_core::buffers! {
30 const POS = "pos" double_buffered drawable;
31 const VEL = "vel" double_buffered;
32 const COLOR = "color" double_buffered drawable;
33}
34#[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 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 const REPLAYS_EXACTLY: bool = false;
58
59 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 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 const REDUCE: ReduceSpec = ReduceSpec {
80 shader: crate::shader_bindings::gpu_boids::reduce::SHADER_STRING,
81 bindings: crate::binding_decls::bindings::GPU_BOIDS_REDUCE,
82 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 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 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
192fn interleave(xs: &[f32], ys: &[f32]) -> Vec<f32> {
194 xs.iter().zip(ys).flat_map(|(&x, &y)| [x, y]).collect()
195}
196
197fn 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
206fn 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 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 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 #[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, ¶ms(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 #[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 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 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 #[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 #[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 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 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 #[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 let world = 4_000.0f32;
377 let mut state = State::new(&ctx, ¶ms(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}