commit 9c7a5dfcd4f01774b75d72a3cc31efd2e15cddc4 Author: Ray Date: Wed Apr 8 20:30:07 2026 -0500 Added collision bench diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/collision-bench/Cargo.lock b/collision-bench/Cargo.lock new file mode 100644 index 0000000..408b7f7 --- /dev/null +++ b/collision-bench/Cargo.lock @@ -0,0 +1,61 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "collision-bench" +version = "0.1.0" +dependencies = [ + "rayon", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] diff --git a/collision-bench/Cargo.toml b/collision-bench/Cargo.toml new file mode 100644 index 0000000..d1820f1 --- /dev/null +++ b/collision-bench/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "collision-bench" +version = "0.1.0" +edition = "2024" + +[dependencies] +rayon = "1.11.0" diff --git a/collision-bench/src/main.rs b/collision-bench/src/main.rs new file mode 100644 index 0000000..4948b45 --- /dev/null +++ b/collision-bench/src/main.rs @@ -0,0 +1,376 @@ +use rayon::prelude::*; +use std::time::{Duration, Instant}; + +// ============================================================ +// Public interface: traits that hide how entities are stored +// ============================================================ + +/// Read-only access to entity positions. Could be backed by a Bevy query, +/// a Vec, an ECS archetype table, whatever. +pub trait Positions: Sync { + fn len(&self) -> usize; + fn get(&self, index: u32) -> (f32, f32); +} + +/// Read-write access to entity kinematics. The collision system never +/// touches this — only the movement system does. +pub trait Kinematics: Send { + fn len(&self) -> usize; + fn get(&self, index: usize) -> (f32, f32, f32, f32); // x, y, vx, vy + fn set(&mut self, index: usize, x: f32, y: f32, vx: f32, vy: f32); +} + +/// Callback invoked for each collision pair. Could enqueue damage events, +/// flag entities for removal, whatever the game needs. +pub trait CollisionHandler: Send { + fn on_collision(&self, a: u32, b: u32); +} + +// ============================================================ +// Spatial grid — no knowledge of what an "entity" is +// ============================================================ + +pub struct SpatialGrid { + cell_size: f32, + inv_cell_size: f32, + grid_dim: usize, + total_cells: usize, + // Counting-sort buffers, reused across frames + cell_counts: Vec, + cell_offsets: Vec, + sorted: Vec, // entity indices sorted by cell + pairs: Vec<(u32, u32)>, // (cell_index, entity_index) staging +} + +impl SpatialGrid { + pub fn new(cell_size: f32, grid_dim: usize) -> Self { + let total_cells = grid_dim * grid_dim; + Self { + cell_size, + inv_cell_size: 1.0 / cell_size, + grid_dim, + total_cells, + cell_counts: vec![0u32; total_cells], + cell_offsets: vec![0u32; total_cells], + sorted: Vec::new(), + pairs: Vec::new(), + } + } + + /// Rebuild the grid from scratch. Call once per tick. + pub fn rebuild(&mut self, positions: &impl Positions) { + let n = positions.len(); + + // Ensure buffers are big enough + if self.sorted.len() < n { + self.sorted.resize(n, 0); + } + + self.cell_counts.iter_mut().for_each(|c| *c = 0); + self.pairs.clear(); + + // Pass 1: bin every entity + for i in 0..n { + let (x, y) = positions.get(i as u32); + if let Some(ci) = self.cell_of(x, y) { + self.cell_counts[ci as usize] += 1; + self.pairs.push((ci, i as u32)); + } + } + + // Pass 2: prefix sum + let mut running = 0u32; + for i in 0..self.total_cells { + self.cell_offsets[i] = running; + running += self.cell_counts[i]; + } + + // Pass 3: scatter into sorted order + let mut cursors = self.cell_offsets.clone(); + for &(ci, ei) in &self.pairs { + let slot = cursors[ci as usize] as usize; + self.sorted[slot] = ei; + cursors[ci as usize] += 1; + } + } + + #[inline(always)] + fn cell_of(&self, x: f32, y: f32) -> Option { + let cx = (x * self.inv_cell_size) as i32; + let cy = (y * self.inv_cell_size) as i32; + let dim = self.grid_dim as i32; + if cx < 0 || cy < 0 || cx >= dim || cy >= dim { + return None; + } + Some((cy as u32) * (self.grid_dim as u32) + (cx as u32)) + } +} + +// ============================================================ +// Collision detection — free function, parallel, read-only +// ============================================================ + +/// Detect all overlapping pairs and report them through the handler. +/// Returns total number of collision pairs found. +pub fn detect_collisions( + grid: &SpatialGrid, + positions: &impl Positions, + collision_dist_sq: f32, +) -> u64 { + const NEIGHBORS: [(i32, i32); 4] = [(1, 0), (0, 1), (-1, 1), (1, 1)]; + + (0..grid.grid_dim) + .into_par_iter() + .map(|cy| { + let mut row_hits: u64 = 0; + for cx in 0..grid.grid_dim { + let ci = cy * grid.grid_dim + cx; + let a_start = grid.cell_offsets[ci] as usize; + let a_count = grid.cell_counts[ci] as usize; + if a_count == 0 { + continue; + } + + // Intra-cell + for i in 0..a_count { + let (px, py) = positions.get(grid.sorted[a_start + i]); + for j in (i + 1)..a_count { + let (qx, qy) = positions.get(grid.sorted[a_start + j]); + let dx = px - qx; + let dy = py - qy; + if dx * dx + dy * dy <= collision_dist_sq { + row_hits += 1; + } + } + } + + // Cross-cell + for &(nx, ny) in &NEIGHBORS { + let ncx = cx as i32 + nx; + let ncy = cy as i32 + ny; + if ncx < 0 || ncy < 0 || ncx >= grid.grid_dim as i32 || ncy >= grid.grid_dim as i32 { + continue; + } + let nci = (ncy as usize) * grid.grid_dim + (ncx as usize); + let b_start = grid.cell_offsets[nci] as usize; + let b_count = grid.cell_counts[nci] as usize; + if b_count == 0 { + continue; + } + for i in 0..a_count { + let (px, py) = positions.get(grid.sorted[a_start + i]); + for j in 0..b_count { + let (qx, qy) = positions.get(grid.sorted[b_start + j]); + let dx = px - qx; + let dy = py - qy; + if dx * dx + dy * dy <= collision_dist_sq { + row_hits += 1; + } + } + } + } + } + row_hits + }) + .sum() +} + +// ============================================================ +// Movement — free function, knows nothing about the grid +// ============================================================ + +/// Step all entities. The acceleration source is injected as a slice +/// so the caller can produce it however they want (AI, player input, +/// brownian noise, gravity, whatever). +pub fn step_entities( + entities: &mut impl Kinematics, + accels: &[(f32, f32)], + dt: f32, + max_speed: f32, + bounds: (f32, f32), +) { + let n = entities.len(); + assert_eq!(accels.len(), n); + + // Parallel via indices since we need &mut through the trait + // We know indices don't alias, so this is safe to parallelize + // by splitting the index range. + let max_speed_sq = max_speed * max_speed; + let (bx, by) = bounds; + + // For simplicity: pull into a temp buffer, process in parallel, push back. + // In a real ECS this would be a par_iter over the archetype. + let mut buf: Vec<(f32, f32, f32, f32)> = (0..n).map(|i| entities.get(i)).collect(); + + buf.par_iter_mut().zip(accels.par_iter()).for_each(|(s, &(ax, ay))| { + s.2 += ax; + s.3 += ay; + + let speed_sq = s.2 * s.2 + s.3 * s.3; + if speed_sq > max_speed_sq { + let inv = max_speed / speed_sq.sqrt(); + s.2 *= inv; + s.3 *= inv; + } + + s.0 += s.2 * dt; + s.1 += s.3 * dt; + + if s.0 < 0.0 { s.0 = -s.0; s.2 = s.2.abs(); } + else if s.0 >= bx { s.0 = 2.0 * bx - s.0; s.2 = -s.2.abs(); } + if s.1 < 0.0 { s.1 = -s.1; s.3 = s.3.abs(); } + else if s.1 >= by { s.1 = 2.0 * by - s.1; s.3 = -s.3.abs(); } + }); + + for (i, &(x, y, vx, vy)) in buf.iter().enumerate() { + entities.set(i, x, y, vx, vy); + } +} + +// ============================================================ +// Benchmark harness — this is the only part that knows the +// concrete storage layout +// ============================================================ + +struct ProjectileStore { + x: Vec, + y: Vec, + vx: Vec, + vy: Vec, +} + +impl Positions for ProjectileStore { + fn len(&self) -> usize { self.x.len() } + #[inline(always)] + fn get(&self, index: u32) -> (f32, f32) { + let i = index as usize; + (self.x[i], self.y[i]) + } +} + +impl Kinematics for ProjectileStore { + fn len(&self) -> usize { self.x.len() } + #[inline(always)] + fn get(&self, index: usize) -> (f32, f32, f32, f32) { + (self.x[index], self.y[index], self.vx[index], self.vy[index]) + } + #[inline(always)] + fn set(&mut self, index: usize, x: f32, y: f32, vx: f32, vy: f32) { + self.x[index] = x; + self.y[index] = y; + self.vx[index] = vx; + self.vy[index] = vy; + } +} + +struct Rng(u64); +impl Rng { + fn next_f32(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 & 0xFFFFFF) as f32 / 0xFFFFFF as f32 + } + fn next_signed(&mut self) -> f32 { self.next_f32() * 2.0 - 1.0 } +} + +const NUM_PROJECTILES: usize = 200_000; +const WORLD_SIZE: f32 = 4000.0; +const PROJECTILE_RADIUS: f32 = 2.0; +const CELL_SIZE: f32 = 8.0; +const GRID_DIM: usize = (WORLD_SIZE / CELL_SIZE) as usize; +const TICK_DT: f32 = 1.0 / 60.0; +const MAX_SPEED: f32 = 200.0; +const BROWNIAN_ACCEL: f32 = 600.0; +const NUM_TICKS: usize = 600; + +fn main() { + let mut rng = Rng(12345); + + let mut store = ProjectileStore { + x: (0..NUM_PROJECTILES).map(|_| rng.next_f32() * WORLD_SIZE).collect(), + y: (0..NUM_PROJECTILES).map(|_| rng.next_f32() * WORLD_SIZE).collect(), + vx: (0..NUM_PROJECTILES).map(|_| rng.next_signed() * MAX_SPEED).collect(), + vy: (0..NUM_PROJECTILES).map(|_| rng.next_signed() * MAX_SPEED).collect(), + }; + + let mut grid = SpatialGrid::new(CELL_SIZE, GRID_DIM); + let collision_dist_sq = (PROJECTILE_RADIUS * 2.0) * (PROJECTILE_RADIUS * 2.0); + + let mut total_collisions: u64 = 0; + let mut total_physics_time = Duration::ZERO; + let mut total_grid_build_time = Duration::ZERO; + let mut total_collision_time = Duration::ZERO; + + println!( + "=== Collision Benchmark (Decoupled + Rayon) ===\n\ + Projectiles: {NUM_PROJECTILES}\n\ + World: {WORLD_SIZE}x{WORLD_SIZE}\n\ + Cell size: {CELL_SIZE} ({GRID_DIM}x{GRID_DIM} = {} cells)\n\ + Projectile radius: {PROJECTILE_RADIUS}\n\ + Ticks: {NUM_TICKS} ({} seconds at 60hz)\n\ + Rayon threads: {}\n", + GRID_DIM * GRID_DIM, + NUM_TICKS as f32 / 60.0, + rayon::current_num_threads(), + ); + + for tick in 0..NUM_TICKS { + // Generate accels (game logic, AI, input, whatever — opaque to movement fn) + let accels: Vec<(f32, f32)> = (0..NUM_PROJECTILES) + .map(|_| ( + rng.next_signed() * BROWNIAN_ACCEL * TICK_DT, + rng.next_signed() * BROWNIAN_ACCEL * TICK_DT, + )) + .collect(); + + // --- Movement (knows nothing about the grid) --- + let t_physics = Instant::now(); + step_entities(&mut store, &accels, TICK_DT, MAX_SPEED, (WORLD_SIZE, WORLD_SIZE)); + let physics_elapsed = t_physics.elapsed(); + total_physics_time += physics_elapsed; + + // --- Grid build (knows nothing about what entities are) --- + let t_grid = Instant::now(); + grid.rebuild(&store); + let grid_elapsed = t_grid.elapsed(); + total_grid_build_time += grid_elapsed; + + // --- Collision (knows nothing about movement or storage) --- + let t_coll = Instant::now(); + let frame_collisions = detect_collisions(&grid, &store, collision_dist_sq); + let coll_elapsed = t_coll.elapsed(); + total_collision_time += coll_elapsed; + total_collisions += frame_collisions; + + if tick % 60 == 0 { + println!( + "Tick {tick:>4}: physics {:.2}ms | grid {:.2}ms | collisions {:.2}ms | hits: {frame_collisions} | total frame: {:.2}ms", + physics_elapsed.as_secs_f64() * 1000.0, + grid_elapsed.as_secs_f64() * 1000.0, + coll_elapsed.as_secs_f64() * 1000.0, + (physics_elapsed + grid_elapsed + coll_elapsed).as_secs_f64() * 1000.0, + ); + } + } + + let budget_ms = 1000.0 / 60.0; + let avg_total = (total_physics_time + total_grid_build_time + total_collision_time).as_secs_f64() + * 1000.0 / NUM_TICKS as f64; + + println!( + "\n=== Averages over {NUM_TICKS} ticks ===\n\ + Physics: {:.2} ms\n\ + Grid build: {:.2} ms\n\ + Collision: {:.2} ms\n\ + TOTAL: {:.2} ms (budget: {:.2} ms)\n\ + Headroom: {:.1}%\n\ + Total collision pairs detected: {total_collisions}", + total_physics_time.as_secs_f64() * 1000.0 / NUM_TICKS as f64, + total_grid_build_time.as_secs_f64() * 1000.0 / NUM_TICKS as f64, + total_collision_time.as_secs_f64() * 1000.0 / NUM_TICKS as f64, + avg_total, + budget_ms, + (1.0 - avg_total / budget_ms) * 100.0, + ); +} \ No newline at end of file