docs/bevy/examples/3d/light_probe_blending.rs 21.2 K · 632 lines · raw · history

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
7 use std::f32::consts::{FRAC_PI_4, PI};
8
9 use crate::radio::{feathers_option_buttons, main_ui_node_scene, RadioButtonOptionValue};
10 use crate::theme::basic_example_theme;
11 use 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"]
25 mod radio;
26
27 #[path = "../helpers/theme.rs"]
28 mod theme;
29
30 /// The settings that the user has chosen.
31 #[derive(Resource, Default)]
32 struct 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)]
45 enum 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)]
55 enum 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)]
66 enum 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)]
78 struct ReflectiveSphere;
79
80 /// A marker component for the reflective prism.
81 #[derive(Clone, Copy, Component, Debug)]
82 struct ReflectivePrism;
83
84 /// A marker component for the help text at the top of the screen.
85 #[derive(Clone, Copy, Component, Debug)]
86 struct 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.
93 const SPHERE_MOVEMENT_SPEED: f32 = 0.3;
94
95 /// The side length of each room, in meters.
96 const ROOM_SIDE_LENGTH: f32 = 10.0;
97
98 /// The number of meters that separates the center of each room.
99 const ROOM_SEPARATION: f32 = 11.0;
100
101 /// The side length of the light probe cube, in meters.
102 const 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.
106 const 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.
122 const 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.
127 const 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.
131 const CAMERA_ORBIT_SPEED_AZIMUTH: f32 = 0.004;
132
133 /// The number of meters that one line of mouse scroll corresponds to.
134 const 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)]
142 struct 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.
152 const LIGHT_PROBE_INTENSITY: f32 = 500.0;
153
154 /// The entry point.
155 fn 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.
181 fn 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.
205 fn 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.
212 fn 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.
225 fn 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.
239 fn 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.
246 fn 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.
267 fn 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.
295 fn 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.
331 fn 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.
360 fn 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.
374 fn 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.
390 fn 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.
436 fn 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.
452 fn 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.
480 fn 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.
550 fn 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.
588 fn 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 "\
609 Gizmos:
610 Tan: Light probe bounds
611 Red: Light probe falloff bounds
612 Blue: 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
626 fn get_web_asset_url(name: &str) -> String {
627 format!(
628 "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/\
629 light_probe_blending/{}",
630 name
631 )
632 }