use bevy::prelude::*;
use clap::{Parser, ValueEnum};
#[derive(Parser, Debug)]
#[command(name = "perf_abtest")]
#[command(about = "Performance A/B test comparing FPS with and without screenshot plugin")]
struct Args {
#[arg(long)]
enable_screenshot: bool,
#[arg(long, default_value = "30")]
duration: f32,
#[arg(long, short = 'e')]
entity_only: bool,
#[arg(long, default_value = "20")]
padding: u32,
#[arg(long, default_value = "60")]
screenshot_interval: u32,
#[arg(long)]
sps: Option<f32>,
#[arg(long, short, default_value = "upper-right")]
position: PositionArg,
#[arg(long, short = 's', default_value = "18")]
font_size: f32,
#[arg(long, short = 'k')]
show_key: bool,
#[arg(long, short = 'd')]
show_description: bool,
#[arg(long)]
no_burn_in: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum PositionArg {
UpperLeft,
UpperRight,
LowerLeft,
LowerRight,
}
#[derive(Resource)]
struct TestConfig {
enable_screenshot: bool,
entity_only: bool,
padding: u32,
screenshot_interval: u32,
screenshot_time_interval: Option<f32>,
duration: f32,
}
#[derive(Resource)]
struct FpsMetrics {
frame_times: Vec<f32>,
start_time: f32,
test_duration: f32,
min_fps: f32,
max_fps: f32,
frame_count: u32,
}
impl FpsMetrics {
fn new(test_duration: f32) -> Self {
Self {
frame_times: Vec::with_capacity(10000),
start_time: 0.0,
test_duration,
min_fps: f32::MAX,
max_fps: f32::MIN,
frame_count: 0,
}
}
fn record_frame(&mut self, delta_secs: f32) {
if delta_secs > 0.0 {
let fps = 1.0 / delta_secs;
self.frame_times.push(delta_secs);
self.min_fps = self.min_fps.min(fps);
self.max_fps = self.max_fps.max(fps);
self.frame_count += 1;
}
}
fn average_fps(&self) -> f32 {
if self.frame_times.is_empty() {
return 0.0;
}
let total_time: f32 = self.frame_times.iter().sum();
self.frame_times.len() as f32 / total_time
}
fn percentile_fps(&self, percentile: f32) -> f32 {
if self.frame_times.is_empty() {
return 0.0;
}
let mut sorted_fps: Vec<f32> = self
.frame_times
.iter()
.filter(|&&t| t > 0.0)
.map(|&t| 1.0 / t)
.collect();
sorted_fps.sort_by(|a, b| a.partial_cmp(b).unwrap());
let idx = ((sorted_fps.len() as f32 * percentile / 100.0) as usize)
.min(sorted_fps.len().saturating_sub(1));
sorted_fps[idx]
}
fn print_summary(&self, enable_screenshot: bool) {
let mode = if enable_screenshot {
"WITH screenshot plugin"
} else {
"WITHOUT screenshot plugin (baseline)"
};
println!("\n╔══════════════════════════════════════════════════════════════╗");
println!("║ PERFORMANCE A/B TEST RESULTS ║");
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ Mode: {:54} ║", mode);
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ Total Frames: {:>10} ║", self.frame_count);
println!("║ Test Duration: {:>10.2} seconds ║", self.test_duration);
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ Average FPS: {:>10.2} ║", self.average_fps());
println!("║ Min FPS: {:>10.2} ║", self.min_fps);
println!("║ Max FPS: {:>10.2} ║", self.max_fps);
println!("║ 1% Low FPS: {:>10.2} ║", self.percentile_fps(1.0));
println!("║ 5% Low FPS: {:>10.2} ║", self.percentile_fps(5.0));
println!("╚══════════════════════════════════════════════════════════════╝");
println!();
if enable_screenshot {
println!("To compare, run without --enable-screenshot for baseline metrics.");
} else {
println!("To compare, run with --enable-screenshot to measure plugin overhead.");
}
}
}
fn main() {
let args = Args::parse();
let screenshot_time_interval = args.sps.map(|sps| {
if sps <= 0.0 {
panic!("--sps must be positive");
}
1.0 / sps
});
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ PERFORMANCE A/B TEST ║");
println!("╠══════════════════════════════════════════════════════════════╣");
println!(
"║ Screenshot Plugin: {:42} ║",
if args.enable_screenshot { "ENABLED" } else { "DISABLED (baseline)" }
);
if args.enable_screenshot {
println!(
"║ Mode: {:54} ║",
if args.entity_only { "entity-only" } else { "full window" }
);
if let Some(sps) = args.sps {
if sps >= 1.0 {
println!("║ Screenshot Rate: {:>5.1} per second (time-based) ║", sps);
} else {
println!("║ Screenshot Rate: 1 every {:>4.1}s (time-based) ║", 1.0 / sps);
}
} else {
println!("║ Screenshot Interval: every {:>3} frames ║", args.screenshot_interval);
}
if args.entity_only {
println!("║ Padding: {:>3}px ║", args.padding);
}
}
println!("║ Test Duration: {:>5.1} seconds ║", args.duration);
println!("╚══════════════════════════════════════════════════════════════╝");
println!();
let mut app = App::new();
app.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: format!(
"Perf A/B Test - Screenshot {}",
if args.enable_screenshot { "ON" } else { "OFF" }
),
resolution: (800u32, 600u32).into(),
..default()
}),
..default()
}));
if args.enable_screenshot {
use bevy_simple_screenshot::prelude::*;
let burn_in = if args.no_burn_in {
BurnInConfig::default() } else {
let position: BurnInPosition = match args.position {
PositionArg::UpperLeft => BurnInPosition::UpperLeft,
PositionArg::UpperRight => BurnInPosition::UpperRight,
PositionArg::LowerLeft => BurnInPosition::LowerLeft,
PositionArg::LowerRight => BurnInPosition::LowerRight,
};
BurnInConfig::enabled()
.with_font("FiraMono-Medium.ttf")
.with_position(position)
.with_font_size(args.font_size)
.with_show_key(args.show_key)
.with_show_description(args.show_description)
};
app.add_plugins(ScreenshotBufferPlugin::with_config(
ScreenshotConfig::default()
.with_output_dir(".screenshots/perf_test")
.with_buffer_capacity(5)
.with_burn_in(burn_in),
));
}
app.insert_resource(TestConfig {
enable_screenshot: args.enable_screenshot,
entity_only: args.entity_only,
padding: args.padding,
screenshot_interval: args.screenshot_interval,
screenshot_time_interval,
duration: args.duration,
})
.insert_resource(FpsMetrics::new(args.duration))
.add_systems(Startup, setup)
.add_systems(Update, (move_object, animate_color, track_fps));
if args.enable_screenshot {
app.add_systems(Update, take_screenshots);
}
app.run();
}
#[derive(Component)]
struct MovingObject {
speed: f32,
}
#[derive(Component)]
struct ColorAnimation {
speed: f32,
phase: f32,
}
#[derive(Resource)]
struct ScreenshotTimer {
frame_counter: u32,
last_screenshot_time: f32,
}
impl Default for ScreenshotTimer {
fn default() -> Self {
Self {
frame_counter: 0,
last_screenshot_time: -1000.0, }
}
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2d::default());
commands.spawn((
Sprite {
color: Color::srgb(1.0, 0.0, 0.0),
custom_size: Some(Vec2::new(50.0, 50.0)),
..default()
},
Transform::from_translation(Vec3::new(-300.0, 0.0, 0.0)),
MovingObject { speed: 150.0 },
ColorAnimation {
speed: 0.381,
phase: 0.0,
},
));
commands.insert_resource(ScreenshotTimer::default());
}
fn move_object(time: Res<Time>, mut query: Query<(&mut Transform, &MovingObject)>) {
for (mut transform, obj) in &mut query {
transform.translation.x += obj.speed * time.delta_secs();
if transform.translation.x > 350.0 {
transform.translation.x = -350.0;
}
}
}
fn animate_color(time: Res<Time>, mut query: Query<(&mut Sprite, &mut ColorAnimation)>) {
for (mut sprite, mut anim) in &mut query {
anim.phase += time.delta_secs() * anim.speed;
let t = (anim.phase * std::f32::consts::PI).sin().abs();
sprite.color = Color::srgb(1.0 - t, t, 0.0);
}
}
#[allow(deprecated)]
fn track_fps(
time: Res<Time>,
config: Res<TestConfig>,
mut metrics: ResMut<FpsMetrics>,
mut exit: bevy::ecs::event::EventWriter<AppExit>,
) {
let elapsed = time.elapsed_secs();
if metrics.frame_count == 0 {
metrics.start_time = elapsed;
}
metrics.record_frame(time.delta_secs());
let test_elapsed = elapsed - metrics.start_time;
if test_elapsed >= config.duration {
metrics.print_summary(config.enable_screenshot);
exit.write(AppExit::Success);
}
}
fn take_screenshots(
time: Res<Time>,
config: Res<TestConfig>,
mut timer: ResMut<ScreenshotTimer>,
trigger: bevy_simple_screenshot::prelude::ScreenshotTrigger,
entity_trigger: bevy_simple_screenshot::prelude::EntityScreenshotTrigger,
query: Query<Entity, With<MovingObject>>,
) {
use bevy_simple_screenshot::prelude::*;
timer.frame_counter += 1;
let should_capture = if let Some(interval_secs) = config.screenshot_time_interval {
let elapsed = time.elapsed_secs();
if elapsed - timer.last_screenshot_time >= interval_secs {
timer.last_screenshot_time = elapsed;
true
} else {
false
}
} else {
timer.frame_counter % config.screenshot_interval == 0
};
if !should_capture {
return;
}
if config.entity_only {
for entity in &query {
let settings = EntityScreenshotSettings::default().with_padding(config.padding);
screenshot_entity!(&entity_trigger, entity, "perf_test", "entity", settings);
}
} else {
screenshot!(&trigger, "perf_test", "frame");
}
}