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