Stress Tests / Bevymark

Back to examples View in GitHub
This example is running in WebGL2 and should work in most browsers. You can check the WebGPU examples here.

bevymark.rs:
//! This example provides a 2D benchmark.
//!
//! Usage: spawn more entities by clicking on the screen.

use std::str::FromStr;

use argh::FromArgs;
use bevy::{
    color::palettes::basic::*,
    diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin},
    prelude::*,
    render::{
        render_asset::RenderAssetUsages,
        render_resource::{Extent3d, TextureDimension, TextureFormat},
    },
    sprite::AlphaMode2d,
    utils::Duration,
    window::{PresentMode, WindowResolution},
    winit::{UpdateMode, WinitSettings},
};
use rand::{seq::SliceRandom, Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;

const BIRDS_PER_SECOND: u32 = 10000;
const GRAVITY: f32 = -9.8 * 100.0;
const MAX_VELOCITY: f32 = 750.;
const BIRD_SCALE: f32 = 0.15;
const BIRD_TEXTURE_SIZE: usize = 256;
const HALF_BIRD_SIZE: f32 = BIRD_TEXTURE_SIZE as f32 * BIRD_SCALE * 0.5;

#[derive(Resource)]
struct BevyCounter {
    pub count: usize,
    pub color: Color,
}

#[derive(Component)]
struct Bird {
    velocity: Vec3,
}

#[derive(FromArgs, Resource)]
/// `bevymark` sprite / 2D mesh stress test
struct Args {
    /// whether to use sprite or mesh2d
    #[argh(option, default = "Mode::Sprite")]
    mode: Mode,

    /// whether to step animations by a fixed amount such that each frame is the same across runs.
    /// If spawning waves, all are spawned up-front to immediately start rendering at the heaviest
    /// load.
    #[argh(switch)]
    benchmark: bool,

    /// how many birds to spawn per wave.
    #[argh(option, default = "0")]
    per_wave: usize,

    /// the number of waves to spawn.
    #[argh(option, default = "0")]
    waves: usize,

    /// whether to vary the material data in each instance.
    #[argh(switch)]
    vary_per_instance: bool,

    /// the number of different textures from which to randomly select the material color. 0 means no textures.
    #[argh(option, default = "1")]
    material_texture_count: usize,

    /// generate z values in increasing order rather than randomly
    #[argh(switch)]
    ordered_z: bool,

    /// the alpha mode used to spawn the sprites
    #[argh(option, default = "AlphaMode::Blend")]
    alpha_mode: AlphaMode,
}

#[derive(Default, Clone)]
enum Mode {
    #[default]
    Sprite,
    Mesh2d,
}

impl FromStr for Mode {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "sprite" => Ok(Self::Sprite),
            "mesh2d" => Ok(Self::Mesh2d),
            _ => Err(format!(
                "Unknown mode: '{s}', valid modes: 'sprite', 'mesh2d'"
            )),
        }
    }
}

#[derive(Default, Clone)]
enum AlphaMode {
    Opaque,
    #[default]
    Blend,
    AlphaMask,
}

impl FromStr for AlphaMode {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "opaque" => Ok(Self::Opaque),
            "blend" => Ok(Self::Blend),
            "alpha_mask" => Ok(Self::AlphaMask),
            _ => Err(format!(
                "Unknown alpha mode: '{s}', valid modes: 'opaque', 'blend', 'alpha_mask'"
            )),
        }
    }
}

const FIXED_TIMESTEP: f32 = 0.2;

