Added collision bench
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target
|
||||
Generated
+61
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "collision-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rayon = "1.11.0"
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user