Skip to main content

light_probe_blending/
light_probe_blending.rs

1//! Demonstrates blending between multiple reflection probes.
2//!
3//! This example shows a reflective sphere that moves between two rooms, each of
4//! which contains a reflection probe with a falloff range. Bevy performs a
5//! blend between the two reflection probes as the sphere moves.
6
7use std::f32::consts::{FRAC_PI_4, PI};
8
9use crate::radio::{feathers_option_buttons, main_ui_node_scene, RadioButtonOptionValue};
10use crate::theme::basic_example_theme;
11use bevy::{
12    camera::Hdr,
13    camera_controller::free_camera::{FreeCamera, FreeCameraPlugin},
14    color::palettes::css::{CORNFLOWER_BLUE, CRIMSON, TAN, WHITE},
15    feathers::{theme::UiTheme, FeathersPlugins},
16    input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll},
17    light::ParallaxCorrection,
18    math::ops::{atan2, cos, sin},
19    prelude::*,
20    ui_widgets::{radio_self_update, ValueChange},
21    window::{CursorGrabMode, CursorOptions},
22};
23
24#[path = "../helpers/radio.rs"]
25mod radio;
26
27#[path = "../helpers/theme.rs"]
28mod theme;
29
30/// The settings that the user has chosen.
31#[derive(Resource, Default)]
32struct AppStatus {
33    /// Whether the gizmos that show the boundaries of the light probe regions
34    /// are to be shown.
35    gizmos_enabled: GizmosEnabled,
36    /// Which object to show: either a reflective sphere or a reflective prism.
37    object_to_show: ObjectToShow,
38    /// Whether to use an orbital pan/zoom camera or a free camera.
39    camera_mode: CameraMode,
40}
41
42/// Whether the gizmos that show the boundaries of the light probe regions are
43/// to be shown.
44#[derive(Clone, Copy, Default, PartialEq)]
45enum GizmosEnabled {
46    /// The gizmos are shown.
47    #[default]
48    On,
49    /// The gizmos are hidden.
50    Off,
51}
52
53/// Which reflective object to show.
54#[derive(Clone, Copy, Default, PartialEq)]
55enum ObjectToShow {
56    /// A reflective sphere that moves between rooms.
57    #[default]
58    Sphere,
59    /// A reflective prism that is static and stretches across the length of the
60    /// two rooms.
61    Prism,
62}
63
64/// How the user can control the camera.
65#[derive(Clone, Copy, Default, PartialEq)]
66enum CameraMode {
67    /// The camera is a pan/zoom orbital camera controllable with dragging and
68    /// the mouse wheel.
69    #[default]
70    Orbit,
71    /// The camera is a free camera controllable by clicking and dragging and
72    /// using the WASDEQ controls.
73    Free,
74}
75
76/// A marker component for the reflective sphere.
77#[derive(Clone, Copy, Component, Debug)]
78struct ReflectiveSphere;
79
80/// A marker component for the reflective prism.
81#[derive(Clone, Copy, Component, Debug)]
82struct ReflectivePrism;
83
84/// A marker component for the help text at the top of the screen.
85#[derive(Clone, Copy, Component, Debug)]
86struct HelpText;
87
88/// The speed at which the sphere moves, as a ratio of the total distance it
89/// travels to seconds.
90///
91/// Specifically, the value of 0.3 means that it moves 3/10 of the way to the
92/// other side per second.
93const SPHERE_MOVEMENT_SPEED: f32 = 0.3;
94
95/// The side length of each room, in meters.
96const ROOM_SIDE_LENGTH: f32 = 10.0;
97
98/// The number of meters that separates the center of each room.
99const ROOM_SEPARATION: f32 = 11.0;
100
101/// The side length of the light probe cube, in meters.
102const LIGHT_PROBE_SIDE_LENGTH: f32 = 15.0;
103
104/// The distance over which the light probe fades out, expressed as a fraction
105/// of the side length of the probe.
106const LIGHT_PROBE_FALLOFF: f32 = 0.5;
107
108/// The side length of the simulated reflected area for each light probe,
109/// specified as a half-extent in light probe space.
110///
111/// We want this side length, in world space, to be half of the world-space room
112/// side length. Since the light probe is scaled by `LIGHT_PROBE_SIDE_LENGTH`,
113/// we divide the room side length by the light probe side length to get this
114/// value, and multiply by 0.5 to convert from a full extent to a half-extent.
115/// That way, when Bevy applies the `LIGHT_PROBE_SIDE_LENGTH` scale, the light
116/// probe side length factor cancels, and we're left with a parallax correction
117/// side length of `ROOM_SIDE_LENGTH` in world space.
118///
119/// A small epsilon value of 0.01 is added in order to ensure that the light
120/// probe parallax bounds encompass the entire room. Otherwise, unsightly
121/// Z-fighting can occur on the room walls.
122const LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH: f32 =
123    ROOM_SIDE_LENGTH / LIGHT_PROBE_SIDE_LENGTH * 0.5 + 0.01;
124
125/// The number of radians of inclination (pitch) that one pixel of mouse
126/// movement corresponds to.
127const CAMERA_ORBIT_SPEED_INCLINATION: f32 = 0.003;
128
129/// The number of radians of azumith (yaw) that one pixel of mouse movement
130/// corresponds to.
131const CAMERA_ORBIT_SPEED_AZIMUTH: f32 = 0.004;
132
133/// The number of meters that one line of mouse scroll corresponds to.
134const CAMERA_ZOOM_SPEED: f32 = 0.15;
135
136/// Information about the orbital pan/zoom camera.
137///
138/// These are in [spherical coordinates].
139///
140/// [spherical coordinates]: https://en.wikipedia.org/wiki/Spherical_coordinate_system
141#[derive(Component)]
142struct OrbitCamera {
143    /// The distance between the camera and the sphere, in meters.
144    radius: f32,
145    /// The camera latitude in radians, relative to the sphere.
146    inclination: f32,
147    /// The camera longitude in radians, relative to the sphere.
148    azimuth: f32,
149}
150
151/// The brightness of the light probe.
152const LIGHT_PROBE_INTENSITY: f32 = 500.0;
153
154/// The entry point.
155fn main() {
156    App::new()
157        .add_plugins((
158            DefaultPlugins.set(WindowPlugin {
159                primary_window: Some(Window {
160                    title: "Bevy Light Probe Blending Example".into(),
161                    ..default()
162                }),
163                ..default()
164            }),
165            FeathersPlugins,
166        ))
167        .add_plugins(FreeCameraPlugin)
168        .insert_resource(UiTheme(basic_example_theme(Color::WHITE)))
169        .init_resource::<AppStatus>()
170        .add_systems(Startup, setup)
171        .add_systems(Update, (move_sphere, orbit_camera).chain())
172        .add_observer(handle_gizmos_enabled_change)
173        .add_observer(handle_object_to_show_change)
174        .add_observer(handle_camera_mode_change)
175        .add_observer(radio_self_update)
176        .add_systems(Update, draw_gizmos)
177        .run();
178}
179
180/// Performs initial setup of the scene.
181fn setup(
182    mut commands: Commands,
183    asset_server: Res<AssetServer>,
184    mut meshes: ResMut<Assets<Mesh>>,
185    mut materials: ResMut<Assets<StandardMaterial>>,
186    mut gizmo_config_store: ResMut<GizmoConfigStore>,
187) {
188    adjust_gizmo_settings(&mut gizmo_config_store);
189
190    let reflective_material = create_reflective_material(&mut materials);
191
192    spawn_camera(&mut commands);
193    spawn_gltf_scene(&mut commands, &asset_server);
194    spawn_reflective_sphere(&mut commands, &mut meshes, reflective_material.clone());
195    spawn_reflective_prism(&mut commands, &mut meshes, reflective_material);
196    spawn_light_probes(&mut commands, &asset_server);
197    spawn_buttons(&mut commands);
198    spawn_help_text(&mut commands);
199}
200
201/// Adjusts the gizmo settings so that the gizmos appear on top of all other
202/// geometry.
203///
204/// If we didn't do this, then the rooms would cover up many of the gizmos.
205fn adjust_gizmo_settings(gizmo_config_store: &mut GizmoConfigStore) {
206    for (_, gizmo_config, _) in &mut gizmo_config_store.iter_mut() {
207        gizmo_config.depth_bias = -1.0;
208    }
209}
210
211/// Creates the perfectly-reflective material that the sphere and prism use.
212fn create_reflective_material(
213    materials: &mut Assets<StandardMaterial>,
214) -> Handle<StandardMaterial> {
215    materials.add(StandardMaterial {
216        base_color: WHITE.into(),
217        metallic: 1.0,
218        reflectance: 1.0,
219        perceptual_roughness: 0.0,
220        ..default()
221    })
222}
223
224/// Spawns the orbital pan/zoom camera.
225fn spawn_camera(commands: &mut Commands) {
226    commands.spawn((
227        Camera3d::default(),
228        Transform::IDENTITY,
229        Hdr,
230        OrbitCamera {
231            radius: 3.0,
232            inclination: 7.0 * FRAC_PI_4,
233            azimuth: FRAC_PI_4,
234        },
235    ));
236}
237
238/// Spawns the glTF scene that contains the two rooms.
239fn spawn_gltf_scene(commands: &mut Commands, asset_server: &AssetServer) {
240    commands.spawn(WorldAssetRoot(asset_server.load(
241        GltfAssetLabel::Scene(0).from_asset(get_web_asset_url("two_rooms.glb")),
242    )));
243}
244
245/// Spawns the reflective sphere, creating its mesh in the process.
246fn spawn_reflective_sphere(
247    commands: &mut Commands,
248    meshes: &mut Assets<Mesh>,
249    material: Handle<StandardMaterial>,
250) {
251    // Create a mesh.
252    let sphere = meshes.add(Sphere::default().mesh().uv(32, 18));
253
254    // Spawn the sphere.
255    commands.spawn((
256        Mesh3d(sphere),
257        MeshMaterial3d(material),
258        Transform::IDENTITY,
259        ReflectiveSphere,
260    ));
261}
262
263/// Spawns the reflective prism, creating its mesh in the process.
264///
265/// The reflective prism starts invisible, but the user can toggle it on and off
266/// as desired.
267fn spawn_reflective_prism(
268    commands: &mut Commands,
269    meshes: &mut Assets<Mesh>,
270    material: Handle<StandardMaterial>,
271) {
272    // Create a mesh.
273    let cube = meshes.add(
274        Cuboid {
275            half_size: vec3(2.0, 1.0, 10.0),
276        }
277        .mesh()
278        .build()
279        // We use flat normals so that the surface appears flat, not curved.
280        .with_duplicated_vertices()
281        .with_computed_flat_normals(),
282    );
283
284    // Spawn the cube.
285    commands.spawn((
286        Mesh3d(cube),
287        MeshMaterial3d(material),
288        Transform::from_xyz(0.0, -4.0, -5.5),
289        ReflectivePrism,
290        Visibility::Hidden,
291    ));
292}
293
294/// Spawns the two light probes, one for each room.
295fn spawn_light_probes(commands: &mut Commands, asset_server: &AssetServer) {
296    // Spawn the first room's light probe.
297    commands.spawn((
298        LightProbe {
299            falloff: Vec3::splat(LIGHT_PROBE_FALLOFF),
300        },
301        EnvironmentMapLight {
302            diffuse_map: asset_server.load(get_web_asset_url("diffuse_room1.ktx2")),
303            specular_map: asset_server.load(get_web_asset_url("specular_room1.ktx2")),
304            intensity: LIGHT_PROBE_INTENSITY,
305            ..default()
306        },
307        Transform::from_scale(vec3(1.0, -1.0, 1.0) * LIGHT_PROBE_SIDE_LENGTH)
308            .with_rotation(Quat::from_rotation_x(PI)),
309        ParallaxCorrection::Custom(Vec3::splat(LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH)),
310    ));
311
312    // Spawn the second room's light probe.
313    commands.spawn((
314        LightProbe {
315            falloff: Vec3::splat(LIGHT_PROBE_FALLOFF),
316        },
317        EnvironmentMapLight {
318            diffuse_map: asset_server.load(get_web_asset_url("diffuse_room2.ktx2")),
319            specular_map: asset_server.load(get_web_asset_url("specular_room2.ktx2")),
320            intensity: LIGHT_PROBE_INTENSITY,
321            ..default()
322        },
323        Transform::from_scale(vec3(1.0, -1.0, 1.0) * LIGHT_PROBE_SIDE_LENGTH)
324            .with_rotation(Quat::from_rotation_x(PI))
325            .with_translation(vec3(0.0, 0.0, -ROOM_SEPARATION)),
326        ParallaxCorrection::Custom(Vec3::splat(LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH)),
327    ));
328}
329
330/// Spawns the radio buttons at the bottom of the screen.
331fn spawn_buttons(commands: &mut Commands) {
332    commands.spawn_scene(bsn! {
333        @main_ui_node_scene()
334        Children [
335            @feathers_option_buttons(
336                "Gizmos",
337                &[(GizmosEnabled::On, "On"), (GizmosEnabled::Off, "Off"),],
338                0,
339            )
340            --
341            @feathers_option_buttons(
342                "Object to Show",
343                &[
344                    (ObjectToShow::Sphere, "Sphere"),
345                    (ObjectToShow::Prism, "Prism"),
346                ],
347                0,
348            )
349            --
350            @feathers_option_buttons(
351                "Camera Mode",
352                &[(CameraMode::Orbit, "Orbit"), (CameraMode::Free, "Free"),],
353                0,
354            )
355        ]
356    });
357}
358
359/// Spawns the help text at the top of the screen.
360fn spawn_help_text(commands: &mut Commands) {
361    commands.spawn((
362        Text::new(""),
363        Node {
364            position_type: PositionType::Absolute,
365            top: px(12),
366            left: px(12),
367            ..default()
368        },
369        HelpText,
370    ));
371}
372
373/// Moves the sphere a bit every frame.
374fn move_sphere(mut spheres: Query<&mut Transform, With<ReflectiveSphere>>, time: Res<Time>) {
375    let Some(t) = SmoothStepCurve
376        .ping_pong()
377        .unwrap()
378        .forever()
379        .unwrap()
380        .sample(time.elapsed_secs() * SPHERE_MOVEMENT_SPEED)
381    else {
382        return;
383    };
384    for mut sphere_transform in &mut spheres {
385        sphere_transform.translation.z = -ROOM_SEPARATION * t;
386    }
387}
388
389/// Processes requests from the user to move the camera.
390fn orbit_camera(
391    mut cameras: Query<(&mut Transform, &mut OrbitCamera)>,
392    spheres: Query<&Transform, (With<ReflectiveSphere>, Without<OrbitCamera>)>,
393    mouse_buttons: Res<ButtonInput<MouseButton>>,
394    mouse_motion: Res<AccumulatedMouseMotion>,
395    mouse_scroll: Res<AccumulatedMouseScroll>,
396) {
397    // Grab the sphere transform.
398    let Some(sphere_transform) = spheres.iter().next() else {
399        return;
400    };
401
402    for (mut camera_transform, mut orbit_camera) in &mut cameras {
403        // Only pan if the left mouse button is pressed.
404        if mouse_buttons.pressed(MouseButton::Left) {
405            let delta = mouse_motion.delta;
406            orbit_camera.azimuth -= delta.x * CAMERA_ORBIT_SPEED_AZIMUTH;
407            orbit_camera.inclination += delta.y * CAMERA_ORBIT_SPEED_INCLINATION;
408        }
409
410        // Zooming doesn't require a mouse button press, as it uses the mouse
411        // wheel.
412        orbit_camera.radius =
413            (orbit_camera.radius - CAMERA_ZOOM_SPEED * mouse_scroll.delta.y).max(0.01);
414
415        // Calculate the new translation using the [spherical coordinates
416        // formula].
417        //
418        // [spherical coordinates formula]:
419        // https://en.wikipedia.org/wiki/Spherical_coordinate_system#Cartesian_coordinates
420        let new_translation = orbit_camera.radius
421            * vec3(
422                sin(orbit_camera.inclination) * cos(orbit_camera.azimuth),
423                cos(orbit_camera.inclination),
424                sin(orbit_camera.inclination) * sin(orbit_camera.azimuth),
425            );
426
427        // Write in the new transform.
428        *camera_transform =
429            Transform::from_translation(new_translation + sphere_transform.translation)
430                .looking_at(sphere_transform.translation, Vec3::Y);
431    }
432}
433
434/// Handles requests from the user to toggle whether gizmos are enabled.
435/// The `radio_self_update` observer handles setting the `Checked` state on the radio buttons.
436fn handle_gizmos_enabled_change(
437    event: On<ValueChange<Entity>>,
438    new_value_query: Query<&RadioButtonOptionValue<GizmosEnabled>>,
439    mut app_status: ResMut<AppStatus>,
440    mut help_text_query: Query<&mut Text, With<HelpText>>,
441) {
442    let Ok(RadioButtonOptionValue(enabled)) = new_value_query.get(event.value) else {
443        return;
444    };
445    app_status.gizmos_enabled = *enabled;
446
447    set_help_text(&app_status, &mut help_text_query);
448}
449
450/// Handles requests from the user to toggle which object is showing.
451/// The `radio_self_update` observer handles setting the `Checked` state on the radio buttons.
452fn handle_object_to_show_change(
453    event: On<ValueChange<Entity>>,
454    new_value_query: Query<&RadioButtonOptionValue<ObjectToShow>>,
455    mut app_status: ResMut<AppStatus>,
456    mut spheres_query: Query<&mut Visibility, (With<ReflectiveSphere>, Without<ReflectivePrism>)>,
457    mut prisms_query: Query<&mut Visibility, (With<ReflectivePrism>, Without<ReflectiveSphere>)>,
458) {
459    let Ok(RadioButtonOptionValue(object_to_show)) = new_value_query.get(event.value) else {
460        return;
461    };
462    app_status.object_to_show = *object_to_show;
463
464    for mut sphere_visibility in &mut spheres_query {
465        *sphere_visibility = match app_status.object_to_show {
466            ObjectToShow::Sphere => Visibility::Inherited,
467            ObjectToShow::Prism => Visibility::Hidden,
468        }
469    }
470    for mut prism_visibility in &mut prisms_query {
471        *prism_visibility = match app_status.object_to_show {
472            ObjectToShow::Sphere => Visibility::Hidden,
473            ObjectToShow::Prism => Visibility::Inherited,
474        }
475    }
476}
477
478/// Handles requests from the user to switch the camera mode when the user clicks on one of the
479/// corresponding radio buttons.
480fn handle_camera_mode_change(
481    event: On<ValueChange<Entity>>,
482    new_value_query: Query<&RadioButtonOptionValue<CameraMode>>,
483    mut commands: Commands,
484    cameras_query: Query<(Entity, &Transform), With<Camera3d>>,
485    sphere_query: Query<&Transform, (With<ReflectiveSphere>, Without<Camera3d>)>,
486    mut help_text_query: Query<&mut Text, With<HelpText>>,
487    mut windows_query: Query<&mut CursorOptions>,
488    mut app_status: ResMut<AppStatus>,
489) {
490    let Some(sphere_transform) = sphere_query.iter().next() else {
491        return;
492    };
493
494    let Ok(RadioButtonOptionValue(camera_mode)) = new_value_query.get(event.value) else {
495        return;
496    };
497
498    app_status.camera_mode = *camera_mode;
499
500    match app_status.camera_mode {
501        CameraMode::Orbit => {
502            for (camera_entity, camera_transform) in &cameras_query {
503                // Convert from Cartesian coordinates back to spherical
504                // coordinates.
505                let relative_camera_position =
506                    camera_transform.translation - sphere_transform.translation;
507                let radius = relative_camera_position.length();
508                let inclination = atan2(
509                    relative_camera_position.xz().length() / radius,
510                    relative_camera_position.y / radius,
511                );
512                let azimuth = atan2(
513                    relative_camera_position.z * relative_camera_position.xz().length_recip(),
514                    relative_camera_position.x * relative_camera_position.xz().length_recip(),
515                );
516
517                commands
518                    .entity(camera_entity)
519                    .remove::<FreeCamera>()
520                    .insert(OrbitCamera {
521                        radius,
522                        inclination,
523                        azimuth,
524                    });
525            }
526        }
527
528        CameraMode::Free => {
529            for (camera_entity, _) in &cameras_query {
530                commands
531                    .entity(camera_entity)
532                    .remove::<OrbitCamera>()
533                    .insert(FreeCamera::default());
534            }
535        }
536    }
537
538    set_help_text(&app_status, &mut help_text_query);
539
540    // Reset the cursor grab mode, because the free camera controller may
541    // have enabled it, and we don't want the cursor to disappear.
542    for mut cursor_options in &mut windows_query {
543        cursor_options.grab_mode = CursorGrabMode::None;
544        cursor_options.visible = true;
545    }
546}
547
548/// Draws gizmos that show the boundaries of the various boxes associated with
549/// the light probes in the scene.
550fn draw_gizmos(
551    light_probes: Query<(&LightProbe, &ParallaxCorrection, &Transform)>,
552    app_status: Res<AppStatus>,
553    mut gizmos: Gizmos,
554) {
555    // If the user has gizmos disabled, bail.
556    if matches!(app_status.gizmos_enabled, GizmosEnabled::Off) {
557        return;
558    }
559
560    for (light_probe, parallax_correction, transform) in &light_probes {
561        // Draw light probe bounds.
562        gizmos.cube(*transform, TAN);
563
564        // Draw light probe falloff.
565        gizmos.cube(
566            Transform {
567                scale: transform.scale * (Vec3::ONE - light_probe.falloff),
568                ..*transform
569            },
570            CRIMSON,
571        );
572
573        // Draw light probe parallax correction bounds.
574        if let ParallaxCorrection::Custom(parallax_correction_bounds) = *parallax_correction {
575            gizmos.cube(
576                Transform {
577                    scale: transform.scale * parallax_correction_bounds,
578                    ..*transform
579                },
580                CORNFLOWER_BLUE,
581            );
582        }
583    }
584}
585
586/// Updates the help text at the top of the screen to reflect a change in camera
587/// or gizmo application settings.
588fn set_help_text(app_status: &AppStatus, help_text_query: &mut Query<&mut Text, With<HelpText>>) {
589    for mut ui_text in help_text_query {
590        let mut help_text = String::new();
591        match app_status.camera_mode {
592            CameraMode::Orbit => {
593                help_text.push_str(
594                    "Click and drag to orbit the camera\nUse the mouse wheel to zoom the camera\n",
595                );
596            }
597            CameraMode::Free => {
598                help_text.push_str(
599                    "Click and drag to rotate the camera\nUse WASDEQ to move the camera\n",
600                );
601            }
602        }
603
604        help_text.push('\n');
605
606        if matches!(app_status.gizmos_enabled, GizmosEnabled::On) {
607            help_text.push_str(
608                "\
609Gizmos:
610Tan: Light probe bounds
611Red: Light probe falloff bounds
612Blue: Parallax correction bounds",
613            );
614        }
615
616        *ui_text = Text::new(help_text);
617    }
618}
619
620/// Returns the GitHub download URL for the given asset.
621///
622/// The files are expected to be in the `light_probe_blending` directory in the
623/// [repository].
624///
625/// [repository]: https://github.com/bevyengine/bevy_asset_files
626fn get_web_asset_url(name: &str) -> String {
627    format!(
628        "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/\
629light_probe_blending/{}",
630        name
631    )
632}