fn main() {
    // `from_env` panics on the web
    #[cfg(not(target_arch = "wasm32"))]
    let args: Args = argh::from_env();
    #[cfg(target_arch = "wasm32")]
    let args = Args::from_args(&[], &[]).unwrap();

    App::new()
        .add_plugins((
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "BevyMark".into(),
                    resolution: WindowResolution::new(1920.0, 1080.0)
                        .with_scale_factor_override(1.0),
                    present_mode: PresentMode::AutoNoVsync,
                    ..default()
                }),
                ..default()
            }),
            FrameTimeDiagnosticsPlugin,
            LogDiagnosticsPlugin::default(),
        ))
        .insert_resource(WinitSettings {
            focused_mode: UpdateMode::Continuous,
            unfocused_mode: UpdateMode::Continuous,
        })
        .insert_resource(args)
        .insert_resource(BevyCounter {
            count: 0,
            color: Color::WHITE,
        })
        .add_systems(Startup, setup)
        .add_systems(FixedUpdate, scheduled_spawner)
        .add_systems(
            Update,
            (
                mouse_handler,
                movement_system,
                collision_system,
                counter_system,
            ),
        )
        .insert_resource(Time::<Fixed>::from_duration(Duration::from_secs_f32(
            FIXED_TIMESTEP,
        )))
        .run();
}

#[derive(Resource)]
struct BirdScheduled {
    waves: usize,
    per_wave: usize,
}

fn scheduled_spawner(
    mut commands: Commands,
    args: Res<Args>,
    window: Single<&Window>,
    mut scheduled: ResMut<BirdScheduled>,
    mut counter: ResMut<BevyCounter>,
    bird_resources: ResMut<BirdResources>,
) {
    if scheduled.waves > 0 {
        let bird_resources = bird_resources.into_inner();
        spawn_birds(
            &mut commands,
            args.into_inner(),
            &window.resolution,
            &mut counter,
            scheduled.per_wave,
            bird_resources,
            None,
            scheduled.waves - 1,
        );

        scheduled.waves -= 1;
    }
}

#[derive(Resource)]
struct BirdResources {
    textures: Vec<Handle<Image>>,
    materials: Vec<Handle<ColorMaterial>>,
    quad: Handle<Mesh>,
    color_rng: ChaCha8Rng,
    material_rng: ChaCha8Rng,
    velocity_rng: ChaCha8Rng,
    transform_rng: ChaCha8Rng,
}

#[derive(Component)]
struct StatsText;

#[allow(clippy::too_many_arguments)]
fn setup(
    mut commands: Commands,
    args: Res<Args>,
    asset_server: Res<AssetServer>,
    mut meshes: ResMut<Assets<Mesh>>,
    material_assets: ResMut<Assets<ColorMaterial>>,
    images: ResMut<Assets<Image>>,
    window: Single<&Window>,
    counter: ResMut<BevyCounter>,
) {
    warn!(include_str!("warning_string.txt"));

    let args = args.into_inner();
    let images = images.into_inner();

    let mut textures = Vec::with_capacity(args.material_texture_count.max(1));
    if matches!(args.mode, Mode::Sprite) || args.material_texture_count > 0 {
        textures.push(asset_server.load("branding/icon.png"));
    }
    init_textures(&mut textures, args, images);

    let material_assets = material_assets.into_inner();
    let materials = init_materials(args, &textures, material_assets);

    let mut bird_resources = BirdResources {
        textures,
        materials,
        quad: meshes.add(Rectangle::from_size(Vec2::splat(BIRD_TEXTURE_SIZE as f32))),
        // We're seeding the PRNG here to make this example deterministic for testing purposes.
        // This isn't strictly required in practical use unless you need your app to be deterministic.
        color_rng: ChaCha8Rng::seed_from_u64(42),
        material_rng: ChaCha8Rng::seed_from_u64(42),
        velocity_rng: ChaCha8Rng::seed_from_u64(42),
        transform_rng: ChaCha8Rng::seed_from_u64(42),
    };

    let font = TextFont {
        font_size: 40.0,
        ..Default::default()
    };

    commands.spawn(Camera2d);
    commands
        .spawn((
            Node {
                position_type: PositionType::Absolute,
                padding: UiRect::all(Val::Px(5.0)),
                ..default()
            },
            BackgroundColor(Color::BLACK.with_alpha(0.75)),
            GlobalZIndex(i32::MAX),
        ))
        .with_children(|p| {
            p.spawn((Text::default(), StatsText)).with_children(|p| {
                p.spawn((
                    TextSpan::new("Bird Count: "),
                    font.clone(),
                    TextColor(LIME.into()),
                ));
                p.spawn((TextSpan::new(""), font.clone(), TextColor(AQUA.into())));
                p.spawn((
                    TextSpan::new("\nFPS (raw): "),
                    font.clone(),
                    TextColor(LIME.into()),
                ));
                p.spawn((TextSpan::new(""), font.clone(), TextColor(AQUA.into())));
                p.spawn((
                    TextSpan::new("\nFPS (SMA): "),
                    font.clone(),
                    TextColor(LIME.into()),
                ));
                p.spawn((TextSpan::new(""), font.clone(), TextColor(AQUA.into())));
                p.spawn((
                    TextSpan::new("\nFPS (EMA): "),
                    font.clone(),
                    TextColor(LIME.into()),
                ));
                p.spawn((TextSpan::new(""), font.clone(), TextColor(AQUA.into())));
            });
        });

    let mut scheduled = BirdScheduled {
        per_wave: args.per_wave,
        waves: args.waves,
    };

    if args.benchmark {
        let counter = counter.into_inner();
        for wave in (0..scheduled.waves).rev() {
            spawn_birds(
                &mut commands,
                args,
                &window.resolution,
                counter,
                scheduled.per_wave,
                &mut bird_resources,
                Some(wave),
                wave,
            );
        }
        scheduled.waves = 0;
    }
    commands.insert_resource(bird_resources);
    commands.insert_resource(scheduled);
}

