Skip to main content

bevymark_3d/
bevymark_3d.rs

1//! This example provides a 3D benchmark.
2//!
3//! Usage: spawn more entities by clicking with the left mouse button.
4
5use core::time::Duration;
6use std::str::FromStr;
7
8use argh::FromArgs;
9use bevy::{
10    asset::RenderAssetUsages,
11    color::palettes::basic::*,
12    diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin},
13    prelude::*,
14    render::render_resource::{Extent3d, TextureDimension, TextureFormat},
15    window::{PresentMode, WindowResolution},
16    winit::WinitSettings,
17};
18use chacha20::ChaCha8Rng;
19use rand::{seq::IndexedRandom, RngExt, SeedableRng};
20
21const CUBES_PER_SECOND: u32 = 10000;
22const GRAVITY: f32 = -9.8;
23const MAX_VELOCITY: f32 = 10.;
24const CUBE_SCALE: f32 = 1.0;
25const CUBE_TEXTURE_SIZE: usize = 256;
26const HALF_CUBE_SIZE: f32 = CUBE_SCALE * 0.5;
27const VOLUME_WIDTH: usize = 50;
28const VOLUME_SIZE: Vec3 = Vec3::splat(VOLUME_WIDTH as f32);
29
30#[derive(Resource)]
31struct BevyCounter {
32    pub count: usize,
33    pub color: Color,
34}
35
36#[derive(Component)]
37struct Cube {
38    velocity: Vec3,
39}
40
41#[derive(FromArgs, Resource)]
42/// `bevymark_3d` cube stress test
43struct Args {
44    /// whether to step animations by a fixed amount such that each frame is the same across runs.
45    /// If spawning waves, all are spawned up-front to immediately start rendering at the heaviest
46    /// load.
47    #[argh(switch)]
48    benchmark: bool,
49
50    /// how many cubes to spawn per wave.
51    #[argh(option, default = "0")]
52    per_wave: usize,
53
54    /// the number of waves to spawn.
55    #[argh(option, default = "0")]
56    waves: usize,
57
58    /// whether to vary the material data in each instance.
59    #[argh(switch)]
60    vary_per_instance: bool,
61
62    /// the number of different textures from which to randomly select the material color. 0 means no textures.
63    #[argh(option, default = "1")]
64    material_texture_count: usize,
65
66    /// the alpha mode used to spawn the cubes
67    #[argh(option, default = "AlphaMode::Opaque")]
68    alpha_mode: AlphaMode,
69}
70
71#[derive(Default, Clone)]
72enum AlphaMode {
73    #[default]
74    Opaque,
75    Blend,
76    AlphaMask,
77}
78
79impl FromStr for AlphaMode {
80    type Err = String;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        match s {
84            "opaque" => Ok(Self::Opaque),
85            "blend" => Ok(Self::Blend),
86            "alpha_mask" => Ok(Self::AlphaMask),
87            _ => Err(format!(
88                "Unknown alpha mode: '{s}', valid modes: 'opaque', 'blend', 'alpha_mask'"
89            )),
90        }
91    }
92}
93
94const FIXED_TIMESTEP: f32 = 0.2;
95
96fn main() {
97    // `from_env` panics on the web
98    #[cfg(not(target_arch = "wasm32"))]
99    let args: Args = argh::from_env();
100    #[cfg(target_arch = "wasm32")]
101    let args = Args::from_args(&[], &[]).unwrap();
102
103    App::new()
104        .add_plugins((
105            DefaultPlugins.set(WindowPlugin {
106                primary_window: Some(Window {
107                    title: "BevyMark 3D".into(),
108                    resolution: WindowResolution::new(1920, 1080).with_scale_factor_override(1.0),
109                    present_mode: PresentMode::AutoNoVsync,
110                    ..default()
111                }),
112                ..default()
113            }),
114            FrameTimeDiagnosticsPlugin::default(),
115            LogDiagnosticsPlugin::default(),
116        ))
117        .insert_resource(WinitSettings::continuous())
118        .insert_resource(args)
119        .insert_resource(BevyCounter {
120            count: 0,
121            color: Color::WHITE,
122        })
123        .add_systems(Startup, setup)
124        .add_systems(FixedUpdate, scheduled_spawner)
125        .add_systems(
126            Update,
127            (
128                mouse_handler,
129                movement_system,
130                collision_system,
131                counter_system,
132            ),
133        )
134        .insert_resource(Time::<Fixed>::from_duration(Duration::from_secs_f32(
135            FIXED_TIMESTEP,
136        )))
137        .run();
138}
139
140#[derive(Resource)]
141struct CubeScheduled {
142    waves: usize,
143    per_wave: usize,
144}
145
146fn scheduled_spawner(
147    mut commands: Commands,
148    args: Res<Args>,
149    mut scheduled: ResMut<CubeScheduled>,
150    mut counter: ResMut<BevyCounter>,
151    cube_resources: ResMut<CubeResources>,
152) {
153    if scheduled.waves > 0 {
154        let cube_resources = cube_resources.into_inner();
155        spawn_cubes(
156            &mut commands,
157            args.into_inner(),
158            &mut counter,
159            scheduled.per_wave,
160            cube_resources,
161            None,
162            scheduled.waves - 1,
163        );
164
165        scheduled.waves -= 1;
166    }
167}
168
169#[derive(Resource)]
170struct CubeResources {
171    _textures: Vec<Handle<Image>>,
172    materials: Vec<Handle<StandardMaterial>>,
173    cube_mesh: Handle<Mesh>,
174    color_rng: ChaCha8Rng,
175    material_rng: ChaCha8Rng,
176    velocity_rng: ChaCha8Rng,
177    transform_rng: ChaCha8Rng,
178}
179
180#[derive(Component)]
181struct StatsText;
182
183fn setup(
184    mut commands: Commands,
185    args: Res<Args>,
186    asset_server: Res<AssetServer>,
187    mut meshes: ResMut<Assets<Mesh>>,
188    material_assets: ResMut<Assets<StandardMaterial>>,
189    images: ResMut<Assets<Image>>,
190    counter: ResMut<BevyCounter>,
191) {
192    let args = args.into_inner();
193    let images = images.into_inner();
194
195    let mut textures = Vec::with_capacity(args.material_texture_count.max(1));
196    if args.material_texture_count > 0 {
197        textures.push(asset_server.load("branding/icon.png"));
198    }
199    init_textures(&mut textures, args, images);
200
201    let material_assets = material_assets.into_inner();
202    let materials = init_materials(args, &textures, material_assets);
203
204    let mut cube_resources = CubeResources {
205        _textures: textures,
206        materials,
207        cube_mesh: meshes.add(Cuboid::from_size(Vec3::splat(CUBE_SCALE))),
208        color_rng: ChaCha8Rng::seed_from_u64(42),
209        material_rng: ChaCha8Rng::seed_from_u64(12),
210        velocity_rng: ChaCha8Rng::seed_from_u64(97),
211        transform_rng: ChaCha8Rng::seed_from_u64(26),
212    };
213
214    let font = TextFont {
215        font_size: FontSize::Px(40.0),
216        ..Default::default()
217    };
218
219    commands.spawn((
220        Camera3d::default(),
221        Transform::from_translation(VOLUME_SIZE * 1.3).looking_at(Vec3::ZERO, Vec3::Y),
222    ));
223
224    commands.spawn((
225        DirectionalLight {
226            illuminance: 10000.0,
227            shadow_maps_enabled: false,
228            ..default()
229        },
230        Transform::from_xyz(1.0, 2.0, 3.0).looking_at(Vec3::ZERO, Vec3::Y),
231    ));
232
233    commands.spawn((
234        Node {
235            position_type: PositionType::Absolute,
236            padding: UiRect::all(px(5)),
237            ..default()
238        },
239        BackgroundColor(Color::BLACK.with_alpha(0.75)),
240        GlobalZIndex(i32::MAX),
241        children![(
242            Text::default(),
243            StatsText,
244            children![
245                (
246                    TextSpan::new("Cube Count: "),
247                    font.clone(),
248                    TextColor(LIME.into()),
249                ),
250                (TextSpan::new(""), font.clone(), TextColor(AQUA.into())),
251                (
252                    TextSpan::new("\nFPS (raw): "),
253                    font.clone(),
254                    TextColor(LIME.into()),
255                ),
256                (TextSpan::new(""), font.clone(), TextColor(AQUA.into())),
257                (
258                    TextSpan::new("\nFPS (SMA): "),
259                    font.clone(),
260                    TextColor(LIME.into()),
261                ),
262                (TextSpan::new(""), font.clone(), TextColor(AQUA.into())),
263                (
264                    TextSpan::new("\nFPS (EMA): "),
265                    font.clone(),
266                    TextColor(LIME.into()),
267                ),
268                (TextSpan::new(""), font.clone(), TextColor(AQUA.into()))
269            ]
270        )],
271    ));
272
273    let mut scheduled = CubeScheduled {
274        per_wave: args.per_wave,
275        waves: args.waves,
276    };
277
278    if args.benchmark {
279        let counter = counter.into_inner();
280        for wave in (0..scheduled.waves).rev() {
281            spawn_cubes(
282                &mut commands,
283                args,
284                counter,
285                scheduled.per_wave,
286                &mut cube_resources,
287                Some(wave),
288                wave,
289            );
290        }
291        scheduled.waves = 0;
292    }
293    commands.insert_resource(cube_resources);
294    commands.insert_resource(scheduled);
295}
296
297fn mouse_handler(
298    mut commands: Commands,
299    args: Res<Args>,
300    time: Res<Time>,
301    mouse_button_input: Res<ButtonInput<MouseButton>>,
302    cube_resources: ResMut<CubeResources>,
303    mut counter: ResMut<BevyCounter>,
304    mut rng: Local<Option<ChaCha8Rng>>,
305    mut wave: Local<usize>,
306) {
307    if rng.is_none() {
308        *rng = Some(ChaCha8Rng::seed_from_u64(42));
309    }
310    let rng = rng.as_mut().unwrap();
311
312    if mouse_button_input.just_released(MouseButton::Left) {
313        counter.color = Color::linear_rgb(rng.random(), rng.random(), rng.random());
314    }
315
316    if mouse_button_input.pressed(MouseButton::Left) {
317        let spawn_count = (CUBES_PER_SECOND as f64 * time.delta_secs_f64()) as usize;
318        spawn_cubes(
319            &mut commands,
320            args.into_inner(),
321            &mut counter,
322            spawn_count,
323            cube_resources.into_inner(),
324            None,
325            *wave,
326        );
327        *wave += 1;
328    }
329}
330
331fn cube_velocity_transform(
332    mut translation: Vec3,
333    velocity_rng: &mut ChaCha8Rng,
334    waves: Option<usize>,
335    dt: f32,
336) -> (Transform, Vec3) {
337    let mut velocity = Vec3::new(0., 0., MAX_VELOCITY * velocity_rng.random::<f32>());
338
339    if let Some(waves) = waves {
340        for _ in 0..(waves * (FIXED_TIMESTEP / dt).round() as usize) {
341            step_movement(&mut translation, &mut velocity, dt);
342            handle_collision(&translation, &mut velocity);
343        }
344    }
345    (Transform::from_translation(translation), velocity)
346}
347
348const FIXED_DELTA_TIME: f32 = 1.0 / 60.0;
349
350fn spawn_cubes(
351    commands: &mut Commands,
352    args: &Args,
353    counter: &mut BevyCounter,
354    spawn_count: usize,
355    cube_resources: &mut CubeResources,
356    waves_to_simulate: Option<usize>,
357    wave: usize,
358) {
359    let batch_material = cube_resources.materials[wave % cube_resources.materials.len()].clone();
360
361    let spawn_y = VOLUME_SIZE.y / 2.0 - HALF_CUBE_SIZE;
362    let spawn_z = -VOLUME_SIZE.z / 2.0 + HALF_CUBE_SIZE;
363
364    let batch = (0..spawn_count)
365        .map(|_| {
366            let spawn_pos = Vec3::new(
367                (cube_resources.transform_rng.random::<f32>() - 0.5) * VOLUME_SIZE.x,
368                spawn_y,
369                spawn_z,
370            );
371
372            let (transform, velocity) = cube_velocity_transform(
373                spawn_pos,
374                &mut cube_resources.velocity_rng,
375                waves_to_simulate,
376                FIXED_DELTA_TIME,
377            );
378
379            let material = if args.vary_per_instance {
380                cube_resources
381                    .materials
382                    .choose(&mut cube_resources.material_rng)
383                    .unwrap()
384                    .clone()
385            } else {
386                batch_material.clone()
387            };
388
389            (
390                Mesh3d(cube_resources.cube_mesh.clone()),
391                MeshMaterial3d(material),
392                transform,
393                Cube { velocity },
394            )
395        })
396        .collect::<Vec<_>>();
397    commands.spawn_batch(batch);
398
399    counter.count += spawn_count;
400    counter.color = Color::linear_rgb(
401        cube_resources.color_rng.random(),
402        cube_resources.color_rng.random(),
403        cube_resources.color_rng.random(),
404    );
405}
406
407fn step_movement(translation: &mut Vec3, velocity: &mut Vec3, dt: f32) {
408    translation.x += velocity.x * dt;
409    translation.y += velocity.y * dt;
410    translation.z += velocity.z * dt;
411    velocity.y += GRAVITY * dt;
412}
413
414fn movement_system(
415    args: Res<Args>,
416    time: Res<Time>,
417    mut cube_query: Query<(&mut Cube, &mut Transform)>,
418) {
419    let dt = if args.benchmark {
420        FIXED_DELTA_TIME
421    } else {
422        time.delta_secs()
423    };
424    for (mut cube, mut transform) in &mut cube_query {
425        step_movement(&mut transform.translation, &mut cube.velocity, dt);
426    }
427}
428
429fn handle_collision(translation: &Vec3, velocity: &mut Vec3) {
430    if (velocity.x > 0. && translation.x + HALF_CUBE_SIZE > VOLUME_SIZE.x / 2.0)
431        || (velocity.x <= 0. && translation.x - HALF_CUBE_SIZE < -VOLUME_SIZE.x / 2.0)
432    {
433        velocity.x = -velocity.x;
434    }
435    if (velocity.z > 0. && translation.z + HALF_CUBE_SIZE > VOLUME_SIZE.z / 2.0)
436        || (velocity.z <= 0. && translation.z - HALF_CUBE_SIZE < -VOLUME_SIZE.z / 2.0)
437    {
438        velocity.z = -velocity.z;
439    }
440
441    let velocity_y = velocity.y;
442    if velocity_y < 0. && translation.y - HALF_CUBE_SIZE < -VOLUME_SIZE.y / 2.0 {
443        velocity.y = -velocity_y;
444    }
445    if translation.y + HALF_CUBE_SIZE > VOLUME_SIZE.y / 2.0 && velocity_y > 0.0 {
446        velocity.y = 0.0;
447    }
448}
449
450fn collision_system(mut cube_query: Query<(&mut Cube, &Transform)>) {
451    cube_query.par_iter_mut().for_each(|(mut cube, transform)| {
452        handle_collision(&transform.translation, &mut cube.velocity);
453    });
454}
455
456fn counter_system(
457    diagnostics: Res<DiagnosticsStore>,
458    counter: Res<BevyCounter>,
459    query: Single<Entity, With<StatsText>>,
460    mut writer: TextUiWriter,
461) {
462    let text = *query;
463
464    if counter.is_changed() {
465        *writer.text(text, 2) = counter.count.to_string();
466    }
467
468    if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) {
469        if let Some(raw) = fps.value() {
470            *writer.text(text, 4) = format!("{raw:.2}");
471        }
472        if let Some(sma) = fps.average() {
473            *writer.text(text, 6) = format!("{sma:.2}");
474        }
475        if let Some(ema) = fps.smoothed() {
476            *writer.text(text, 8) = format!("{ema:.2}");
477        }
478    };
479}
480
481fn init_textures(textures: &mut Vec<Handle<Image>>, args: &Args, images: &mut Assets<Image>) {
482    let mut color_rng = ChaCha8Rng::seed_from_u64(42);
483    while textures.len() < args.material_texture_count {
484        let pixel = [
485            color_rng.random(),
486            color_rng.random(),
487            color_rng.random(),
488            255,
489        ];
490        textures.push(images.add(Image::new_fill(
491            Extent3d {
492                width: CUBE_TEXTURE_SIZE as u32,
493                height: CUBE_TEXTURE_SIZE as u32,
494                depth_or_array_layers: 1,
495            },
496            TextureDimension::D2,
497            &pixel,
498            TextureFormat::Rgba8UnormSrgb,
499            RenderAssetUsages::RENDER_WORLD,
500        )));
501    }
502}
503
504fn init_materials(
505    args: &Args,
506    textures: &[Handle<Image>],
507    assets: &mut Assets<StandardMaterial>,
508) -> Vec<Handle<StandardMaterial>> {
509    let mut capacity = if args.vary_per_instance {
510        args.per_wave * args.waves
511    } else {
512        args.material_texture_count.max(args.waves)
513    };
514    if !args.benchmark {
515        capacity = capacity.max(256);
516    }
517    capacity = capacity.max(1);
518
519    let alpha_mode = match args.alpha_mode {
520        AlphaMode::Opaque => bevy::prelude::AlphaMode::Opaque,
521        AlphaMode::Blend => bevy::prelude::AlphaMode::Blend,
522        AlphaMode::AlphaMask => bevy::prelude::AlphaMode::Mask(0.5),
523    };
524
525    let mut materials = Vec::with_capacity(capacity);
526    materials.push(assets.add(StandardMaterial {
527        base_color: Color::WHITE,
528        base_color_texture: textures.first().cloned(),
529        alpha_mode,
530        ..default()
531    }));
532
533    let mut color_rng = ChaCha8Rng::seed_from_u64(42);
534    let mut texture_rng = ChaCha8Rng::seed_from_u64(42);
535    materials.extend(
536        std::iter::repeat_with(|| {
537            assets.add(StandardMaterial {
538                base_color: Color::linear_rgb(
539                    color_rng.random(),
540                    color_rng.random(),
541                    color_rng.random(),
542                ),
543                base_color_texture: textures.choose(&mut texture_rng).cloned(),
544                alpha_mode,
545                ..default()
546            })
547        })
548        .take(capacity - materials.len()),
549    );
550
551    materials
552}