Skip to main content

visibility_range/
visibility_range.rs

1//! Demonstrates visibility ranges, also known as HLODs.
2
3use std::f32::consts::PI;
4
5use bevy::{
6    camera::visibility::{NoCpuCulling, VisibilityRange},
7    core_pipeline::prepass::{DepthPrepass, NormalPrepass},
8    input::mouse::MouseWheel,
9    light::{light_consts::lux::FULL_DAYLIGHT, CascadeShadowConfigBuilder},
10    math::vec3,
11    prelude::*,
12};
13
14use argh::FromArgs;
15
16// Where the camera is focused.
17const CAMERA_FOCAL_POINT: Vec3 = vec3(0.0, 0.3, 0.0);
18// Speed in units per frame.
19const CAMERA_KEYBOARD_ZOOM_SPEED: f32 = 0.05;
20// Speed in radians per frame.
21const CAMERA_KEYBOARD_PAN_SPEED: f32 = 0.01;
22// Speed in units per frame.
23const CAMERA_MOUSE_MOVEMENT_SPEED: f32 = 0.25;
24// The minimum distance that the camera is allowed to be from the model.
25const MIN_ZOOM_DISTANCE: f32 = 0.5;
26
27// The visibility ranges for high-poly and low-poly models respectively, when
28// both models are being shown.
29static NORMAL_VISIBILITY_RANGE_HIGH_POLY: VisibilityRange = VisibilityRange {
30    start_margin: 0.0..0.0,
31    end_margin: 3.0..4.0,
32    use_aabb: false,
33};
34static NORMAL_VISIBILITY_RANGE_LOW_POLY: VisibilityRange = VisibilityRange {
35    start_margin: 3.0..4.0,
36    end_margin: 8.0..9.0,
37    use_aabb: false,
38};
39
40// A visibility model that we use to always show a model (until the camera is so
41// far zoomed out that it's culled entirely).
42static SINGLE_MODEL_VISIBILITY_RANGE: VisibilityRange = VisibilityRange {
43    start_margin: 0.0..0.0,
44    end_margin: 8.0..9.0,
45    use_aabb: false,
46};
47
48// A visibility range that we use to completely hide a model.
49static INVISIBLE_VISIBILITY_RANGE: VisibilityRange = VisibilityRange {
50    start_margin: 0.0..0.0,
51    end_margin: 0.0..0.0,
52    use_aabb: false,
53};
54
55// Allows us to identify the main model.
56#[derive(Component, Debug, Clone, Copy, PartialEq)]
57enum MainModel {
58    // The high-poly version.
59    HighPoly,
60    // The low-poly version.
61    LowPoly,
62}
63
64// The current mode.
65#[derive(Default, Resource)]
66struct AppStatus {
67    // Whether to show only one model.
68    show_one_model_only: Option<MainModel>,
69    // Whether to enable the prepass.
70    prepass: bool,
71}
72
73/// Demonstrates visibility ranges, also known as HLODs
74#[derive(FromArgs, Resource)]
75struct Args {
76    /// whether to use GPU culling only
77    #[argh(switch)]
78    no_cpu_culling: bool,
79}
80
81fn main() {
82    #[cfg(not(target_arch = "wasm32"))]
83    let args: Args = argh::from_env();
84    #[cfg(target_arch = "wasm32")]
85    let args = Args::from_args(&[], &[]).unwrap();
86
87    App::new()
88        .add_plugins(DefaultPlugins.set(WindowPlugin {
89            primary_window: Some(Window {
90                title: "Bevy Visibility Range Example".into(),
91                ..default()
92            }),
93            ..default()
94        }))
95        .init_resource::<AppStatus>()
96        .insert_resource(args)
97        .add_systems(Startup, setup)
98        .add_systems(
99            Update,
100            (
101                move_camera,
102                set_visibility_ranges,
103                update_help_text,
104                update_mode,
105                toggle_prepass,
106            ),
107        )
108        .run();
109}
110
111// Set up a simple 3D scene. Load the two meshes.
112fn setup(
113    mut commands: Commands,
114    mut meshes: ResMut<Assets<Mesh>>,
115    mut materials: ResMut<Assets<StandardMaterial>>,
116    asset_server: Res<AssetServer>,
117    app_status: Res<AppStatus>,
118) {
119    // Spawn a plane.
120    commands.spawn((
121        Mesh3d(meshes.add(Plane3d::default().mesh().size(50.0, 50.0))),
122        MeshMaterial3d(materials.add(Color::srgb(0.1, 0.2, 0.1))),
123    ));
124
125    // Spawn the two HLODs.
126
127    commands.spawn((
128        WorldAssetRoot(
129            asset_server
130                .load(GltfAssetLabel::Scene(0).from_asset("models/FlightHelmet/FlightHelmet.gltf")),
131        ),
132        MainModel::HighPoly,
133    ));
134
135    commands.spawn((
136        WorldAssetRoot(
137            asset_server.load(
138                GltfAssetLabel::Scene(0)
139                    .from_asset("models/FlightHelmetLowPoly/FlightHelmetLowPoly.gltf"),
140            ),
141        ),
142        MainModel::LowPoly,
143    ));
144
145    // Spawn a light.
146    commands.spawn((
147        DirectionalLight {
148            illuminance: FULL_DAYLIGHT,
149            shadow_maps_enabled: true,
150            ..default()
151        },
152        Transform::from_rotation(Quat::from_euler(EulerRot::ZYX, 0.0, PI * -0.15, PI * -0.15)),
153        CascadeShadowConfigBuilder {
154            maximum_distance: 30.0,
155            first_cascade_far_bound: 0.9,
156            ..default()
157        }
158        .build(),
159    ));
160
161    // Spawn a camera.
162    commands
163        .spawn((
164            Camera3d::default(),
165            Transform::from_xyz(0.7, 0.7, 1.0).looking_at(CAMERA_FOCAL_POINT, Vec3::Y),
166        ))
167        .insert(EnvironmentMapLight {
168            diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"),
169            specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"),
170            intensity: 150.0,
171            ..default()
172        });
173
174    // Create the text.
175    commands.spawn((
176        app_status.create_text(),
177        Node {
178            position_type: PositionType::Absolute,
179            bottom: px(12),
180            left: px(12),
181            ..default()
182        },
183    ));
184}
185
186// We need to add the `VisibilityRange` components manually, as glTF currently
187// has no way to specify visibility ranges. This system watches for new meshes,
188// determines which `Scene` they're under, and adds the `VisibilityRange`
189// component as appropriate.
190fn set_visibility_ranges(
191    mut commands: Commands,
192    mut new_meshes: Query<Entity, Added<Mesh3d>>,
193    children: Query<(Option<&ChildOf>, Option<&MainModel>)>,
194    args: Res<Args>,
195) {
196    // Loop over each newly-added mesh.
197    for new_mesh in new_meshes.iter_mut() {
198        // Search for the nearest ancestor `MainModel` component.
199        let (mut current, mut main_model) = (new_mesh, None);
200        while let Ok((child_of, maybe_main_model)) = children.get(current) {
201            if let Some(model) = maybe_main_model {
202                main_model = Some(model);
203                break;
204            }
205            match child_of {
206                Some(child_of) => current = child_of.parent(),
207                None => break,
208            }
209        }
210
211        // Add the `VisibilityRange` component.
212        match main_model {
213            Some(MainModel::HighPoly) => {
214                let mut entity_commands = commands.entity(new_mesh);
215                entity_commands
216                    .insert(NORMAL_VISIBILITY_RANGE_HIGH_POLY.clone())
217                    .insert(MainModel::HighPoly);
218                if args.no_cpu_culling {
219                    entity_commands.insert(NoCpuCulling);
220                }
221            }
222            Some(MainModel::LowPoly) => {
223                let mut entity_commands = commands.entity(new_mesh);
224                entity_commands
225                    .insert(NORMAL_VISIBILITY_RANGE_LOW_POLY.clone())
226                    .insert(MainModel::LowPoly);
227                if args.no_cpu_culling {
228                    entity_commands.insert(NoCpuCulling);
229                }
230            }
231            None => {}
232        }
233    }
234}
235
236// Process the movement controls.
237fn move_camera(
238    keyboard_input: Res<ButtonInput<KeyCode>>,
239    mut mouse_wheel_reader: MessageReader<MouseWheel>,
240    mut cameras: Query<&mut Transform, With<Camera3d>>,
241) {
242    let (mut zoom_delta, mut theta_delta) = (0.0, 0.0);
243
244    // Process zoom in and out via the keyboard.
245    if keyboard_input.pressed(KeyCode::KeyW) || keyboard_input.pressed(KeyCode::ArrowUp) {
246        zoom_delta -= CAMERA_KEYBOARD_ZOOM_SPEED;
247    } else if keyboard_input.pressed(KeyCode::KeyS) || keyboard_input.pressed(KeyCode::ArrowDown) {
248        zoom_delta += CAMERA_KEYBOARD_ZOOM_SPEED;
249    }
250
251    // Process left and right pan via the keyboard.
252    if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
253        theta_delta -= CAMERA_KEYBOARD_PAN_SPEED;
254    } else if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
255        theta_delta += CAMERA_KEYBOARD_PAN_SPEED;
256    }
257
258    // Process zoom in and out via the mouse wheel.
259    for mouse_wheel in mouse_wheel_reader.read() {
260        zoom_delta -= mouse_wheel.y * CAMERA_MOUSE_MOVEMENT_SPEED;
261    }
262
263    // Update the camera transform.
264    for transform in cameras.iter_mut() {
265        let transform = transform.into_inner();
266
267        let direction = transform.translation.normalize_or_zero();
268        let magnitude = transform.translation.length();
269
270        let new_direction = Mat3::from_rotation_y(theta_delta) * direction;
271        let new_magnitude = (magnitude + zoom_delta).max(MIN_ZOOM_DISTANCE);
272
273        transform.translation = new_direction * new_magnitude;
274        transform.look_at(CAMERA_FOCAL_POINT, Vec3::Y);
275    }
276}
277
278// Toggles modes if the user requests.
279fn update_mode(
280    mut meshes: Query<(&mut VisibilityRange, &MainModel)>,
281    keyboard_input: Res<ButtonInput<KeyCode>>,
282    mut app_status: ResMut<AppStatus>,
283) {
284    // Toggle the mode as requested.
285    if keyboard_input.just_pressed(KeyCode::Digit1) || keyboard_input.just_pressed(KeyCode::Numpad1)
286    {
287        app_status.show_one_model_only = None;
288    } else if keyboard_input.just_pressed(KeyCode::Digit2)
289        || keyboard_input.just_pressed(KeyCode::Numpad2)
290    {
291        app_status.show_one_model_only = Some(MainModel::HighPoly);
292    } else if keyboard_input.just_pressed(KeyCode::Digit3)
293        || keyboard_input.just_pressed(KeyCode::Numpad3)
294    {
295        app_status.show_one_model_only = Some(MainModel::LowPoly);
296    } else {
297        return;
298    }
299
300    // Update the visibility ranges as appropriate.
301    for (mut visibility_range, main_model) in meshes.iter_mut() {
302        *visibility_range = match (main_model, app_status.show_one_model_only) {
303            (&MainModel::HighPoly, Some(MainModel::LowPoly))
304            | (&MainModel::LowPoly, Some(MainModel::HighPoly)) => {
305                INVISIBLE_VISIBILITY_RANGE.clone()
306            }
307            (&MainModel::HighPoly, Some(MainModel::HighPoly))
308            | (&MainModel::LowPoly, Some(MainModel::LowPoly)) => {
309                SINGLE_MODEL_VISIBILITY_RANGE.clone()
310            }
311            (&MainModel::HighPoly, None) => NORMAL_VISIBILITY_RANGE_HIGH_POLY.clone(),
312            (&MainModel::LowPoly, None) => NORMAL_VISIBILITY_RANGE_LOW_POLY.clone(),
313        }
314    }
315}
316
317// Toggles the prepass if the user requests.
318fn toggle_prepass(
319    mut commands: Commands,
320    cameras: Query<Entity, With<Camera3d>>,
321    keyboard_input: Res<ButtonInput<KeyCode>>,
322    mut app_status: ResMut<AppStatus>,
323) {
324    if !keyboard_input.just_pressed(KeyCode::Space) {
325        return;
326    }
327
328    app_status.prepass = !app_status.prepass;
329
330    for camera in cameras.iter() {
331        if app_status.prepass {
332            commands
333                .entity(camera)
334                .insert(DepthPrepass)
335                .insert(NormalPrepass);
336        } else {
337            commands
338                .entity(camera)
339                .remove::<DepthPrepass>()
340                .remove::<NormalPrepass>();
341        }
342    }
343}
344
345// A system that updates the help text.
346fn update_help_text(mut text_query: Query<&mut Text>, app_status: Res<AppStatus>) {
347    for mut text in text_query.iter_mut() {
348        *text = app_status.create_text();
349    }
350}
351
352impl AppStatus {
353    // Creates and returns help text reflecting the app status.
354    fn create_text(&self) -> Text {
355        format!(
356            "\
357{} (1) Switch from high-poly to low-poly based on camera distance
358{} (2) Show only the high-poly model
359{} (3) Show only the low-poly model
360Press 1, 2, or 3 to switch which model is shown
361Press WASD or use the mouse wheel to move the camera
362Press Space to {} the prepass",
363            if self.show_one_model_only.is_none() {
364                '>'
365            } else {
366                ' '
367            },
368            if self.show_one_model_only == Some(MainModel::HighPoly) {
369                '>'
370            } else {
371                ' '
372            },
373            if self.show_one_model_only == Some(MainModel::LowPoly) {
374                '>'
375            } else {
376                ' '
377            },
378            if self.prepass { "disable" } else { "enable" }
379        )
380        .into()
381    }
382}