#[allow(clippy::too_many_arguments)]
fn mouse_handler(
    mut commands: Commands,
    args: Res<Args>,
    time: Res<Time>,
    mouse_button_input: Res<ButtonInput<MouseButton>>,
    window: Single<&Window>,
    bird_resources: ResMut<BirdResources>,
    mut counter: ResMut<BevyCounter>,
    mut rng: Local<Option<ChaCha8Rng>>,
    mut wave: Local<usize>,
) {
    if rng.is_none() {
        // We're seeding the PRNG here to make this example deterministic for testing purposes.
        // This isn't strictly required in practical use unless you need your app to be deterministic.
        *rng = Some(ChaCha8Rng::seed_from_u64(42));
    }
    let rng = rng.as_mut().unwrap();

    if mouse_button_input.just_released(MouseButton::Left) {
        counter.color = Color::linear_rgb(rng.gen(), rng.gen(), rng.gen());
    }

    if mouse_button_input.pressed(MouseButton::Left) {
        let spawn_count = (BIRDS_PER_SECOND as f64 * time.delta_secs_f64()) as usize;
        spawn_birds(
            &mut commands,
            args.into_inner(),
            &window.resolution,
            &mut counter,
            spawn_count,
            bird_resources.into_inner(),
            None,
            *wave,
        );
        *wave += 1;
    }
}

fn bird_velocity_transform(
    half_extents: Vec2,
    mut translation: Vec3,
    velocity_rng: &mut ChaCha8Rng,
    waves: Option<usize>,
    dt: f32,
) -> (Transform, Vec3) {
    let mut velocity = Vec3::new(MAX_VELOCITY * (velocity_rng.gen::<f32>() - 0.5), 0., 0.);

    if let Some(waves) = waves {
        // Step the movement and handle collisions as if the wave had been spawned at fixed time intervals
        // and with dt-spaced frames of simulation
        for _ in 0..(waves * (FIXED_TIMESTEP / dt).round() as usize) {
            step_movement(&mut translation, &mut velocity, dt);
            handle_collision(half_extents, &translation, &mut velocity);
        }
    }
    (
        Transform::from_translation(translation).with_scale(Vec3::splat(BIRD_SCALE)),
        velocity,
    )
}

const FIXED_DELTA_TIME: f32 = 1.0 / 60.0;

