Skip to main content

many_cubes/
many_cubes.rs

1//! Simple benchmark to test per-entity draw overhead.
2//!
3//! To measure performance realistically, be sure to run this in release mode.
4//! `cargo run --example many_cubes --release`
5//!
6//! By default, this arranges the meshes in a spherical pattern that
7//! distributes the meshes evenly.
8//!
9//! See `cargo run --example many_cubes --release -- --help` for more options.
10
11use std::{f64::consts::PI, str::FromStr};
12
13use argh::FromArgs;
14use bevy::{
15    asset::RenderAssetUsages,
16    camera::visibility::{NoCpuCulling, NoFrustumCulling},
17    diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin},
18    light::NotShadowCaster,
19    math::{
20        ops::{cbrt, sqrt},
21        DVec2, DVec3,
22    },
23    mesh::MeshCompressionArgs,
24    post_process::motion_blur::MotionBlur,
25    prelude::*,
26    render::{
27        batching::NoAutomaticBatching,
28        render_resource::{Extent3d, TextureDimension, TextureFormat},
29        view::NoIndirectDrawing,
30    },
31    window::{PresentMode, WindowResolution},
32    winit::WinitSettings,
33};
34use chacha20::ChaCha8Rng;
35use rand::{seq::IndexedRandom, RngExt, SeedableRng};
36
37#[derive(FromArgs, Resource)]
38/// `many_cubes` stress test
39struct Args {
40    /// how the cube instances should be positioned.
41    #[argh(option, default = "Layout::Sphere")]
42    layout: Layout,
43
44    /// whether to step the camera animation by a fixed amount such that each frame is the same across runs.
45    #[argh(switch)]
46    benchmark: bool,
47
48    /// whether to vary the material data in each instance.
49    #[argh(switch)]
50    vary_material_data_per_instance: bool,
51
52    /// the number of different textures from which to randomly select the material base color. 0 means no textures.
53    #[argh(option, default = "0")]
54    material_texture_count: usize,
55
56    /// the number of different meshes from which to randomly select. Clamped to at least 1.
57    #[argh(option, default = "1")]
58    mesh_count: usize,
59
60    /// the number of cubes
61    #[argh(option, default = "1600000")]
62    instance_count: usize,
63
64    /// whether to disable all frustum culling. Stresses queuing and batching as all mesh material entities in the scene are always drawn.
65    #[argh(switch)]
66    no_frustum_culling: bool,
67
68    /// whether to disable automatic batching. Skips batching resulting in heavy stress on render pass draw command encoding.
69    #[argh(switch)]
70    no_automatic_batching: bool,
71
72    /// whether to disable indirect drawing.
73    #[argh(switch)]
74    no_indirect_drawing: bool,
75
76    /// whether to disable CPU culling.
77    #[argh(switch)]
78    no_cpu_culling: bool,
79
80    /// whether to enable directional light cascaded shadow mapping.
81    #[argh(switch)]
82    shadows: bool,
83
84    /// whether to continuously rotate individual cubes.
85    #[argh(switch)]
86    rotate_cubes: bool,
87
88    /// animate the cube materials by updating the material from the cpu each frame
89    #[argh(switch)]
90    animate_materials: bool,
91
92    /// whether to enable motion blur.
93    #[argh(switch)]
94    motion_blur: bool,
95
96    /// whether to enable mesh compression.
97    #[argh(switch)]
98    mesh_compression: bool,
99}
100
101#[derive(Default, Clone, PartialEq)]
102enum Layout {
103    Cube,
104    #[default]
105    Sphere,
106    Dense,
107}
108
109impl FromStr for Layout {
110    type Err = String;
111
112    fn from_str(s: &str) -> Result<Self, Self::Err> {
113        match s {
114            "cube" => Ok(Self::Cube),
115            "sphere" => Ok(Self::Sphere),
116            "dense" => Ok(Self::Dense),
117            _ => Err(format!(
118                "Unknown layout value: '{s}', valid options: 'cube', 'sphere', 'dense'"
119            )),
120        }
121    }
122}
123
124fn main() {
125    // `from_env` panics on the web
126    #[cfg(not(target_arch = "wasm32"))]
127    let args: Args = argh::from_env();
128    #[cfg(target_arch = "wasm32")]
129    let args = Args::from_args(&[], &[]).unwrap();
130
131    let mut app = App::new();
132    app.add_plugins((
133        DefaultPlugins.set(WindowPlugin {
134            primary_window: Some(Window {
135                present_mode: PresentMode::AutoNoVsync,
136                resolution: WindowResolution::new(1920, 1080).with_scale_factor_override(1.0),
137                ..default()
138            }),
139            ..default()
140        }),
141        FrameTimeDiagnosticsPlugin::default(),
142        LogDiagnosticsPlugin::default(),
143    ))
144    .insert_resource(WinitSettings::continuous())
145    .add_systems(Startup, setup)
146    .add_systems(Update, print_mesh_count);
147
148    if args.layout != Layout::Dense {
149        app.add_systems(Update, move_camera);
150    }
151
152    if args.rotate_cubes {
153        app.add_systems(Update, rotate_cubes);
154    }
155
156    if args.animate_materials {
157        app.add_systems(Update, update_materials);
158    }
159
160    app.insert_resource(args).run();
161}
162
163const WIDTH: usize = 200;
164const HEIGHT: usize = 200;
165
166fn setup(
167    mut commands: Commands,
168    args: Res<Args>,
169    mesh_assets: ResMut<Assets<Mesh>>,
170    material_assets: ResMut<Assets<StandardMaterial>>,
171    images: ResMut<Assets<Image>>,
172) {
173    warn!(include_str!("warning_string.txt"));
174
175    let args = args.into_inner();
176    let images = images.into_inner();
177    let material_assets = material_assets.into_inner();
178    let mesh_assets = mesh_assets.into_inner();
179
180    let meshes = init_meshes(args, mesh_assets);
181
182    let material_textures = init_textures(args, images);
183    let materials = init_materials(args, &material_textures, material_assets);
184
185    // We're seeding the PRNG here to make this example deterministic for testing purposes.
186    // This isn't strictly required in practical use unless you need your app to be deterministic.
187    let mut material_rng = ChaCha8Rng::seed_from_u64(42);
188    match args.layout {
189        Layout::Sphere => {
190            // NOTE: This pattern is good for testing performance of culling as it provides roughly
191            // the same number of visible meshes regardless of the viewing angle.
192            let n_points: usize = args.instance_count;
193            // NOTE: f64 is used to avoid precision issues that produce visual artifacts in the distribution
194            let radius = WIDTH as f64 * 2.5;
195            let golden_ratio = 0.5f64 * (1.0f64 + 5.0f64.sqrt());
196            for i in 0..n_points {
197                let spherical_polar_theta_phi =
198                    fibonacci_spiral_on_sphere(golden_ratio, i, n_points);
199                let unit_sphere_p = spherical_polar_to_cartesian(spherical_polar_theta_phi);
200                let (mesh, transform) = meshes.choose(&mut material_rng).unwrap();
201                commands
202                    .spawn((
203                        Mesh3d(mesh.clone()),
204                        MeshMaterial3d(materials.choose(&mut material_rng).unwrap().clone()),
205                        Transform::from_translation((radius * unit_sphere_p).as_vec3())
206                            .looking_at(Vec3::ZERO, Vec3::Y)
207                            .mul_transform(*transform),
208                    ))
209                    .insert_if(NoFrustumCulling, || args.no_frustum_culling)
210                    .insert_if(NoAutomaticBatching, || args.no_automatic_batching)
211                    .insert_if(NoCpuCulling, || args.no_cpu_culling);
212            }
213
214            // camera
215            let mut camera = commands.spawn(Camera3d::default());
216            if args.no_indirect_drawing {
217                camera.insert(NoIndirectDrawing);
218            }
219            if args.no_cpu_culling {
220                camera.insert(NoCpuCulling);
221            }
222            if args.motion_blur {
223                camera.insert((
224                    MotionBlur {
225                        // Use an unrealistically large shutter angle so that motion blur is clearly visible.
226                        shutter_angle: 3.0,
227                        ..Default::default()
228                    },
229                    // MSAA and MotionBlur are not compatible on WebGL.
230                    #[cfg(all(
231                        feature = "webgl2",
232                        target_arch = "wasm32",
233                        not(feature = "webgpu")
234                    ))]
235                    Msaa::Off,
236                ));
237            }
238
239            // Inside-out box around the meshes onto which shadows are cast (though you cannot see them...)
240            commands.spawn((
241                Mesh3d(mesh_assets.add(Cuboid::from_size(Vec3::splat(radius as f32 * 2.2)))),
242                MeshMaterial3d(material_assets.add(StandardMaterial::from(Color::WHITE))),
243                Transform::from_scale(-Vec3::ONE),
244                NotShadowCaster,
245            ));
246        }
247        Layout::Cube => {
248            // NOTE: This pattern is good for demonstrating that frustum culling is working correctly
249            // as the number of visible meshes rises and falls depending on the viewing angle.
250            let scale = 2.5;
251
252            // Scale the width and height by the same factor so that we have the
253            // right number of instances.
254            // Because of the moiré pattern check and the fact that we're
255            // spawning 4 instances per trip around the inner loop below, we're
256            // solving the following equation for the factor variable:
257            //
258            //      4 * (9/10 * factor * width * 9/10 * factor * height) = count
259            //
260            // The solution is the value below.
261            let factor = (5.0 / 9.0) * sqrt(args.instance_count as f32)
262                / (sqrt(HEIGHT as f32) * sqrt(WIDTH as f32));
263            let dimensions = (vec2(WIDTH as f32, HEIGHT as f32) * factor)
264                .ceil()
265                .as_uvec2();
266
267            for x in 0..dimensions.x {
268                for y in 0..dimensions.y {
269                    // introduce spaces to break any kind of moiré pattern
270                    if x % 10 == 0 || y % 10 == 0 {
271                        continue;
272                    }
273                    // cube
274                    commands
275                        .spawn((
276                            Mesh3d(meshes.choose(&mut material_rng).unwrap().0.clone()),
277                            MeshMaterial3d(materials.choose(&mut material_rng).unwrap().clone()),
278                            Transform::from_xyz((x as f32) * scale, (y as f32) * scale, 0.0),
279                        ))
280                        .insert_if(NoCpuCulling, || args.no_cpu_culling);
281                    commands
282                        .spawn((
283                            Mesh3d(meshes.choose(&mut material_rng).unwrap().0.clone()),
284                            MeshMaterial3d(materials.choose(&mut material_rng).unwrap().clone()),
285                            Transform::from_xyz(
286                                (x as f32) * scale,
287                                dimensions.y as f32 * scale,
288                                (y as f32) * scale,
289                            ),
290                        ))
291                        .insert_if(NoCpuCulling, || args.no_cpu_culling);
292                    commands
293                        .spawn((
294                            Mesh3d(meshes.choose(&mut material_rng).unwrap().0.clone()),
295                            MeshMaterial3d(materials.choose(&mut material_rng).unwrap().clone()),
296                            Transform::from_xyz((x as f32) * scale, 0.0, (y as f32) * scale),
297                        ))
298                        .insert_if(NoCpuCulling, || args.no_cpu_culling);
299                    commands
300                        .spawn((
301                            Mesh3d(meshes.choose(&mut material_rng).unwrap().0.clone()),
302                            MeshMaterial3d(materials.choose(&mut material_rng).unwrap().clone()),
303                            Transform::from_xyz(0.0, (x as f32) * scale, (y as f32) * scale),
304                        ))
305                        .insert_if(NoCpuCulling, || args.no_cpu_culling);
306                }
307            }
308            // camera
309            let center = 0.5
310                * scale
311                * Vec3::new(
312                    dimensions.x as f32,
313                    dimensions.y as f32,
314                    dimensions.x as f32,
315                );
316            commands.spawn((Camera3d::default(), Transform::from_translation(center)));
317            // Inside-out box around the meshes onto which shadows are cast (though you cannot see them...)
318            commands.spawn((
319                Mesh3d(mesh_assets.add(Cuboid::from_size(2.0 * 1.1 * center))),
320                MeshMaterial3d(material_assets.add(StandardMaterial::from(Color::WHITE))),
321                Transform::from_scale(-Vec3::ONE).with_translation(center),
322                NotShadowCaster,
323            ));
324        }
325        Layout::Dense => {
326            // NOTE: This pattern is good for demonstrating a dense configuration of cubes
327            // overlapping each other, all within the camera frustum.
328            let count = args.instance_count;
329            let size = cbrt(count as f32).round();
330            let gap = 1.25;
331
332            for i in 0..count {
333                let x = i as f32 % size;
334                let y = (i as f32 / size) % size;
335                let z = i as f32 / (size * size);
336                let pos = Vec3::new(x * gap, y * gap, z * gap);
337                commands
338                    .spawn((
339                        Mesh3d(meshes.choose(&mut material_rng).unwrap().0.clone()),
340                        MeshMaterial3d(materials.choose(&mut material_rng).unwrap().clone()),
341                        Transform::from_translation(pos),
342                    ))
343                    .insert_if(NoCpuCulling, || args.no_cpu_culling);
344            }
345
346            // camera
347            commands.spawn((
348                Camera3d::default(),
349                Transform::from_xyz(100.0, 90.0, 100.0)
350                    .looking_at(Vec3::new(0.0, -10.0, 0.0), Vec3::Y),
351            ));
352        }
353    }
354
355    commands.spawn((
356        DirectionalLight {
357            shadow_maps_enabled: args.shadows,
358            ..default()
359        },
360        Transform::IDENTITY.looking_at(Vec3::new(0.0, -1.0, -1.0), Vec3::Y),
361    ));
362}
363
364fn init_textures(args: &Args, images: &mut Assets<Image>) -> Vec<Handle<Image>> {
365    // We're seeding the PRNG here to make this example deterministic for testing purposes.
366    // This isn't strictly required in practical use unless you need your app to be deterministic.
367    let mut color_rng = ChaCha8Rng::seed_from_u64(42);
368    let color_bytes: Vec<u8> = (0..(args.material_texture_count * 4))
369        .map(|i| {
370            if (i % 4) == 3 {
371                255
372            } else {
373                color_rng.random()
374            }
375        })
376        .collect();
377    color_bytes
378        .chunks(4)
379        .map(|pixel| {
380            images.add(Image::new_fill(
381                Extent3d::default(),
382                TextureDimension::D2,
383                pixel,
384                TextureFormat::Rgba8UnormSrgb,
385                RenderAssetUsages::RENDER_WORLD,
386            ))
387        })
388        .collect()
389}
390
391fn init_materials(
392    args: &Args,
393    textures: &[Handle<Image>],
394    assets: &mut Assets<StandardMaterial>,
395) -> Vec<Handle<StandardMaterial>> {
396    let capacity = if args.vary_material_data_per_instance {
397        args.instance_count
398    } else {
399        args.material_texture_count
400    }
401    .max(1);
402
403    let mut materials = Vec::with_capacity(capacity);
404    materials.push(assets.add(StandardMaterial {
405        base_color: Color::WHITE,
406        base_color_texture: textures.first().cloned(),
407        ..default()
408    }));
409
410    // We're seeding the PRNG here to make this example deterministic for testing purposes.
411    // This isn't strictly required in practical use unless you need your app to be deterministic.
412    let mut color_rng = ChaCha8Rng::seed_from_u64(42);
413    let mut texture_rng = ChaCha8Rng::seed_from_u64(42);
414    materials.extend(
415        std::iter::repeat_with(|| {
416            assets.add(StandardMaterial {
417                base_color: Color::srgb_u8(
418                    color_rng.random(),
419                    color_rng.random(),
420                    color_rng.random(),
421                ),
422                base_color_texture: textures.choose(&mut texture_rng).cloned(),
423                ..default()
424            })
425        })
426        .take(capacity - materials.len()),
427    );
428
429    materials
430}
431
432fn compress_mesh(args: &Args, mesh: impl Into<Mesh>) -> Mesh {
433    if args.mesh_compression {
434        mesh.into()
435            .compressed_mesh(&MeshCompressionArgs::regular())
436            .unwrap()
437    } else {
438        mesh.into()
439    }
440}
441
442fn init_meshes(args: &Args, assets: &mut Assets<Mesh>) -> Vec<(Handle<Mesh>, Transform)> {
443    let capacity = args.mesh_count.max(1);
444
445    // We're seeding the PRNG here to make this example deterministic for testing purposes.
446    // This isn't strictly required in practical use unless you need your app to be deterministic.
447    let mut radius_rng = ChaCha8Rng::seed_from_u64(42);
448    let mut variant = 0;
449    std::iter::repeat_with(|| {
450        let radius = radius_rng.random_range(0.25f32..=0.75f32);
451        let (handle, transform) = match variant % 15 {
452            0 => (
453                assets.add(compress_mesh(
454                    args,
455                    Cuboid {
456                        half_size: Vec3::splat(radius),
457                    },
458                )),
459                Transform::IDENTITY,
460            ),
461            1 => (
462                assets.add(compress_mesh(
463                    args,
464                    Capsule3d {
465                        radius,
466                        half_length: radius,
467                    },
468                )),
469                Transform::IDENTITY,
470            ),
471            2 => (
472                assets.add(compress_mesh(args, Circle { radius })),
473                Transform::IDENTITY.looking_at(Vec3::Z, Vec3::Y),
474            ),
475            3 => {
476                let mut vertices = [Vec2::ZERO; 3];
477                let dtheta = std::f32::consts::TAU / 3.0;
478                for (i, vertex) in vertices.iter_mut().enumerate() {
479                    let (s, c) = ops::sin_cos(i as f32 * dtheta);
480                    *vertex = Vec2::new(c, s) * radius;
481                }
482                (
483                    assets.add(compress_mesh(args, Triangle2d { vertices })),
484                    Transform::IDENTITY.looking_at(Vec3::Z, Vec3::Y),
485                )
486            }
487            4 => (
488                assets.add(compress_mesh(
489                    args,
490                    Rectangle {
491                        half_size: Vec2::splat(radius),
492                    },
493                )),
494                Transform::IDENTITY.looking_at(Vec3::Z, Vec3::Y),
495            ),
496            v if (5..=8).contains(&v) => (
497                assets.add(compress_mesh(
498                    args,
499                    RegularPolygon {
500                        circumcircle: Circle { radius },
501                        sides: v,
502                    },
503                )),
504                Transform::IDENTITY.looking_at(Vec3::Z, Vec3::Y),
505            ),
506            9 => (
507                assets.add(compress_mesh(
508                    args,
509                    Cylinder {
510                        radius,
511                        half_height: radius,
512                    },
513                )),
514                Transform::IDENTITY,
515            ),
516            10 => (
517                assets.add(compress_mesh(
518                    args,
519                    Ellipse {
520                        half_size: Vec2::new(radius, 0.5 * radius),
521                    },
522                )),
523                Transform::IDENTITY.looking_at(Vec3::Z, Vec3::Y),
524            ),
525            11 => (
526                assets.add(compress_mesh(
527                    args,
528                    Plane3d {
529                        normal: Dir3::NEG_Z,
530                        half_size: Vec2::splat(radius / 2.0),
531                    },
532                )),
533                Transform::IDENTITY,
534            ),
535            12 => (
536                assets.add(compress_mesh(args, Sphere { radius })),
537                Transform::IDENTITY,
538            ),
539            13 => (
540                assets.add(compress_mesh(
541                    args,
542                    Torus {
543                        minor_radius: 0.5 * radius,
544                        major_radius: radius,
545                    },
546                )),
547                Transform::IDENTITY.looking_at(Vec3::Y, Vec3::Y),
548            ),
549            14 => (
550                assets.add(compress_mesh(
551                    args,
552                    Capsule2d {
553                        radius,
554                        half_length: radius,
555                    },
556                )),
557                Transform::IDENTITY.looking_at(Vec3::Z, Vec3::Y),
558            ),
559            _ => unreachable!(),
560        };
561        variant += 1;
562        (handle, transform)
563    })
564    .take(capacity)
565    .collect()
566}
567
568// NOTE: This epsilon value is apparently optimal for optimizing for the average
569// nearest-neighbor distance. See:
570// http://extremelearning.com.au/how-to-evenly-distribute-points-on-a-sphere-more-effectively-than-the-canonical-fibonacci-lattice/
571// for details.
572const EPSILON: f64 = 0.36;
573
574fn fibonacci_spiral_on_sphere(golden_ratio: f64, i: usize, n: usize) -> DVec2 {
575    DVec2::new(
576        PI * 2. * (i as f64 / golden_ratio),
577        f64::acos(1.0 - 2.0 * (i as f64 + EPSILON) / (n as f64 - 1.0 + 2.0 * EPSILON)),
578    )
579}
580
581fn spherical_polar_to_cartesian(p: DVec2) -> DVec3 {
582    let (sin_theta, cos_theta) = p.x.sin_cos();
583    let (sin_phi, cos_phi) = p.y.sin_cos();
584    DVec3::new(cos_theta * sin_phi, sin_theta * sin_phi, cos_phi)
585}
586
587// System for rotating the camera
588fn move_camera(
589    time: Res<Time>,
590    args: Res<Args>,
591    mut camera_transform: Single<&mut Transform, With<Camera>>,
592) {
593    let delta = 0.15
594        * if args.benchmark {
595            1.0 / 60.0
596        } else {
597            time.delta_secs()
598        };
599    camera_transform.rotate_z(delta);
600    camera_transform.rotate_x(delta);
601}
602
603// System for printing the number of meshes on every tick of the timer
604fn print_mesh_count(
605    time: Res<Time>,
606    mut timer: Local<PrintingTimer>,
607    sprites: Query<(&Mesh3d, &ViewVisibility)>,
608) {
609    timer.tick(time.delta());
610
611    if timer.just_finished() {
612        info!(
613            "Meshes: {} - Visible Meshes {}",
614            sprites.iter().len(),
615            sprites.iter().filter(|(_, vis)| vis.get()).count(),
616        );
617    }
618}
619
620#[derive(Deref, DerefMut)]
621struct PrintingTimer(Timer);
622
623impl Default for PrintingTimer {
624    fn default() -> Self {
625        Self(Timer::from_seconds(1.0, TimerMode::Repeating))
626    }
627}
628
629fn update_materials(mut materials: ResMut<Assets<StandardMaterial>>, time: Res<Time>) {
630    let elapsed = time.elapsed_secs();
631    for (i, (_, material)) in materials.iter_mut().enumerate() {
632        let hue = (elapsed + i as f32 * 0.005).rem_euclid(1.0);
633        // This is much faster than using base_color.set_hue(hue), and in a tight loop it shows.
634        let color = fast_hue_to_rgb(hue);
635        material.base_color = Color::linear_rgb(color.x, color.y, color.z);
636    }
637}
638
639fn rotate_cubes(
640    mut query: Query<&mut Transform, (With<Mesh3d>, Without<NotShadowCaster>)>,
641    time: Res<Time>,
642) {
643    query.par_iter_mut().for_each(|mut transform| {
644        transform.rotate_y(10.0 * time.delta_secs());
645    });
646}
647
648#[inline]
649fn fast_hue_to_rgb(hue: f32) -> Vec3 {
650    (hue * 6.0 - vec3(3.0, 2.0, 4.0)).abs() * vec3(1.0, -1.0, -1.0) + vec3(-1.0, 2.0, 2.0)
651}