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