#[allow(clippy::too_many_arguments)]
fn spawn_birds(
    commands: &mut Commands,
    args: &Args,
    primary_window_resolution: &WindowResolution,
    counter: &mut BevyCounter,
    spawn_count: usize,
    bird_resources: &mut BirdResources,
    waves_to_simulate: Option<usize>,
    wave: usize,
) {
    let bird_x = (primary_window_resolution.width() / -2.) + HALF_BIRD_SIZE;
    let bird_y = (primary_window_resolution.height() / 2.) - HALF_BIRD_SIZE;

    let half_extents = 0.5 * primary_window_resolution.size();

    let color = counter.color;
    let current_count = counter.count;

    match args.mode {
        Mode::Sprite => {
            let batch = (0..spawn_count)
                .map(|count| {
                    let bird_z = if args.ordered_z {
                        (current_count + count) as f32 * 0.00001
                    } else {
                        bird_resources.transform_rng.gen::<f32>()
                    };

                    let (transform, velocity) = bird_velocity_transform(
                        half_extents,
                        Vec3::new(bird_x, bird_y, bird_z),
                        &mut bird_resources.velocity_rng,
                        waves_to_simulate,
                        FIXED_DELTA_TIME,
                    );

                    let color = if args.vary_per_instance {
                        Color::linear_rgb(
                            bird_resources.color_rng.gen(),
                            bird_resources.color_rng.gen(),
                            bird_resources.color_rng.gen(),
                        )
                    } else {
                        color
                    };
                    (
                        Sprite {
                            image: bird_resources
                                .textures
                                .choose(&mut bird_resources.material_rng)
                                .unwrap()
                                .clone(),
                            color,
                            ..default()
                        },
                        transform,
                        Bird { velocity },
                    )
                })
                .collect::<Vec<_>>();
            commands.spawn_batch(batch);
        }
        Mode::Mesh2d => {
            let batch = (0..spawn_count)
                .map(|count| {
                    let bird_z = if args.ordered_z {
                        (current_count + count) as f32 * 0.00001
                    } else {
                        bird_resources.transform_rng.gen::<f32>()
                    };

                    let (transform, velocity) = bird_velocity_transform(
                        half_extents,
                        Vec3::new(bird_x, bird_y, bird_z),
                        &mut bird_resources.velocity_rng,
                        waves_to_simulate,
                        FIXED_DELTA_TIME,
                    );

                    let material =
                        if args.vary_per_instance || args.material_texture_count > args.waves {
                            bird_resources
                                .materials
                                .choose(&mut bird_resources.material_rng)
                                .unwrap()
                                .clone()
                        } else {
                            bird_resources.materials[wave % bird_resources.materials.len()].clone()
                        };
                    (
                        Mesh2d(bird_resources.quad.clone()),
                        MeshMaterial2d(material),
                        transform,
                        Bird { velocity },
                    )
                })
                .collect::<Vec<_>>();
            commands.spawn_batch(batch);
        }
    }

    counter.count += spawn_count;
    counter.color = Color::linear_rgb(
        bird_resources.color_rng.gen(),
        bird_resources.color_rng.gen(),
        bird_resources.color_rng.gen(),
    );
}

fn step_movement(translation: &mut Vec3, velocity: &mut Vec3, dt: f32) {
    translation.x += velocity.x * dt;
    translation.y += velocity.y * dt;
    velocity.y += GRAVITY * dt;
}

fn movement_system(
    args: Res<Args>,
    time: Res<Time>,
    mut bird_query: Query<(&mut Bird, &mut Transform)>,
) {
    let dt = if args.benchmark {
        FIXED_DELTA_TIME
    } else {
        time.delta_secs()
    };
    for (mut bird, mut transform) in &mut bird_query {
        step_movement(&mut transform.translation, &mut bird.velocity, dt);
    }
}

