diff --git a/simulator/Cargo.toml b/simulator/Cargo.toml index 82ce93b..a94ee21 100644 --- a/simulator/Cargo.toml +++ b/simulator/Cargo.toml @@ -12,3 +12,7 @@ rand_gen = { package = "rand", version = "0.10.3" } rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } toml = "1.2.1" + +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/simulator/src/main.rs b/simulator/src/main.rs index c7da006..f6e7797 100644 --- a/simulator/src/main.rs +++ b/simulator/src/main.rs @@ -1,15 +1,12 @@ -use std::cmp::min; use std::collections::HashMap; use std::f32::consts::TAU; use std::fs; use std::path::PathBuf; -use std::ptr; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use macroquad::prelude::*; use parry2d::math::Pose; -use parry2d::math::Pose2; use parry2d::math::Rot2; use parry2d::math::Vec2; use parry2d::query::contact; @@ -31,6 +28,148 @@ use serde::Deserialize; const BOT_RADIUS: f32 = 16.0; +/// Broad-phase grid cell size. Derived from the sensing range (the dominant +/// query length) and clamped so both sensing and food pick-up stay efficient +/// across configurations. +fn grid_cell(view_dist: f32) -> f32 { + (view_dist * 0.8).clamp(64.0, 256.0) +} + +/// Uniform spatial grid over a slice of 2D points, rebuilt every simulation +/// step. Indices are stored in an intrusive singly-linked list (`head`/`next`) +/// so building and querying is allocation-free and hash-free. +struct Grid { + cell: f32, + inv_cell: f32, + min_x: f32, + min_y: f32, + cols: i32, + rows: i32, + head: Vec, + next: Vec, +} + +impl Grid { + fn new(cell: f32) -> Self { + Self { + cell, + inv_cell: 1.0 / cell, + min_x: 0.0, + min_y: 0.0, + cols: 0, + rows: 0, + head: Vec::new(), + next: Vec::new(), + } + } + + /// Rebuild the grid for `pts` (index `i` in `pts` is what queries return). + fn rebuild(&mut self, pts: &[Vec2]) { + if pts.is_empty() { + self.cols = 0; + self.rows = 0; + self.head.clear(); + self.next.clear(); + return; + } + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); + for p in pts { + min_x = min_x.min(p.x); + min_y = min_y.min(p.y); + max_x = max_x.max(p.x); + max_y = max_y.max(p.y); + } + // One cell of slack on every side keeps the arithmetic in bounds. + self.min_x = min_x - self.cell; + self.min_y = min_y - self.cell; + self.cols = (((max_x - self.min_x) * self.inv_cell) as i32 + 2).max(1); + self.rows = (((max_y - self.min_y) * self.inv_cell) as i32 + 2).max(1); + + let n = (self.cols as usize) * (self.rows as usize); + self.head.clear(); + self.head.resize(n, -1); + self.next.clear(); + self.next.resize(pts.len(), -1); + for (i, p) in pts.iter().enumerate() { + let cx = ((p.x - self.min_x) * self.inv_cell) as i32; + let cy = ((p.y - self.min_y) * self.inv_cell) as i32; + let cx = cx.clamp(0, self.cols - 1); + let cy = cy.clamp(0, self.rows - 1); + let c = (cy * self.cols + cx) as usize; + self.next[i] = self.head[c]; + self.head[c] = i as i32; + } + } + + /// Visit every index whose point lies inside the axis-aligned box + /// `[lo, hi]`. Conservative: may visit points just outside. + #[inline] + fn for_each_in_aabb(&self, lo: Vec2, hi: Vec2, mut f: impl FnMut(usize)) { + if self.cols == 0 { + return; + } + let cx0 = (((lo.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1); + let cx1 = (((hi.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1); + let cy0 = (((lo.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1); + let cy1 = (((hi.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1); + for cy in cy0..=cy1 { + let base = cy * self.cols; + for cx in cx0..=cx1 { + let mut i = self.head[(base + cx) as usize]; + while i >= 0 { + f(i as usize); + i = self.next[i as usize]; + } + } + } + } +} + +/// Exact distance from `a` to the point where the segment `a..b` first comes +/// within `radius` of `center`, or `None` if it never does. +/// +/// This reproduces parry's `contact(Pose::IDENTITY, segment, center, ball, 0.0)` +/// followed by `contact.point1.distance(a)`: parry reports the closest point on +/// the segment as `point1`, and contacts exactly when the centre-to-segment +/// distance is `<= radius`. +#[inline] +fn seg_ball_dist(a: Vec2, b: Vec2, center: Vec2, radius: f32) -> Option { + let ab = b - a; + let ab2 = ab.length_squared(); + let t = if ab2 > 0.0 { + ((center - a).dot(ab) / ab2).clamp(0.0, 1.0) + } else { + 0.0 + }; + let q = a + ab * t; + if (center - q).length_squared() <= radius * radius { + Some((q - a).length()) + } else { + None + } +} + +/// Exact distance from a ball's centre to the contact point on another ball, +/// matching parry's ball-ball contact (`point1` sits on ball 1's surface along +/// the centre-to-centre normal). Contacts exactly when the centres are closer +/// than the sum of the radii. The add-then-subtract mirrors parry transforming +/// the local contact point into world space and measuring back to the centre, +/// so the result is bit-identical (including the cancellation error). +#[inline] +fn ball_ball_dist(center1: Vec2, center2: Vec2, radius: f32) -> Option { + let delta = center2 - center1; + let d2 = delta.length_squared(); + let sum = radius + radius; + if d2 < sum * sum { + let n = if d2 != 0.0 { delta.normalize() } else { Vec2::X }; + let world_point1 = center1 + n * radius; + Some(world_point1.distance(center1)) + } else { + None + } +} + trait Mortal { fn collider(&self) -> (&Pose, &dyn Shape); fn kill(&self); @@ -90,11 +229,20 @@ impl Robot { } - fn detect<'a>(&self, things: impl Iterator) -> Option { - let collider = Segment::new(self.pose.translation, self.view_end()); - things - .map(|thing| contact(&Pose2::IDENTITY, &collider, thing.0, thing.1, 0.0).unwrap()).flatten() - .map(|contact| contact.point1.distance(self.pose.translation)).reduce(|x: f32,y: f32| x.min(y)) + /// Distance from the robot's translation to the closest point on its view + /// segment that lies within `radius` of `center`, or `None` if the segment + /// misses the target's ball. This is an exact, closed-form replacement for + /// parry's GJK `contact` query in the ball-vs-segment case (all bodies here + /// are circles), and is dramatically cheaper. + #[inline] + fn contact_dist(&self, translation: Vec2, end: Vec2, center: Vec2) -> Option { + let reach = self.view_dist + BOT_RADIUS; + let dx = center.x - translation.x; + let dy = center.y - translation.y; + if dx * dx + dy * dy > reach * reach { + return None; + } + seg_ball_dist(translation, end, center, BOT_RADIUS) } fn crater(&self) -> (f32, f32, f32, Color) { @@ -134,7 +282,7 @@ struct Food { alive: AtomicBool, } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone, Copy)] enum Sensor { Food, Kin, @@ -169,10 +317,24 @@ impl Food { } - fn detect<'a>(&self, things: impl Iterator) -> Option { - things - .map(|robot| contact(&self.pose, &self.collider, &robot.pose, &robot.collider, 0.0).unwrap().map(|a| (a,robot.team))).flatten() - .map(|(contact,team)| (contact.point1.distance(self.pose.translation),team)).reduce(|x ,y| if x.0 < y.0 {x} else{y} ).map(|(_, team)| team) + /// Team of the nearest robot touching this food, via the robot broad-phase + /// grid. Preserves the original tie-break (a later candidate wins ties). + fn detect(&self, grid: &Grid, robots: &[Robot]) -> Option { + let c = self.pose.translation; + let r = 2.0 * BOT_RADIUS; + let lo = Vec2::new(c.x - r, c.y - r); + let hi = Vec2::new(c.x + r, c.y + r); + let mut best: Option<(f32, Color)> = None; + grid.for_each_in_aabb(lo, hi, |ri| { + let robot = &robots[ri]; + if let Some(d) = ball_ball_dist(c, robot.pose.translation, BOT_RADIUS) { + match best { + Some((bd, _)) if bd < d => {} + _ => best = Some((d, robot.team)), + } + } + }); + best.map(|(_, team)| team) } } @@ -253,6 +415,12 @@ struct Simulation { new_craters: Vec<(f32, f32, f32, Color)>, watchdog: i32, score: HashMap<[u32;4], i32>, + robot_grid: Grid, + food_grid: Grid, + // Scratch buffers reused every step to avoid per-step allocations. + robot_pos: Vec, + food_pos: Vec, + sensors: Vec, } impl Simulation { @@ -288,7 +456,10 @@ impl Simulation { let y = rng.random_range(200.0 .. 900.0); food.push(Food::new(Vec2 { x, y })); } - Self { robots, food, craters, new_craters, watchdog, score } + let cell = grid_cell(config.view_dist); + Self { robots, food, craters, new_craters, watchdog, score, + robot_grid: Grid::new(cell), food_grid: Grid::new(cell), + robot_pos: Vec::new(), food_pos: Vec::new(), sensors: Vec::new() } } fn color_hash(color: Color) -> [u32; 4] { @@ -296,10 +467,18 @@ impl Simulation { } fn sim_step(&mut self) -> Option<(i32, i32)>{ - let Simulation { robots, food, craters, new_craters, watchdog, score } = self; + let Simulation { robots, food, craters: _, new_craters, watchdog, score, + robot_grid, food_grid, robot_pos, food_pos, sensors } = self; + + // Broad-phase for food pickup: must cover the same robot set the + // original iterated, i.e. *before* the retain below (dead robots get + // one more chance to score). + robot_pos.clear(); + robot_pos.extend(robots.iter().map(|r| r.pose.translation)); + robot_grid.rebuild(robot_pos); for food in &*food{ - if let Some(team) = food.detect(robots.iter()) { + if let Some(team) = food.detect(robot_grid, robots) { if food.living() { score.entry(Self::color_hash(team)).and_modify(|s| *s +=1 ).or_insert(1); food.kill(); @@ -308,14 +487,50 @@ impl Simulation { } } + let robots_before = robots.len(); food.retain(Mortal::living); robots.retain(Mortal::living); - let mut sensors = Vec::new(); - for robot in &*robots { - let detect_food = robot.detect(food.iter().map(Food::collider)); - let detect_kin = robot.detect(robots.iter().filter(|o| o.team == robot.team).filter(|r| !ptr::eq(&**r , &**&robot)).map(Robot::collider)); - let detect_non_kin = robot.detect(robots.iter().filter(|o| o.team != robot.team).filter(|r| !ptr::eq(&**r , &**&robot)).map(Robot::collider)); + // Rebuild the grids on the retained sets for the sensing pass. The + // robot grid is still valid (positions unchanged) when no robot died. + if robots.len() != robots_before { + robot_pos.clear(); + robot_pos.extend(robots.iter().map(|r| r.pose.translation)); + robot_grid.rebuild(robot_pos); + } + food_pos.clear(); + food_pos.extend(food.iter().map(|f| f.pose.translation)); + food_grid.rebuild(food_pos); + + sensors.clear(); + for (i, robot) in robots.iter().enumerate() { + let translation = robot.pose.translation; + let end = robot.view_end(); + let lo = Vec2::new(translation.x.min(end.x) - BOT_RADIUS, translation.y.min(end.y) - BOT_RADIUS); + let hi = Vec2::new(translation.x.max(end.x) + BOT_RADIUS, translation.y.max(end.y) + BOT_RADIUS); + + let mut detect_food: Option = None; + food_grid.for_each_in_aabb(lo, hi, |fi| { + if let Some(d) = robot.contact_dist(translation, end, food[fi].pose.translation) { + detect_food = Some(detect_food.map_or(d, |b| b.min(d))); + } + }); + + let mut detect_kin: Option = None; + let mut detect_non_kin: Option = None; + robot_grid.for_each_in_aabb(lo, hi, |ri| { + if ri == i { + return; + } + let other = &robots[ri]; + if let Some(d) = robot.contact_dist(translation, end, other.pose.translation) { + if other.team == robot.team { + detect_kin = Some(detect_kin.map_or(d, |b| b.min(d))); + } else { + detect_non_kin = Some(detect_non_kin.map_or(d, |b| b.min(d))); + } + } + }); let mut sensor = Sensor::None; let mut closest: Option = None; @@ -334,6 +549,7 @@ impl Simulation { sensors.push(sensor); } + for idx in 0..robots.len() { let (before, after) = robots.split_at_mut(idx); let (robot, after) = after.split_first_mut().unwrap(); @@ -362,7 +578,7 @@ impl Simulation { } async fn render(&mut self) { - let Simulation { robots, food, craters, new_craters, watchdog: _, score } = self; + let Simulation { robots, food, craters, new_craters, watchdog: _, score, .. } = self; clear_background(LIGHTGRAY); for crater in &*new_craters { @@ -440,31 +656,163 @@ struct Cli { debug: u8, } -#[macroquad::main("Adversarial Foraging Simulator")] -async fn main() { +fn load_config(path: &std::path::Path) -> SimConfig { + let text = fs::read_to_string(path).unwrap(); + toml::from_str(&text).unwrap() +} + +fn print_average(red: i32, blu: i32, iters: u64) { + let iters = iters.max(1) as f32; + let red = red as f32 / iters; + let blu = blu as f32 / iters; + println!("{} wins on average! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"}); +} + +fn main() { let cli = Cli::parse(); + + // Headless runs never need a window. Going through macroquad's event loop + // would otherwise cost seconds of X11/window setup on every invocation, + // which dwarfs the actual simulation for parallel sweeps. + if cli.no_gui { + let config = load_config(&cli.config); + let iters = cli.run_count.unwrap_or(1); + let (red, blu) = (0..iters) + .into_par_iter() + .map(|s| run_headless_simulation(s, &config)) + .reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1)); + print_average(red, blu, iters); + } else { + macroquad::Window::new("Adversarial Foraging Simulator", gui_main(cli)); + } +} + +async fn gui_main(cli: Cli) { let iters = cli.run_count.unwrap_or(1); let speedup = cli.gui_speedup.unwrap_or(5); + let config = load_config(&cli.config); - let config = fs::read_to_string(cli.config).unwrap(); - let config: SimConfig = toml::from_str(&config).unwrap(); - - let (red, blu) = if cli.no_gui { - (0..iters).into_par_iter().map(|s |run_headless_simulation(s,&config)).reduce(|| (0,0), |a,b| (a.0 + b.0, a.1 + b.1)) - } else { - let (mut rtot, mut btot) = (0, 0); - for i in 0..iters { - let (red, blu) = run_simulation(i, speedup, &config).await; - rtot += red; - btot += blu; - - println!("{} wins! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"}); - } - (rtot,btot) - }; - - let red = red as f32 / iters as f32; - let blu = blu as f32 / iters as f32; - println!("{} wins on average! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"}); + let (mut rtot, mut btot) = (0, 0); + for i in 0..iters { + let (red, blu) = run_simulation(i, speedup, &config).await; + rtot += red; + btot += blu; + println!("{} wins! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"}); + } + print_average(rtot, btot, iters); +} + +#[cfg(test)] +mod tests { + use super::*; + use parry2d::math::Pose2; + + fn config() -> SimConfig { + toml::from_str(include_str!("../conf.toml")).unwrap() + } + + /// The analytic replacements must agree with parry bit-for-bit, both on + /// whether a contact exists and on the reported `point1.distance(origin)`. + /// This is the load-bearing test behind the whole speedup: the hot path no + /// longer calls into parry at all. + #[test] + fn analytic_matches_parry() { + let mut rng = SmallRng::seed_from_u64(0xC0FFEE); + let mut seg_contacts = 0u64; + let mut bb_contacts = 0u64; + for _ in 0..2_000_000 { + // segment vs ball + let a = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0)); + let ang = rng.random_range(0.0f32..TAU); + let len = rng.random_range(0.0f32..300.0); + let b = a + Vec2::new(ang.cos(), ang.sin()) * len; + let center = Vec2::new(rng.random_range(-600.0f32..600.0), rng.random_range(-600.0..600.0)); + let pose = Pose::from_translation(center.to_array().into()); + let parry = contact(&Pose2::IDENTITY, &Segment::new(a, b), &pose, &Ball::new(BOT_RADIUS), 0.0) + .unwrap() + .map(|c| c.point1.distance(a)); + let mine = seg_ball_dist(a, b, center, BOT_RADIUS); + assert_eq!(parry.is_some(), mine.is_some(), "segment-ball contact existence"); + if let (Some(p), Some(m)) = (parry, mine) { + assert_eq!(p.to_bits(), m.to_bits(), "segment-ball distance {p} != {m}"); + seg_contacts += 1; + } + + // ball vs ball + let c1 = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0)); + let c2 = Vec2::new(rng.random_range(-520.0f32..520.0), rng.random_range(-520.0..520.0)); + let p1 = Pose::from_translation(c1.to_array().into()); + let p2 = Pose::from_translation(c2.to_array().into()); + let parry = contact(&p1, &Ball::new(BOT_RADIUS), &p2, &Ball::new(BOT_RADIUS), 0.0) + .unwrap() + .map(|c| c.point1.distance(c1)); + let mine = ball_ball_dist(c1, c2, BOT_RADIUS); + assert_eq!(parry.is_some(), mine.is_some(), "ball-ball contact existence"); + if let (Some(p), Some(m)) = (parry, mine) { + assert_eq!(p.to_bits(), m.to_bits(), "ball-ball distance {p} != {m}"); + bb_contacts += 1; + } + } + // Guard against the generator accidentally producing (almost) no contacts. + assert!(seg_contacts > 1000, "too few segment-ball contacts sampled"); + assert!(bb_contacts > 1000, "too few ball-ball contacts sampled"); + } + + /// End-to-end behaviour lock: exact scores for the first 16 seeds. Any + /// change to the simulation's physics, ordering or collision code that + /// alters outcomes will fail here. + #[test] + fn regression_scores() { + let config = config(); + let expected: [(i32, i32); 16] = [ + (53, 45), (37, 61), (32, 48), (48, 51), + (43, 51), (30, 68), (59, 41), (49, 45), + (54, 37), (70, 28), (43, 41), (41, 42), + (44, 51), (49, 49), (30, 62), (48, 43), + ]; + for (seed, want) in expected.iter().enumerate() { + let got = run_headless_simulation(seed as u64, &config); + assert_eq!(got, *want, "seed {seed}"); + } + } + + /// The whole sweep must stay deterministic and reproduce the documented + /// average when run from scratch. + #[test] + fn regression_sweep_average() { + let config = config(); + let (red, blu) = (0..200u64) + .map(|seed| run_headless_simulation(seed, &config)) + .fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1)); + assert_eq!(red, 9615, "red total over 200 seeds"); + assert_eq!(blu, 9088, "blu total over 200 seeds"); + } + + /// Same seed twice must produce the same result (no hidden global state). + #[test] + fn deterministic() { + let config = config(); + for seed in 0..8u64 { + assert_eq!( + run_headless_simulation(seed, &config), + run_headless_simulation(seed, &config), + "seed {seed}" + ); + } + } + + /// Coarse performance smoke test. Run explicitly with: + /// cargo test --release -- --ignored --nocapture bench_full_sweep + #[test] + #[ignore] + fn bench_full_sweep() { + let config = config(); + let t0 = std::time::Instant::now(); + let (red, blu) = (0..200u64) + .map(|seed| run_headless_simulation(seed, &config)) + .fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1)); + let dt = t0.elapsed(); + println!("200 headless runs (1 core): {dt:?} ({:.3} ms/run) RED={red} BLU={blu}", dt.as_secs_f64() * 1000.0 / 200.0); + } }