diff --git a/examples/perf_spike.rs b/examples/perf_spike.rs new file mode 100644 index 0000000..755058c --- /dev/null +++ b/examples/perf_spike.rs @@ -0,0 +1,490 @@ +//! Throwaway performance spike for InfiniTower. +//! +//! Answers one question only: how many enemies and projectiles does Bevy 0.19 carry +//! in 2D before the frame budget of 16.6 ms (60 fps) is gone? +//! +//! Deliberately minimal: no gameplay, no UI, no assets. Enemies walk radially towards +//! the centre, a single tower fires at the nearest one, projectiles fly straight, a hit +//! removes enemy and projectile. The enemy population is held constant so the load +//! does not drift during a run. +//! +//! Run (release profile, otherwise the numbers are meaningless): +//! cargo run --release --example perf_spike -- 2000 +//! PERF_N=2000 cargo run --release --example perf_spike +//! +//! Knobs (env vars, CLI arg 1 overrides PERF_N): +//! PERF_N enemy population held constant (default 1000) +//! PERF_FIRE_RATE tower shots per second (default 20) +//! PERF_RENDER 1 = window + sprites, 0 = headless logic (default 1) +//! PERF_COLLIDE 1 = run naive O(n*m) collision (default 1) +//! PERF_MOVE 1 = run enemy movement (default 1) +//! PERF_SECONDS length of the measurement window (default 12) +//! PERF_WARMUP seconds discarded before measuring (default 4) +//! +//! Every run prints a `=== PERF SPIKE RESULT ===` block with medians (not first-frame +//! values) and then exits on its own. + +use std::time::Instant; + +use bevy::{ + diagnostic::{ + Diagnostic, DiagnosticPath, Diagnostics, DiagnosticsPlugin, DiagnosticsStore, + FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin, RegisterDiagnostic, + }, + platform::collections::HashSet, + prelude::*, +}; +use core::{f32::consts::TAU, time::Duration}; + +/// Radius of the ring the enemies spawn on. Chosen so the whole arena fits into a +/// 1920x1080 window: nothing is frustum-culled, so the render cost is honest. +const ARENA_RADIUS: f32 = 500.0; +const ENEMY_SPEED: f32 = 50.0; +const ENEMY_SIZE: f32 = 8.0; +const PROJECTILE_SPEED: f32 = 700.0; +const PROJECTILE_SIZE: f32 = 4.0; +/// A projectile within this distance of an enemy counts as a hit. +const HIT_RADIUS: f32 = 10.0; +/// An enemy this close to the centre has "arrived" and is recycled to the rim. +const CENTER_RADIUS: f32 = 24.0; +/// Enough history to hold every frame of a long run at high fps. +const HISTORY: usize = 120_000; + +const D_MAINTAIN: DiagnosticPath = DiagnosticPath::const_new("spike/maintain_us"); +const D_MOVE_ENEMIES: DiagnosticPath = DiagnosticPath::const_new("spike/move_enemies_us"); +const D_FIRE: DiagnosticPath = DiagnosticPath::const_new("spike/tower_fire_us"); +const D_MOVE_PROJ: DiagnosticPath = DiagnosticPath::const_new("spike/move_projectiles_us"); +const D_COLLIDE: DiagnosticPath = DiagnosticPath::const_new("spike/collide_us"); +const D_ENEMIES: DiagnosticPath = DiagnosticPath::const_new("spike/enemies"); +const D_PROJECTILES: DiagnosticPath = DiagnosticPath::const_new("spike/projectiles"); +const D_CHECKS: DiagnosticPath = DiagnosticPath::const_new("spike/pair_checks"); + +#[derive(Resource, Clone, Copy)] +struct SpikeConfig { + enemy_count: usize, + fire_rate: f32, + render: bool, + collide: bool, + move_enemies: bool, + warmup_secs: f64, + measure_secs: f64, +} + +impl SpikeConfig { + fn from_env() -> Self { + let cli_n = std::env::args().nth(1).and_then(|a| a.parse::().ok()); + Self { + enemy_count: cli_n.unwrap_or_else(|| env_num("PERF_N", 1000.0) as usize), + fire_rate: env_num("PERF_FIRE_RATE", 20.0) as f32, + render: env_flag("PERF_RENDER", true), + collide: env_flag("PERF_COLLIDE", true), + move_enemies: env_flag("PERF_MOVE", true), + warmup_secs: env_num("PERF_WARMUP", 4.0), + measure_secs: env_num("PERF_SECONDS", 12.0), + } + } +} + +fn env_num(key: &str, default: f64) -> f64 { + std::env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn env_flag(key: &str, default: bool) -> bool { + std::env::var(key) + .ok() + .map(|v| !matches!(v.as_str(), "0" | "false" | "off")) + .unwrap_or(default) +} + +#[derive(Component)] +struct Enemy; + +#[derive(Component)] +struct Projectile { + velocity: Vec2, +} + +#[derive(Resource)] +struct TowerGun { + timer: Timer, +} + +/// Monotonic counter, used as the seed for respawn angles so runs are deterministic. +#[derive(Resource, Default)] +struct SpawnSeed(u32); + +#[derive(Resource)] +struct Harness { + elapsed: f64, + measuring: bool, + done: bool, +} + +fn main() { + let cfg = SpikeConfig::from_env(); + + let mut app = App::new(); + + if cfg.render { + app.add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "InfiniTower perf spike".into(), + // Uncapped: with vsync the frame time would just be the monitor's. + present_mode: bevy::window::PresentMode::AutoNoVsync, + resolution: bevy::window::WindowResolution::new(1920, 1080) + .with_scale_factor_override(1.0), + ..default() + }), + ..default() + })) + .insert_resource(bevy::winit::WinitSettings::continuous()); + } else { + // Headless: same simulation, no renderer at all. The delta against the + // rendered run is the render cost. + app.add_plugins((MinimalPlugins, bevy::log::LogPlugin::default(), DiagnosticsPlugin)); + } + + app.add_plugins(( + FrameTimeDiagnosticsPlugin::new(HISTORY), + LogDiagnosticsPlugin { + wait_duration: Duration::from_secs(2), + ..default() + }, + )) + .insert_resource(cfg) + .init_resource::() + .insert_resource(TowerGun { + timer: Timer::from_seconds(1.0 / cfg.fire_rate.max(0.01), TimerMode::Repeating), + }) + .insert_resource(Harness { + elapsed: 0.0, + measuring: false, + done: false, + }); + + for path in [ + &D_MAINTAIN, + &D_MOVE_ENEMIES, + &D_FIRE, + &D_MOVE_PROJ, + &D_COLLIDE, + &D_ENEMIES, + &D_PROJECTILES, + &D_CHECKS, + ] { + app.register_diagnostic(Diagnostic::new(path.clone()).with_max_history_length(HISTORY)); + } + + app.add_systems(Startup, setup) + .add_systems( + Update, + ( + maintain_population, + move_enemies.run_if(|c: Res| c.move_enemies), + tower_fire, + move_projectiles, + collide.run_if(|c: Res| c.collide), + ) + .chain(), + ) + .add_systems(Last, harness) + .run(); +} + +fn setup(mut commands: Commands, cfg: Res) { + if cfg.render { + commands.spawn(Camera2d); + // The tower itself. Purely cosmetic, the firing logic lives in a resource. + commands.spawn(( + Sprite::from_color(Color::srgb(0.9, 0.9, 0.2), Vec2::splat(24.0)), + Transform::default(), + )); + } + + // Initial population: angles evenly spread over the circle, radii staggered. + // Without the stagger every enemy would reach the centre in the same frame and + // the frame time would oscillate instead of settling. + let n = cfg.enemy_count.max(1); + for i in 0..cfg.enemy_count { + let angle = (i as f32 / n as f32) * TAU; + let radius = ARENA_RADIUS * (0.2 + 0.8 * hash01(i as u32)); + spawn_enemy(&mut commands, angle, radius, cfg.render); + } +} + +/// Keeps the enemy population at exactly `PERF_N`, so the load stays constant while +/// hits and centre-arrivals despawn entities. Also the place where spawn cost shows up. +fn maintain_population( + mut commands: Commands, + cfg: Res, + enemies: Query<(), With>, + mut seed: ResMut, + mut diagnostics: Diagnostics, +) { + let t0 = Instant::now(); + let alive = enemies.iter().len(); + for _ in alive..cfg.enemy_count { + seed.0 = seed.0.wrapping_add(1); + spawn_enemy(&mut commands, hash01(seed.0) * TAU, ARENA_RADIUS, cfg.render); + } + let us = t0.elapsed().as_secs_f64() * 1e6; + diagnostics.add_measurement(&D_MAINTAIN, || us); + diagnostics.add_measurement(&D_ENEMIES, || alive as f64); +} + +fn move_enemies( + mut commands: Commands, + time: Res