fn handle_collision(half_extents: Vec2, translation: &Vec3, velocity: &mut Vec3) {
    if (velocity.x > 0. && translation.x + HALF_BIRD_SIZE > half_extents.x)
        || (velocity.x <= 0. && translation.x - HALF_BIRD_SIZE < -half_extents.x)
    {
        velocity.x = -velocity.x;
    }
    let velocity_y = velocity.y;
    if velocity_y < 0. && translation.y - HALF_BIRD_SIZE < -half_extents.y {
        velocity.y = -velocity_y;
    }
    if translation.y + HALF_BIRD_SIZE > half_extents.y && velocity_y > 0.0 {
        velocity.y = 0.0;
    }
}
fn collision_system(window: Single<&Window>, mut bird_query: Query<(&mut Bird, &Transform)>) {
    let half_extents = 0.5 * window.size();

    for (mut bird, transform) in &mut bird_query {
        handle_collision(half_extents, &transform.translation, &mut bird.velocity);
    }
}

fn counter_system(
    diagnostics: Res<DiagnosticsStore>,
    counter: Res<BevyCounter>,
    query: Single<Entity, With<StatsText>>,
    mut writer: TextUiWriter,
) {
    let text = *query;

    if counter.is_changed() {
        *writer.text(text, 2) = counter.count.to_string();
    }

    if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) {
        if let Some(raw) = fps.value() {
            *writer.text(text, 4) = format!("{raw:.2}");
        }
        if let Some(sma) = fps.average() {
            *writer.text(text, 6) = format!("{sma:.2}");
        }
        if let Some(ema) = fps.smoothed() {
            *writer.text(text, 8) = format!("{ema:.2}");
        }
    };
}

fn init_textures(textures: &mut Vec<Handle<Image>>, args: &Args, images: &mut Assets<Image>) {
    // We're seeding the PRNG here to make this example deterministic for testing purposes.
    // This isn't strictly required in practical use unless you need your app to be deterministic.
    let mut color_rng = ChaCha8Rng::seed_from_u64(42);
    while textures.len() < args.material_texture_count {
        let pixel = [color_rng.gen(), color_rng.gen(), color_rng.gen(), 255];
        textures.push(images.add(Image::new_fill(
            Extent3d {
                width: BIRD_TEXTURE_SIZE as u32,
                height: BIRD_TEXTURE_SIZE as u32,
                depth_or_array_layers: 1,
            },
            TextureDimension::D2,
            &pixel,
            TextureFormat::Rgba8UnormSrgb,
            RenderAssetUsages::RENDER_WORLD,
        )));
    }
}

fn init_materials(
    args: &Args,
    textures: &[Handle<Image>],
    assets: &mut Assets<ColorMaterial>,
) -> Vec<Handle<ColorMaterial>> {
    let capacity = if args.vary_per_instance {
        args.per_wave * args.waves
    } else {
        args.material_texture_count.max(args.waves)
    }
    .max(1);

    let alpha_mode = match args.alpha_mode {
        AlphaMode::Opaque => AlphaMode2d::Opaque,
        AlphaMode::Blend => AlphaMode2d::Blend,
        AlphaMode::AlphaMask => AlphaMode2d::Mask(0.5),
    };

    let mut materials = Vec::with_capacity(capacity);
    materials.push(assets.add(ColorMaterial {
        color: Color::WHITE,
        texture: textures.first().cloned(),
        alpha_mode,
    }));

    // We're seeding the PRNG here to make this example deterministic for testing purposes.
    // This isn't strictly required in practical use unless you need your app to be deterministic.
    let mut color_rng = ChaCha8Rng::seed_from_u64(42);
    let mut texture_rng = ChaCha8Rng::seed_from_u64(42);
    materials.extend(
        std::iter::repeat_with(|| {
            assets.add(ColorMaterial {
                color: Color::srgb_u8(color_rng.gen(), color_rng.gen(), color_rng.gen()),
                texture: textures.choose(&mut texture_rng).cloned(),
                alpha_mode,
            })
        })
        .take(capacity - materials.len()),
    );

    materials
}