Added collision bench

This commit is contained in:
2026-04-08 20:30:07 -05:00
commit 9c7a5dfcd4
4 changed files with 445 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target
+61
View File
@@ -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",
]
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "collision-bench"
version = "0.1.0"
edition = "2024"
[dependencies]
rayon = "1.11.0"
+376
View File
@@ -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<u32>,
cell_offsets: Vec<u32>,
sorted: Vec<u32>, // 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<u32> {
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<f32>,
y: Vec<f32>,
vx: Vec<f32>,
vy: Vec<f32>,
}
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,
);
}