Skip to main content

proof_engine/particle/
flock.rs

1//! Full boid/flocking simulation with obstacle avoidance, leader following, and predator flee.
2//!
3//! This module implements Craig Reynolds' Boids rules extended with:
4//!  - Weighted steering behaviors (alignment, cohesion, separation)
5//!  - Obstacle avoidance (sphere obstacles)
6//!  - Leader following (follow a target point with arrival behavior)
7//!  - Predator avoidance (flee from predator positions)
8//!  - Speed limits and steering force clamping
9//!  - Wall containment (keep boids within a bounding box)
10//!  - Wander behavior (noise-driven random steering when idle)
11
12use glam::Vec3;
13use std::f32::consts::TAU;
14
15// ── Neighbor ──────────────────────────────────────────────────────────────────
16
17/// A neighboring boid for computing steering forces.
18#[derive(Clone, Debug)]
19pub struct FlockNeighbor {
20    pub position: Vec3,
21    pub velocity: Vec3,
22}
23
24// ── Obstacle ──────────────────────────────────────────────────────────────────
25
26/// A spherical obstacle to avoid.
27#[derive(Clone, Debug)]
28pub struct Obstacle {
29    pub center: Vec3,
30    pub radius: f32,
31}
32
33impl Obstacle {
34    pub fn new(center: Vec3, radius: f32) -> Self { Self { center, radius } }
35
36    /// Returns how much this obstacle overlaps with a point (0 = no overlap).
37    pub fn overlap(&self, pos: Vec3) -> f32 {
38        let dist = (pos - self.center).length();
39        (self.radius - dist).max(0.0)
40    }
41}
42
43// ── Flock config ──────────────────────────────────────────────────────────────
44
45/// Weights and limits for the boid steering behaviors.
46#[derive(Clone, Debug)]
47pub struct FlockConfig {
48    // ── Weights ───────────────────────────────────────────────────────────────
49    /// Align velocity with neighbors.
50    pub alignment_weight:   f32,
51    /// Move toward neighbor center of mass.
52    pub cohesion_weight:    f32,
53    /// Steer away from nearby boids.
54    pub separation_weight:  f32,
55    /// Avoid obstacles.
56    pub avoidance_weight:   f32,
57    /// Follow a leader/target.
58    pub leader_weight:      f32,
59    /// Flee from predators.
60    pub flee_weight:        f32,
61    /// Random wander steering.
62    pub wander_weight:      f32,
63    /// Stay inside bounding box.
64    pub containment_weight: f32,
65
66    // ── Radii ─────────────────────────────────────────────────────────────────
67    /// Radius within which neighbors are perceived.
68    pub perception_radius:   f32,
69    /// Radius within which separation is active.
70    pub separation_radius:   f32,
71    /// Radius within which the predator is feared.
72    pub flee_radius:         f32,
73    /// Arrival slow-down begins within this radius of the leader.
74    pub arrival_radius:      f32,
75
76    // ── Speed limits ──────────────────────────────────────────────────────────
77    pub max_speed:     f32,
78    pub max_force:     f32,
79    pub min_speed:     f32,
80
81    // ── Containment box ───────────────────────────────────────────────────────
82    pub bounds_min: Vec3,
83    pub bounds_max: Vec3,
84}
85
86impl Default for FlockConfig {
87    fn default() -> Self {
88        Self {
89            alignment_weight:   1.0,
90            cohesion_weight:    0.8,
91            separation_weight:  1.5,
92            avoidance_weight:   3.0,
93            leader_weight:      1.2,
94            flee_weight:        2.5,
95            wander_weight:      0.3,
96            containment_weight: 2.0,
97            perception_radius:  4.0,
98            separation_radius:  1.5,
99            flee_radius:        8.0,
100            arrival_radius:     2.0,
101            max_speed:          5.0,
102            max_force:          10.0,
103            min_speed:          0.5,
104            bounds_min:         Vec3::splat(-50.0),
105            bounds_max:         Vec3::splat(50.0),
106        }
107    }
108}
109
110// ── Boid state ────────────────────────────────────────────────────────────────
111
112/// State for a single boid in the flock.
113#[derive(Clone, Debug)]
114pub struct Boid {
115    pub position:    Vec3,
116    pub velocity:    Vec3,
117    /// Wander circle angle (radians) — drifts each frame for smooth randomness.
118    wander_angle:    f32,
119    /// Accumulated age (used for wander noise phase).
120    age:             f32,
121    /// Index into the flock (for seeding per-boid random values).
122    pub index:       usize,
123}
124
125impl Boid {
126    pub fn new(position: Vec3, velocity: Vec3, index: usize) -> Self {
127        Self {
128            position,
129            velocity,
130            wander_angle: index as f32 * 2.399,  // irrational offset
131            age: 0.0,
132            index,
133        }
134    }
135
136    /// Advance the boid by dt with computed steering force.
137    pub fn integrate(&mut self, steering: Vec3, dt: f32, config: &FlockConfig) {
138        let clamped = limit(steering, config.max_force);
139        self.velocity += clamped * dt;
140        self.velocity  = limit(self.velocity, config.max_speed);
141
142        // Enforce min speed (boids always move a little)
143        let speed = self.velocity.length();
144        if speed < config.min_speed && speed > 0.001 {
145            self.velocity = self.velocity / speed * config.min_speed;
146        }
147
148        self.position += self.velocity * dt;
149        self.age += dt;
150    }
151}
152
153// ── Steering computations ─────────────────────────────────────────────────────
154
155/// Compute all steering forces for a single boid and return the total.
156pub fn compute_steering(
157    boid:       &mut Boid,
158    neighbors:  &[FlockNeighbor],
159    obstacles:  &[Obstacle],
160    leader:     Option<Vec3>,
161    predators:  &[Vec3],
162    config:     &FlockConfig,
163) -> Vec3 {
164    let mut total = Vec3::ZERO;
165
166    // ── Classic boid rules ────────────────────────────────────────────────────
167    let (align, cohere, separate) = boid_forces(boid, neighbors, config);
168    total += align    * config.alignment_weight;
169    total += cohere   * config.cohesion_weight;
170    total += separate * config.separation_weight;
171
172    // ── Obstacle avoidance ────────────────────────────────────────────────────
173    if config.avoidance_weight > 0.0 {
174        total += avoid_obstacles(boid, obstacles) * config.avoidance_weight;
175    }
176
177    // ── Leader following ──────────────────────────────────────────────────────
178    if config.leader_weight > 0.0 {
179        if let Some(target) = leader {
180            total += seek_arrive(boid, target, config) * config.leader_weight;
181        }
182    }
183
184    // ── Predator flee ─────────────────────────────────────────────────────────
185    if config.flee_weight > 0.0 && !predators.is_empty() {
186        total += flee_predators(boid, predators, config) * config.flee_weight;
187    }
188
189    // ── Wander ────────────────────────────────────────────────────────────────
190    if config.wander_weight > 0.0 {
191        total += wander(boid, config) * config.wander_weight;
192    }
193
194    // ── Containment ───────────────────────────────────────────────────────────
195    if config.containment_weight > 0.0 {
196        total += contain(boid, config) * config.containment_weight;
197    }
198
199    total
200}
201
202/// Classic three boid rules: alignment, cohesion, separation.
203fn boid_forces(
204    boid:      &Boid,
205    neighbors: &[FlockNeighbor],
206    config:    &FlockConfig,
207) -> (Vec3, Vec3, Vec3) {
208    if neighbors.is_empty() {
209        return (Vec3::ZERO, Vec3::ZERO, Vec3::ZERO);
210    }
211
212    let mut align_sum = Vec3::ZERO;
213    let mut cohere_sum = Vec3::ZERO;
214    let mut sep_sum = Vec3::ZERO;
215    let mut align_count = 0usize;
216    let mut cohere_count = 0usize;
217
218    for n in neighbors {
219        let delta = boid.position - n.position;
220        let dist  = delta.length();
221
222        if dist < config.perception_radius && dist > 0.001 {
223            align_sum  += n.velocity;
224            cohere_sum += n.position;
225            align_count  += 1;
226            cohere_count += 1;
227
228            if dist < config.separation_radius {
229                // Separation force: inverse distance weighted
230                sep_sum += delta / (dist * dist);
231            }
232        }
233    }
234
235    let align = if align_count > 0 {
236        let avg = align_sum / align_count as f32;
237        steer_toward(boid, avg.normalize_or_zero() * config.max_speed)
238    } else {
239        Vec3::ZERO
240    };
241
242    let cohere = if cohere_count > 0 {
243        let center = cohere_sum / cohere_count as f32;
244        steer_toward(boid, (center - boid.position).normalize_or_zero() * config.max_speed)
245    } else {
246        Vec3::ZERO
247    };
248
249    let separate = if sep_sum.length() > 0.001 {
250        steer_toward(boid, sep_sum.normalize_or_zero() * config.max_speed)
251    } else {
252        Vec3::ZERO
253    };
254
255    (align, cohere, separate)
256}
257
258/// Obstacle avoidance: steer away from sphere obstacles.
259fn avoid_obstacles(boid: &Boid, obstacles: &[Obstacle]) -> Vec3 {
260    let mut force = Vec3::ZERO;
261    let look_ahead = boid.velocity.normalize_or_zero() * 2.5;
262    let future_pos = boid.position + look_ahead;
263
264    for obs in obstacles {
265        let delta = future_pos - obs.center;
266        let dist  = delta.length();
267        let threat_radius = obs.radius + 1.0;  // buffer zone
268
269        if dist < threat_radius && dist > 0.001 {
270            let push = delta / dist * (threat_radius - dist) / threat_radius;
271            force += push;
272        }
273    }
274
275    // Also consider current position penetration
276    for obs in obstacles {
277        let overlap = obs.overlap(boid.position);
278        if overlap > 0.0 {
279            let dir = (boid.position - obs.center).normalize_or_zero();
280            force += dir * overlap * 5.0;
281        }
282    }
283
284    force
285}
286
287/// Seek and arrive at a leader/target with smooth slowdown near arrival radius.
288fn seek_arrive(boid: &Boid, target: Vec3, config: &FlockConfig) -> Vec3 {
289    let delta = target - boid.position;
290    let dist  = delta.length();
291    if dist < 0.01 { return Vec3::ZERO; }
292
293    let desired_speed = if dist < config.arrival_radius {
294        config.max_speed * (dist / config.arrival_radius)
295    } else {
296        config.max_speed
297    };
298
299    let desired = delta / dist * desired_speed;
300    steer_toward(boid, desired)
301}
302
303/// Flee from all predator positions within flee_radius.
304fn flee_predators(boid: &Boid, predators: &[Vec3], config: &FlockConfig) -> Vec3 {
305    let mut force = Vec3::ZERO;
306    for &predator in predators {
307        let delta = boid.position - predator;
308        let dist  = delta.length();
309        if dist < config.flee_radius && dist > 0.001 {
310            let urgency = 1.0 - dist / config.flee_radius;
311            force += delta.normalize_or_zero() * urgency;
312        }
313    }
314    if force.length() > 0.001 {
315        steer_toward(boid, force.normalize_or_zero() * config.max_speed)
316    } else {
317        Vec3::ZERO
318    }
319}
320
321/// Wander: smooth random steering using a wander circle on the velocity vector.
322fn wander(boid: &mut Boid, config: &FlockConfig) -> Vec3 {
323    let wander_radius   = 1.5f32;
324    let wander_distance = 3.0f32;
325    let wander_jitter   = 0.8f32;
326
327    // Drift the wander angle with noise
328    let noise = lcg_f32(boid.index as u64, (boid.age * 10.0) as u64) * 2.0 - 1.0;
329    boid.wander_angle += noise * wander_jitter;
330
331    let circle_center = boid.velocity.normalize_or_zero() * wander_distance;
332    let wander_point = Vec3::new(
333        circle_center.x + boid.wander_angle.cos() * wander_radius,
334        circle_center.y + boid.wander_angle.sin() * wander_radius,
335        circle_center.z,
336    );
337
338    let _ = config; // suppress unused warning
339    wander_point.normalize_or_zero()
340}
341
342/// Containment: steer back inward when approaching bounding box edges.
343fn contain(boid: &Boid, config: &FlockConfig) -> Vec3 {
344    let margin = 5.0f32;
345    let mut force = Vec3::ZERO;
346
347    let min = config.bounds_min;
348    let max = config.bounds_max;
349
350    if boid.position.x < min.x + margin { force.x += 1.0; }
351    if boid.position.x > max.x - margin { force.x -= 1.0; }
352    if boid.position.y < min.y + margin { force.y += 1.0; }
353    if boid.position.y > max.y - margin { force.y -= 1.0; }
354    if boid.position.z < min.z + margin { force.z += 1.0; }
355    if boid.position.z > max.z - margin { force.z -= 1.0; }
356
357    force
358}
359
360// ── Full flock simulation ─────────────────────────────────────────────────────
361
362/// The complete flock — owns and ticks all boids each frame.
363pub struct Flock {
364    pub boids:     Vec<Boid>,
365    pub config:    FlockConfig,
366    pub obstacles: Vec<Obstacle>,
367    pub leader:    Option<Vec3>,
368    pub predators: Vec<Vec3>,
369}
370
371impl Flock {
372    pub fn new(config: FlockConfig) -> Self {
373        Self {
374            boids: Vec::new(),
375            config,
376            obstacles: Vec::new(),
377            leader: None,
378            predators: Vec::new(),
379        }
380    }
381
382    /// Spawn N boids in a circle at the given center with random velocities.
383    pub fn spawn_circle(&mut self, n: usize, center: Vec3, radius: f32) {
384        let start = self.boids.len();
385        for i in 0..n {
386            let angle = i as f32 / n as f32 * TAU;
387            let pos = center + Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0);
388            let vel_angle = angle + std::f32::consts::FRAC_PI_2;
389            let vel = Vec3::new(vel_angle.cos(), vel_angle.sin(), 0.0) * self.config.min_speed;
390            self.boids.push(Boid::new(pos, vel, start + i));
391        }
392    }
393
394    /// Spawn N boids in a random scatter within radius.
395    pub fn spawn_scatter(&mut self, n: usize, center: Vec3, radius: f32, seed: u64) {
396        let start = self.boids.len();
397        let mut rng = seed;
398        for i in 0..n {
399            rng = rng.wrapping_mul(0x6c62272e07bb0142).wrapping_add(0x62b821756295c58d);
400            let angle = (rng >> 32) as f32 / u32::MAX as f32 * TAU;
401            rng = rng.wrapping_mul(0x6c62272e07bb0142).wrapping_add(0x62b821756295c58d);
402            let r = ((rng >> 32) as f32 / u32::MAX as f32).sqrt() * radius;
403            let pos = center + Vec3::new(angle.cos() * r, angle.sin() * r, 0.0);
404            let vel_angle = angle + 1.57;
405            let vel = Vec3::new(vel_angle.cos(), vel_angle.sin(), 0.0) * self.config.min_speed;
406            self.boids.push(Boid::new(pos, vel, start + i));
407        }
408    }
409
410    /// Tick all boids by dt seconds. Computes neighbor lists and integrates.
411    pub fn tick(&mut self, dt: f32) {
412        let n = self.boids.len();
413        if n == 0 { return; }
414
415        // Snapshot current positions/velocities for neighbor computation
416        let snapshot: Vec<FlockNeighbor> = self.boids.iter()
417            .map(|b| FlockNeighbor { position: b.position, velocity: b.velocity })
418            .collect();
419
420        // Compute steering forces (needs snapshot so we can mutably borrow boids)
421        let forces: Vec<Vec3> = (0..n).map(|i| {
422            let boid = &self.boids[i];
423            let neighbors: Vec<FlockNeighbor> = snapshot.iter().enumerate()
424                .filter(|(j, _)| *j != i)
425                .map(|(_, n)| FlockNeighbor { position: n.position, velocity: n.velocity })
426                .collect();
427
428            // We need mutable access to boid for wander_angle; clone and integrate later
429            let mut tmp_boid = boid.clone();
430            compute_steering(
431                &mut tmp_boid,
432                &neighbors,
433                &self.obstacles,
434                self.leader,
435                &self.predators,
436                &self.config,
437            )
438        }).collect();
439
440        // Apply forces and integrate
441        for (boid, force) in self.boids.iter_mut().zip(forces.iter()) {
442            boid.integrate(*force, dt, &self.config);
443        }
444    }
445
446    /// Add a spherical obstacle to avoid.
447    pub fn add_obstacle(&mut self, center: Vec3, radius: f32) {
448        self.obstacles.push(Obstacle::new(center, radius));
449    }
450
451    /// Set the leader position (boids will follow).
452    pub fn set_leader(&mut self, pos: Option<Vec3>) {
453        self.leader = pos;
454    }
455
456    /// Set predator positions (boids will flee).
457    pub fn set_predators(&mut self, predators: Vec<Vec3>) {
458        self.predators = predators;
459    }
460
461    /// Number of boids.
462    pub fn len(&self) -> usize { self.boids.len() }
463    pub fn is_empty(&self) -> bool { self.boids.is_empty() }
464
465    /// Average position (flock centroid).
466    pub fn centroid(&self) -> Vec3 {
467        if self.boids.is_empty() { return Vec3::ZERO; }
468        let sum: Vec3 = self.boids.iter().map(|b| b.position).sum();
469        sum / self.boids.len() as f32
470    }
471
472    /// Average speed.
473    pub fn avg_speed(&self) -> f32 {
474        if self.boids.is_empty() { return 0.0; }
475        self.boids.iter().map(|b| b.velocity.length()).sum::<f32>() / self.boids.len() as f32
476    }
477
478    /// Cohesion metric: 1.0 = tight flock, 0.0 = completely scattered.
479    /// Measured as 1 / (1 + average distance from centroid).
480    pub fn cohesion_metric(&self) -> f32 {
481        if self.boids.is_empty() { return 1.0; }
482        let c = self.centroid();
483        let avg_dist = self.boids.iter()
484            .map(|b| (b.position - c).length())
485            .sum::<f32>() / self.boids.len() as f32;
486        1.0 / (1.0 + avg_dist)
487    }
488
489    /// Polarization: how aligned the flock velocity is (1.0 = all same direction).
490    pub fn polarization(&self) -> f32 {
491        if self.boids.is_empty() { return 0.0; }
492        let sum: Vec3 = self.boids.iter()
493            .map(|b| b.velocity.normalize_or_zero())
494            .sum();
495        sum.length() / self.boids.len() as f32
496    }
497
498    /// Kill and remove boids with a predicate.
499    pub fn remove_if(&mut self, predicate: impl Fn(&Boid) -> bool) {
500        self.boids.retain(|b| !predicate(b));
501    }
502}
503
504// ── Free function versions (for use without full Flock struct) ────────────────
505
506/// Compute a steering force for one boid given its neighbors.
507/// Simplified version using just the three classic forces.
508pub fn flock_force(
509    pos:        Vec3,
510    vel:        Vec3,
511    neighbors:  &[FlockNeighbor],
512    alignment:  f32,
513    cohesion:   f32,
514    separation: f32,
515    radius:     f32,
516) -> Vec3 {
517    if neighbors.is_empty() { return Vec3::ZERO; }
518
519    let mut avg_vel  = Vec3::ZERO;
520    let mut avg_pos  = Vec3::ZERO;
521    let mut sep      = Vec3::ZERO;
522    let mut count    = 0usize;
523
524    for n in neighbors {
525        let delta = pos - n.position;
526        let dist  = delta.length();
527        if dist < radius && dist > 0.001 {
528            avg_vel += n.velocity;
529            avg_pos += n.position;
530            count   += 1;
531            if dist < radius * 0.5 {
532                sep += delta / (dist * dist);
533            }
534        }
535    }
536
537    if count == 0 { return Vec3::ZERO; }
538
539    let n = count as f32;
540    avg_vel /= n;
541    avg_pos /= n;
542
543    let align   = (avg_vel - vel) * alignment;
544    let cohese  = (avg_pos - pos) * cohesion;
545    let repel   = sep * separation;
546
547    align + cohese + repel
548}
549
550// ── Utility ───────────────────────────────────────────────────────────────────
551
552/// Compute a steering force (desired - current velocity), clamped to max_force.
553fn steer_toward(boid: &Boid, desired: Vec3) -> Vec3 {
554    desired - boid.velocity
555}
556
557/// Clamp a vector to maximum length.
558fn limit(v: Vec3, max: f32) -> Vec3 {
559    let len = v.length();
560    if len > max && len > 0.001 { v / len * max } else { v }
561}
562
563/// Simple LCG float in [0, 1) from two u64 seeds.
564fn lcg_f32(seed1: u64, seed2: u64) -> f32 {
565    let x = seed1.wrapping_mul(0x9e3779b97f4a7c15)
566                 .wrapping_add(seed2)
567                 .wrapping_mul(0x6c62272e07bb0142);
568    (x >> 32) as f32 / u32::MAX as f32
569}
570
571// ── Tests ─────────────────────────────────────────────────────────────────────
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn flock_ticks_without_panic() {
579        let mut flock = Flock::new(FlockConfig::default());
580        flock.spawn_circle(10, Vec3::ZERO, 3.0);
581        assert_eq!(flock.len(), 10);
582        flock.tick(0.016);
583        flock.tick(0.016);
584    }
585
586    #[test]
587    fn separation_pushes_apart() {
588        let pos = Vec3::ZERO;
589        let vel = Vec3::X;
590        let neighbors = vec![
591            FlockNeighbor { position: Vec3::new(0.5, 0.0, 0.0), velocity: Vec3::X },
592        ];
593        let force = flock_force(pos, vel, &neighbors, 0.0, 0.0, 1.0, 4.0);
594        // Separation should push away (negative x direction from 0.5 offset)
595        assert!(force.x < 0.0);
596    }
597
598    #[test]
599    fn cohesion_metric_tight_flock() {
600        let mut flock = Flock::new(FlockConfig::default());
601        flock.spawn_circle(6, Vec3::ZERO, 0.1);
602        let metric = flock.cohesion_metric();
603        assert!(metric > 0.8);
604    }
605
606    #[test]
607    fn flock_flee_changes_direction() {
608        let config = FlockConfig { flee_weight: 5.0, ..Default::default() };
609        let mut flock = Flock::new(config);
610        flock.spawn_circle(5, Vec3::new(3.0, 0.0, 0.0), 0.5);
611        flock.set_predators(vec![Vec3::ZERO]);
612
613        let init_centroid = flock.centroid();
614        for _ in 0..10 {
615            flock.tick(0.016);
616        }
617        let after_centroid = flock.centroid();
618        // Flock should have moved away from origin
619        assert!(after_centroid.x > init_centroid.x);
620    }
621}