//! 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