1mod lanes;
7mod step;
8
9use henad_compute::cpu::primitives::chunked::{STATS_CHUNK, reduce_chunks};
10use henad_core::action::ActionDescriptor;
11use henad_core::authoring::model::agent_model::{AgentModel, StepCtx};
12use henad_core::authoring::model::field::{Extent, NoField};
13use henad_core::authoring::primitives::rng::next_float;
14use henad_core::authoring::primitives::space::heading_octant;
15use henad_core::helpers::{extract_f32, f32_param};
16use henad_core::params::{ParamDescriptor, ParamValue};
17use henad_core::spatial_hash::SpatialHash;
18use henad_core::view::{StatDescriptor, StatValue};
19
20pub use crate::boids::lanes::{BoidChunk, BoidLanes, BoidRead};
21
22pub const PALETTE: [[u8; 4]; 3] = [
24 [0xE4, 0x37, 0x48, 0xFF], [0xFF, 0xC1, 0x07, 0xFF], [0x00, 0x7A, 0xF5, 0xFF], ];
28
29pub const HEADING_PALETTE: [[u8; 4]; 8] = [
34 [0xE4, 0x37, 0x48, 0xFF], [0xF0, 0x7A, 0x28, 0xFF], [0xFF, 0xC1, 0x07, 0xFF], [0x8B, 0xC3, 0x4A, 0xFF], [0x1E, 0xA8, 0x7C, 0xFF], [0x00, 0x9E, 0xC8, 0xFF], [0x00, 0x7A, 0xF5, 0xFF], [0x8E, 0x54, 0xD8, 0xFF], ];
43
44henad_core::params! {
46 const VISUAL_RANGE = f32_param("visual_range", "Visual Range", 50.0, 1.0, 200.0, Some(1.0));
47 const PROTECTED_RANGE = f32_param("protected_range", "Protected Range", 8.0, 0.5, 50.0, Some(0.5));
48 const SEPARATION = f32_param("separation", "Separation", 0.05, 0.0, 2.0, Some(0.01));
49 const ALIGNMENT = f32_param("alignment", "Alignment", 0.05, 0.0, 2.0, Some(0.01));
50 const COHESION = f32_param("cohesion", "Cohesion", 0.0005, 0.0, 0.01, Some(0.0001));
51 const MAX_SPEED = f32_param("max_speed", "Max Speed", 15.0, 1.0, 50.0, Some(0.5));
52 const MIN_SPEED = f32_param("min_speed", "Min Speed", 3.0, 0.5, 20.0, Some(0.5));
53}
54henad_core::actions! {
57 const RANDOMISE_HEADINGS = ActionDescriptor::new("randomise_headings", "Randomise headings");
58}
59
60#[derive(Debug)]
62pub struct BoidsModel;
63
64fn randomise_headings(lanes: &mut BoidLanes, params: &[ParamValue], rng: &mut u64) {
67 let max_speed = extract_f32(params, MAX_SPEED, 15.0);
68 let min_speed = extract_f32(params, MIN_SPEED, 3.0);
69 let stationary = min_speed.midpoint(max_speed);
70 for i in 0..lanes.vel_x.len() {
71 let speed = lanes.vel_x[i].hypot(lanes.vel_y[i]);
72 let speed = if speed > 0.0 { speed } else { stationary };
73 let angle = next_float(rng, std::f32::consts::TAU);
74 lanes.vel_x[i] = angle.cos() * speed;
75 lanes.vel_y[i] = angle.sin() * speed;
76 lanes.color[i] = heading_octant(lanes.vel_x[i], lanes.vel_y[i]);
77 }
78}
79
80#[derive(Debug)]
84pub struct BoidParams {
85 pub visual_range: f32,
87 pub visual_sq: f32,
89 pub protected_sq: f32,
91 pub separation_factor: f32,
93 pub alignment_factor: f32,
95 pub cohesion_factor: f32,
97 pub max_speed: f32,
99 pub min_speed: f32,
101 pub half_w: f32,
103 pub half_h: f32,
105 pub world_w: f32,
107 pub world_h: f32,
109}
110
111impl AgentModel for BoidsModel {
112 const NAME: &'static str = "Boids Flocking";
113 const ID: &'static str = "boids";
114 const DESCRIPTION: &'static str = "A simulation of flocking behavior in a group of boids.";
115 const PALETTE: &'static [[u8; 4]] = &HEADING_PALETTE;
116 const STATS: &'static [StatDescriptor] = &[
117 StatDescriptor::new("Average Speed", PALETTE[1]),
118 StatDescriptor::new("Average Velocity", PALETTE[2]),
119 ];
120 const CHUNK: usize = 64;
123 const ACTIONS: &'static [ActionDescriptor] = ACTION_SPECS;
124 const DEFAULT_AGENTS: u32 = 50_000;
125 const MAX_AGENTS: u32 = 1_000_000;
126 const DEFAULT_EXTENT: Extent = Extent { w: 1_000.0, h: 1_000.0 };
127
128 type Lanes = BoidLanes;
129 type Field = NoField;
130 type Index = SpatialHash;
131 type Params = BoidParams;
132 type Tally = ();
133
134 fn param_descriptors() -> Vec<ParamDescriptor> {
135 descriptors()
136 }
137
138 fn from_params(params: &[ParamValue], extent: Extent) -> BoidParams {
139 let visual_range = extract_f32(params, VISUAL_RANGE, 50.0);
140 let protected_range = extract_f32(params, PROTECTED_RANGE, 8.0);
141 let (world_w, world_h) = (extent.w, extent.h);
142 BoidParams {
143 visual_range,
144 visual_sq: visual_range * visual_range,
145 protected_sq: protected_range * protected_range,
146 separation_factor: extract_f32(params, SEPARATION, 0.05),
147 alignment_factor: extract_f32(params, ALIGNMENT, 0.05),
148 cohesion_factor: extract_f32(params, COHESION, 0.0005),
149 max_speed: extract_f32(params, MAX_SPEED, 15.0),
150 min_speed: extract_f32(params, MIN_SPEED, 3.0),
151 half_w: 0.5 * world_w,
152 half_h: 0.5 * world_h,
153 world_w,
154 world_h,
155 }
156 }
157
158 fn index_cell_size(params: &BoidParams) -> f32 {
161 params.visual_range / 3.0
162 }
163
164 fn init(lanes: &mut BoidLanes, extent: Extent, params: &[ParamValue], rng: &mut u64) {
165 let max_speed = extract_f32(params, MAX_SPEED, 15.0);
166 let min_speed = extract_f32(params, MIN_SPEED, 3.0);
167 let speed = min_speed.midpoint(max_speed);
168 for i in 0..lanes.pos_x.len() {
169 lanes.pos_x[i] = next_float(rng, extent.w);
170 lanes.pos_y[i] = next_float(rng, extent.h);
171 let angle = next_float(rng, std::f32::consts::TAU);
172 lanes.vel_x[i] = angle.cos() * speed;
173 lanes.vel_y[i] = angle.sin() * speed;
174 lanes.color[i] = heading_octant(lanes.vel_x[i], lanes.vel_y[i]);
176 }
177 }
178
179 fn run_step_pass(lanes: &mut BoidLanes, ctx: &StepCtx<'_, Self>, seed: u64, tick: u64) {
180 step::run(lanes, ctx, seed, tick);
181 }
182
183 #[expect(clippy::single_match, reason = "for future multi-action extendability")]
184 fn act(
185 action: usize,
186 lanes: &mut BoidLanes,
187 _field: &mut NoField,
188 _extent: Extent,
189 params: &[ParamValue],
190 rng: &mut u64,
191 ) {
192 match action {
193 RANDOMISE_HEADINGS => randomise_headings(lanes, params, rng),
194 _ => {}
195 }
196 }
197
198 fn stats(lanes: &BoidLanes, _field: &NoField, (): &()) -> Vec<StatValue> {
199 let sums = velocity_sums(&lanes.vel_x, &lanes.vel_y);
200 let inv = 1.0 / (lanes.vel_x.len().max(1) as f64);
201 vec![
202 StatValue::Scalar(sums.speed * inv),
203 StatValue::Vector2D {
204 x: sums.vx * inv,
205 y: sums.vy * inv,
206 },
207 ]
208 }
209}
210
211#[cfg(test)]
212mod tests {
213
214 use super::*;
215 use henad_compute::cpu::agent_engine::AgentModelState;
216 use henad_core::model::SimState as _;
217 use henad_core::view::StatValue;
218
219 type State = AgentModelState<BoidsModel>;
220
221 fn reference(vel_x: &[f32], vel_y: &[f32]) -> (f64, f64, f64) {
223 let (mut speed, mut vx, mut vy) = (0.0, 0.0, 0.0);
224 for (&x, &y) in vel_x.iter().zip(vel_y.iter()) {
225 speed += f64::from(x.hypot(y));
226 vx += f64::from(x);
227 vy += f64::from(y);
228 }
229 (speed, vx, vy)
230 }
231
232 fn state_spanning_several_chunks() -> State {
234 let n = STATS_CHUNK as u32 * 2 + 37;
235 let params = vec![ParamValue::U32(n), ParamValue::F32(4_000.0), ParamValue::F32(4_000.0)];
236 let mut state = State::from_params(¶ms);
237 for _ in 0..5 {
238 state.step();
239 }
240 state
241 }
242
243 #[test]
244 fn stats_match_the_sequential_reference() {
245 let state = state_spanning_several_chunks();
246 let lanes = state.lanes();
247 let (speed, vx, vy) = reference(&lanes.vel_x, &lanes.vel_y);
248 let inv = 1.0 / lanes.vel_x.len() as f64;
249 let stats = state.stats();
250
251 let StatValue::Scalar(avg_speed) = stats[0].value else {
252 panic!("average speed is a scalar");
253 };
254 let StatValue::Vector2D { x, y } = stats[1].value else {
255 panic!("average velocity is a vector");
256 };
257
258 let close = |a: f64, b: f64| (a - b).abs() <= 1e-4 * b.abs().max(1.0);
260 assert!(close(avg_speed, speed * inv), "speed {avg_speed} vs {}", speed * inv);
261 assert!(close(x, vx * inv), "vx {x} vs {}", vx * inv);
262 assert!(close(y, vy * inv), "vy {y} vs {}", vy * inv);
263 }
264
265 #[test]
267 fn stats_track_the_current_velocities() {
268 let mut state = state_spanning_several_chunks();
269 let before = state.stats();
270 for _ in 0..20 {
271 state.step();
272 }
273 let after = state.stats();
274
275 let (StatValue::Vector2D { x: bx, .. }, StatValue::Vector2D { x: ax, .. }) =
276 (&before[1].value, &after[1].value)
277 else {
278 panic!("average velocity is a vector");
279 };
280 assert!(
281 (ax - bx).abs() > f64::EPSILON,
282 "average velocity never moved: {bx} then {ax}"
283 );
284 }
285
286 #[test]
289 fn results_do_not_depend_on_the_thread_count() {
290 fn run(threads: usize) -> Vec<u32> {
291 let pool = rayon::ThreadPoolBuilder::new()
292 .num_threads(threads)
293 .build()
294 .expect("rayon pool");
295 pool.install(|| {
296 let params = vec![ParamValue::U32(20_000), ParamValue::F32(800.0), ParamValue::F32(800.0)];
297 let mut state = State::from_params(¶ms);
298 for _ in 0..30 {
299 state.step();
300 }
301 let lanes = state.lanes();
302 lanes
303 .pos_x
304 .iter()
305 .zip(&lanes.pos_y)
306 .flat_map(|(x, y)| [x.to_bits(), y.to_bits()])
307 .collect()
308 })
309 }
310 assert_eq!(run(1), run(7), "boid positions depend on the thread count");
311 }
312
313 #[test]
315 fn point_view_reports_the_engine_extent() {
316 let params = vec![ParamValue::U32(64), ParamValue::F32(512.0), ParamValue::F32(256.0)];
317 let state = State::from_params(¶ms);
318 let view = state.point_view().expect("boids draw an agent layer");
319 assert_eq!((view.world_w, view.world_h), (512.0, 256.0));
320 assert!(state.grid_view().is_none(), "boids have no field layer");
321 }
322
323 #[test]
324 fn from_agents_initializes_velocities_from_model_params() {
325 let params = vec![
326 ParamValue::U32(1),
327 ParamValue::F32(100.0),
328 ParamValue::F32(100.0),
329 ParamValue::F32(20.0),
330 ParamValue::F32(5.0),
331 ParamValue::F32(0.5),
332 ParamValue::F32(0.25),
333 ParamValue::F32(0.125),
334 ParamValue::F32(8.0),
335 ParamValue::F32(2.0),
336 ];
337 let state = State::from_agents(¶ms, |lanes, _extent| {
338 lanes.pos_x[0] = 50.0;
339 lanes.pos_y[0] = 50.0;
340 });
341 let lanes = state.lanes();
342
343 let speed = lanes.vel_x[0].hypot(lanes.vel_y[0]);
346 assert!(
347 (speed - 5.0).abs() < 1e-4,
348 "initial speed should be 0.5 * (min_speed + max_speed), got {speed}"
349 );
350 }
351}
352
353#[derive(Clone, Copy)]
354struct VelSums {
355 speed: f64,
356 vx: f64,
357 vy: f64,
358}
359
360impl VelSums {
361 const ZERO: Self = Self {
362 speed: 0.0,
363 vx: 0.0,
364 vy: 0.0,
365 };
366
367 fn add(self, other: Self) -> Self {
368 Self {
369 speed: self.speed + other.speed,
370 vx: self.vx + other.vx,
371 vy: self.vy + other.vy,
372 }
373 }
374}
375
376fn velocity_sums(vel_x: &[f32], vel_y: &[f32]) -> VelSums {
379 reduce_chunks(
380 vel_x.len(),
381 STATS_CHUNK,
382 |r| {
383 let mut speed_sum = 0.0f32;
384 let mut vx_sum = 0.0;
385 let mut vy_sum = 0.0;
386 for (&vx, &vy) in vel_x[r.clone()].iter().zip(vel_y[r].iter()) {
387 #[expect(
388 clippy::imprecise_flops,
389 reason = "this won't overflow and we want to avoid extra casts"
390 )]
391 let speed = (vx * vx + vy * vy).sqrt();
392 speed_sum += speed;
393 vx_sum += f64::from(vx);
394 vy_sum += f64::from(vy);
395 }
396 VelSums {
397 speed: f64::from(speed_sum),
398 vx: vx_sum,
399 vy: vy_sum,
400 }
401 },
402 VelSums::add,
403 VelSums::ZERO,
404 )
405}