Skip to main content

henad_models/boids/
mod.rs

1//! Boids flocking as an [`AgentModel`] on a torus, with neighbours found through a [`SpatialHash`].
2//!
3//! Each boid steers away from the neighbours inside its protected range, and towards the mean velocity and the mean
4//! position of those inside its visual range. Its speed is then clamped between `min_speed` and `max_speed`.
5
6mod 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
22/// Colours of the stat series.
23pub const PALETTE: [[u8; 4]; 3] = [
24    [0xE4, 0x37, 0x48, 0xFF], // Unused - red
25    [0xFF, 0xC1, 0x07, 0xFF], // Average speed - yellow
26    [0x00, 0x7A, 0xF5, 0xFF], // Average velocity - blue
27];
28
29/// Agent colours by heading octant.
30///
31/// Colouring by speed would collapse to one colour once the flock settles at `min_speed`. The palette is cyclic, and a
32/// turning flock shifts hue instead of jumping.
33pub const HEADING_PALETTE: [[u8; 4]; 8] = [
34    [0xE4, 0x37, 0x48, 0xFF], // [0, 45)    E -> SE
35    [0xF0, 0x7A, 0x28, 0xFF], // [45, 90)   SE -> S
36    [0xFF, 0xC1, 0x07, 0xFF], // [90, 135)  S -> SW
37    [0x8B, 0xC3, 0x4A, 0xFF], // [135, 180) SW -> W
38    [0x1E, 0xA8, 0x7C, 0xFF], // [180, 225) W -> NW
39    [0x00, 0x9E, 0xC8, 0xFF], // [225, 270) NW -> N
40    [0x00, 0x7A, 0xF5, 0xFF], // [270, 315) N -> NE
41    [0x8E, 0x54, 0xD8, 0xFF], // [315, 360) NE -> E
42];
43
44// --8<-- [start:params]
45henad_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}
54// --8<-- [end:params]
55
56henad_core::actions! {
57    const RANDOMISE_HEADINGS = ActionDescriptor::new("randomise_headings", "Randomise headings");
58}
59
60/// Boids flocking as an [`AgentModel`].
61#[derive(Debug)]
62pub struct BoidsModel;
63
64/// Gives every boid a new random heading without changing its speed, so a settled flock scatters and
65/// re-forms rather than restarting.
66fn 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/// Parameters of [`BoidsModel`], read once per tick.
81///
82/// The squared ranges and half extents are precomputed, so the inner loop does no setup per neighbour.
83#[derive(Debug)]
84pub struct BoidParams {
85    /// Distance within which a boid sees its neighbours.
86    pub visual_range: f32,
87    /// Square of `visual_range`.
88    pub visual_sq: f32,
89    /// Square of the distance within which a boid steers away from its neighbours.
90    pub protected_sq: f32,
91    /// Weight of the steer away from close neighbours.
92    pub separation_factor: f32,
93    /// Weight of the steer towards the neighbours' mean velocity.
94    pub alignment_factor: f32,
95    /// Weight of the steer towards the neighbours' mean position.
96    pub cohesion_factor: f32,
97    /// Highest speed a boid keeps after a step.
98    pub max_speed: f32,
99    /// Lowest speed a boid keeps after a step.
100    pub min_speed: f32,
101    /// Half the world width.
102    pub half_w: f32,
103    /// Half the world height.
104    pub half_h: f32,
105    /// World width.
106    pub world_w: f32,
107    /// World height.
108    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    /// Agents per chunk, smaller than the default since the kernel draws no random numbers and the
121    /// population can be small. At 512, a thousand boids make two chunks and most of the pool sits idle.
122    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    /// Returns a third of the visual range, so the walk covers about twice the disc it needs instead
159    /// of about three times.
160    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            // The initial snapshot is published before any tick, and the colour lane is seeded here too.
175            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    /// Returns the sums of speed and velocity in plain sequential `hypot` form.
222    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    /// Returns a stepped flock whose size is not a multiple of `STATS_CHUNK`, so the ragged final chunk is covered.
233    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(&params);
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        // f32 speed accumulation within a chunk is the loosest term here.
259        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    /// Boids move, so a stale cached average would show up as stats that never change.
266    #[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    /// The chunk seed comes from the chunk index, so the flock must not depend on how rayon
287    /// splits the work.
288    #[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(&params);
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    /// The engine owns the world extent, so the point view must report it rather than a lane range.
314    #[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(&params);
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(&params, |lanes, _extent| {
338            lanes.pos_x[0] = 50.0;
339            lanes.pos_y[0] = 50.0;
340        });
341        let lanes = state.lanes();
342
343        // The speed is the midpoint of the configured band, compared within a tolerance. It is computed with
344        // `sin` and `cos`, whose last bit is a libm detail and varies by platform.
345        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
376/// Sums the speed and velocity of every boid chunk by chunk in index order, so the result does not depend on how rayon
377/// schedules the work.
378fn 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}