Skip to main content

boids/
boids.rs

1//! Boids: flocking with separation, alignment and cohesion, using a spatial
2//! grid for neighbor search and all CPU cores for the update.
3//!
4//!   `cargo run --release --example boids`
5//!   `BOIDS=100000 cargo run --release --example boids`
6//!   `HEADLESS=1 BOIDS=1000000 TICKS=300 cargo run --release --example boids`
7//!
8//! Keys: Space pause, Period single step, 1-4 speed (1x/4x/16x/64x), Escape quit.
9
10use ponko::*;
11
12/// Reflected, so tools and agents (ponko-mcp) can read and change velocities.
13#[derive(serde::Serialize, serde::Deserialize)]
14struct Vel(Vec2);
15
16const RADIUS: f32 = 24.0;
17const MAX_SPEED: f32 = 140.0;
18const MIN_SPEED: f32 = 60.0;
19
20fn env_number(name: &str, default: f64) -> f64 {
21    std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
22}
23
24// ANCHOR: flocking
25/// The flocking system. It owns a grid that stores each boid's velocity, so
26/// neighbors' velocities can be read while the world is busy with the query.
27fn flocking(bounds: Vec2) -> impl FnMut(&mut Ctx) {
28    let mut grid = SpatialGrid::with_data(RADIUS);
29    move |ctx: &mut Ctx| {
30        let dt = ctx.dt();
31        // Keep boids that are close in space close in memory, so neighbor
32        // lookups hit the cache (1M boids: 62 -> 40 ms/tick).
33        if ctx.sim_tick().is_multiple_of(SPATIAL_SORT_EVERY) {
34            ctx.world.sort_by::<Transform2D>(|t| spatial_key(t.position, RADIUS));
35        }
36        grid.rebuild_with_data(
37            ctx.world
38                .query::<(Entity, &Transform2D, &Vel)>()
39                .iter()
40                .map(|(e, t, v)| (e, t.position, v.0)),
41        );
42        let grid = &grid;
43        ctx.world
44            .query::<(Entity, &mut Transform2D, &mut Vel)>()
45            .par_for_each(|(me, t, v)| {
46                let (mut away, mut heading, mut center, mut n) = (Vec2::ZERO, Vec2::ZERO, Vec2::ZERO, 0.0);
47                grid.for_each_in_radius(t.position, RADIUS, |o| {
48                    if o.entity == me {
49                        return;
50                    }
51                    let d = t.position - o.position;
52                    away += d / d.length_squared().max(1.0);
53                    heading += o.data;
54                    center += o.position;
55                    n += 1.0;
56                });
57                let mut steer = away * 900.0;
58                if n > 0.0 {
59                    steer += (heading / n - v.0) * 1.5 + (center / n - t.position) * 1.2;
60                }
61                let speed = (v.0 + steer * dt).length().clamp(MIN_SPEED, MAX_SPEED);
62                v.0 = (v.0 + steer * dt).normalize_or(Vec2::X) * speed;
63                t.position = (t.position + v.0 * dt).rem_euclid(bounds);
64                t.rotation = v.0.to_angle();
65            });
66    }
67}
68// ANCHOR_END: flocking
69
70fn controls(ctx: &mut Ctx) {
71    if ctx.key_pressed(Key::Space) {
72        if ctx.is_paused() { ctx.resume() } else { ctx.pause() }
73    }
74    if ctx.key_pressed(Key::Period) {
75        ctx.step();
76    }
77    for (key, speed) in [(Key::Num1, 1.0), (Key::Num2, 4.0), (Key::Num3, 16.0), (Key::Num4, 64.0)] {
78        if ctx.key_pressed(key) {
79            ctx.set_sim_speed(speed);
80        }
81    }
82    if ctx.key_pressed(Key::Escape) {
83        ctx.quit();
84    }
85}
86
87fn spawn_boids(ctx: &mut Ctx, n: usize, bounds: Vec2) {
88    ctx.world.reflect::<Vel>();
89    let tex = ctx.load_texture("crab.png");
90    let mut s = 0x9E37_79B9_7F4A_7C15u64;
91    let mut rand = move || {
92        s ^= s << 13;
93        s ^= s >> 7;
94        s ^= s << 17;
95        (s >> 40) as f32 / (1u64 << 24) as f32
96    };
97    let mut batch = Vec::with_capacity(n);
98    for _ in 0..n {
99        let pos = vec2(rand(), rand()) * bounds;
100        let vel = Vec2::from_angle(rand() * std::f32::consts::TAU) * MIN_SPEED;
101        let tint = Color::rgb(0.5 + rand() * 0.5, 0.7 + rand() * 0.3, 0.9);
102        batch.push((
103            Transform2D::at(pos).scaled(Vec2::splat(0.5)),
104            Sprite::new(tex).with_color(tint),
105            Vel(vel),
106        ));
107    }
108    ctx.world.spawn_batch(batch);
109}
110
111fn main() {
112    let n = env_number("BOIDS", 20_000.0) as usize;
113    let headless = env_number("HEADLESS", 0.0) != 0.0;
114
115    if headless {
116        // Keep density like the windowed version: scale the world with the count.
117        let side = (n as f32 / 20_000.0).sqrt() * 1280.0;
118        let bounds = Vec2::splat(side);
119        let ticks = env_number("TICKS", 300.0) as u64;
120        let mut start = Instant::now();
121        let world = Game::new("boids", 1, 1)
122            .sim_system(flocking(bounds))
123            .run_headless(ticks, |ctx| {
124                spawn_boids(ctx, n, bounds);
125                start = Instant::now();
126            });
127        let secs = start.elapsed().as_secs_f64();
128        println!(
129            "{} boids, {ticks} ticks: {:.2} ms/tick ({:.0} ticks/s)",
130            world.len(),
131            secs * 1e3 / ticks as f64,
132            ticks as f64 / secs
133        );
134        return;
135    }
136
137    let bounds = vec2(1280.0, 720.0);
138    Game::new("Crabby Engine: boids", 1280, 720)
139        .sim_system(flocking(bounds))
140        .system(controls)
141        .run(move |ctx| {
142            spawn_boids(ctx, n, bounds);
143            let mut next = 1.0;
144            move |ctx: &mut Ctx| {
145                if ctx.time() >= next {
146                    next += 1.0;
147                    let sim_ms: f32 = ctx.system_timings().iter().filter(|t| t.sim).map(|t| t.avg_ms).sum();
148                    println!(
149                        "boids: {}  fps: {:.0}  sim: {:.2} ms/frame  tick: {}",
150                        ctx.world.len(),
151                        ctx.fps(),
152                        sim_ms,
153                        ctx.sim_tick()
154                    );
155                }
156            }
157        });
158}