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