1use std::f32::consts::{FRAC_PI_4, PI};
8
9use bevy::{
10 camera::Hdr,
11 camera_controller::free_camera::{self, FreeCamera, FreeCameraPlugin},
12 color::palettes::css::{CORNFLOWER_BLUE, CRIMSON, TAN, WHITE},
13 input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll},
14 light::ParallaxCorrection,
15 math::ops::{atan2, cos, sin},
16 prelude::*,
17 window::{CursorGrabMode, CursorOptions},
18};
19
20use crate::widgets::{WidgetClickEvent, WidgetClickSender};
21
22#[path = "../helpers/widgets.rs"]
23mod widgets;
24
25#[derive(Resource, Default)]
27struct AppStatus {
28 gizmos_enabled: GizmosEnabled,
31 object_to_show: ObjectToShow,
33 camera_mode: CameraMode,
35}
36
37#[derive(Clone, Copy, Default, PartialEq)]
40enum GizmosEnabled {
41 #[default]
43 On,
44 Off,
46}
47
48#[derive(Clone, Copy, Default, PartialEq)]
50enum ObjectToShow {
51 #[default]
53 Sphere,
54 Prism,
57}
58
59#[derive(Clone, Copy, Default, PartialEq)]
61enum CameraMode {
62 #[default]
65 Orbit,
66 Free,
69}
70
71#[derive(Clone, Copy, Component, Debug)]
73struct ReflectiveSphere;
74
75#[derive(Clone, Copy, Component, Debug)]
77struct ReflectivePrism;
78
79#[derive(Clone, Copy, Component, Debug)]
81struct HelpText;
82
83const SPHERE_MOVEMENT_SPEED: f32 = 0.3;
89
90const ROOM_SIDE_LENGTH: f32 = 10.0;
92
93const ROOM_SEPARATION: f32 = 11.0;
95
96const LIGHT_PROBE_SIDE_LENGTH: f32 = 15.0;
98
99const LIGHT_PROBE_FALLOFF: f32 = 0.5;
102
103const LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH: f32 =
118 ROOM_SIDE_LENGTH / LIGHT_PROBE_SIDE_LENGTH * 0.5 + 0.01;
119
120const CAMERA_ORBIT_SPEED_INCLINATION: f32 = 0.003;
123
124const CAMERA_ORBIT_SPEED_AZIMUTH: f32 = 0.004;
127
128const CAMERA_ZOOM_SPEED: f32 = 0.15;
130
131#[derive(Component)]
137struct OrbitCamera {
138 radius: f32,
140 inclination: f32,
142 azimuth: f32,
144}
145
146const LIGHT_PROBE_INTENSITY: f32 = 500.0;
148
149fn main() {
151 App::new()
152 .add_plugins(DefaultPlugins.set(WindowPlugin {
153 primary_window: Some(Window {
154 title: "Bevy Light Probe Blending Example".into(),
155 ..default()
156 }),
157 ..default()
158 }))
159 .add_plugins(FreeCameraPlugin)
160 .init_resource::<AppStatus>()
161 .add_message::<WidgetClickEvent<GizmosEnabled>>()
162 .add_message::<WidgetClickEvent<ObjectToShow>>()
163 .add_message::<WidgetClickEvent<CameraMode>>()
164 .add_systems(Startup, setup)
165 .add_systems(Update, (move_sphere, orbit_camera).chain())
166 .add_systems(
167 Update,
168 (
169 widgets::handle_ui_interactions::<GizmosEnabled>,
170 handle_gizmos_enabled_change,
171 )
172 .chain(),
173 )
174 .add_systems(
175 Update,
176 (
177 widgets::handle_ui_interactions::<ObjectToShow>,
178 handle_object_to_show_change,
179 )
180 .chain(),
181 )
182 .add_systems(
183 Update,
184 (
185 widgets::handle_ui_interactions::<CameraMode>,
186 handle_camera_mode_change,
187 )
188 .chain()
189 .after(free_camera::run_freecamera_controller),
190 )
191 .add_systems(
192 Update,
193 update_radio_buttons
194 .after(widgets::handle_ui_interactions::<GizmosEnabled>)
195 .after(widgets::handle_ui_interactions::<ObjectToShow>)
196 .after(widgets::handle_ui_interactions::<CameraMode>),
197 )
198 .add_systems(Update, draw_gizmos)
199 .run();
200}
201
202fn setup(
204 mut commands: Commands,
205 asset_server: Res<AssetServer>,
206 mut meshes: ResMut<Assets<Mesh>>,
207 mut materials: ResMut<Assets<StandardMaterial>>,
208 mut gizmo_config_store: ResMut<GizmoConfigStore>,
209) {
210 adjust_gizmo_settings(&mut gizmo_config_store);
211
212 let reflective_material = create_reflective_material(&mut materials);
213
214 spawn_camera(&mut commands);
215 spawn_gltf_scene(&mut commands, &asset_server);
216 spawn_reflective_sphere(&mut commands, &mut meshes, reflective_material.clone());
217 spawn_reflective_prism(&mut commands, &mut meshes, reflective_material);
218 spawn_light_probes(&mut commands, &asset_server);
219 spawn_buttons(&mut commands);
220 spawn_help_text(&mut commands);
221}
222
223fn adjust_gizmo_settings(gizmo_config_store: &mut GizmoConfigStore) {
228 for (_, gizmo_config, _) in &mut gizmo_config_store.iter_mut() {
229 gizmo_config.depth_bias = -1.0;
230 }
231}
232
233fn create_reflective_material(
235 materials: &mut Assets<StandardMaterial>,
236) -> Handle<StandardMaterial> {
237 materials.add(StandardMaterial {
238 base_color: WHITE.into(),
239 metallic: 1.0,
240 reflectance: 1.0,
241 perceptual_roughness: 0.0,
242 ..default()
243 })
244}
245
246fn spawn_camera(commands: &mut Commands) {
248 commands.spawn((
249 Camera3d::default(),
250 Transform::IDENTITY,
251 Hdr,
252 OrbitCamera {
253 radius: 3.0,
254 inclination: 7.0 * FRAC_PI_4,
255 azimuth: FRAC_PI_4,
256 },
257 ));
258}
259
260fn spawn_gltf_scene(commands: &mut Commands, asset_server: &AssetServer) {
262 commands.spawn(WorldAssetRoot(asset_server.load(
263 GltfAssetLabel::Scene(0).from_asset(get_web_asset_url("two_rooms.glb")),
264 )));
265}
266
267fn spawn_reflective_sphere(
269 commands: &mut Commands,
270 meshes: &mut Assets<Mesh>,
271 material: Handle<StandardMaterial>,
272) {
273 let sphere = meshes.add(Sphere::default().mesh().uv(32, 18));
275
276 commands.spawn((
278 Mesh3d(sphere),
279 MeshMaterial3d(material),
280 Transform::IDENTITY,
281 ReflectiveSphere,
282 ));
283}
284
285fn spawn_reflective_prism(
290 commands: &mut Commands,
291 meshes: &mut Assets<Mesh>,
292 material: Handle<StandardMaterial>,
293) {
294 let cube = meshes.add(
296 Cuboid {
297 half_size: vec3(2.0, 1.0, 10.0),
298 }
299 .mesh()
300 .build()
301 .with_duplicated_vertices()
303 .with_computed_flat_normals(),
304 );
305
306 commands.spawn((
308 Mesh3d(cube),
309 MeshMaterial3d(material),
310 Transform::from_xyz(0.0, -4.0, -5.5),
311 ReflectivePrism,
312 Visibility::Hidden,
313 ));
314}
315
316fn spawn_light_probes(commands: &mut Commands, asset_server: &AssetServer) {
318 commands.spawn((
320 LightProbe {
321 falloff: Vec3::splat(LIGHT_PROBE_FALLOFF),
322 },
323 EnvironmentMapLight {
324 diffuse_map: asset_server.load(get_web_asset_url("diffuse_room1.ktx2")),
325 specular_map: asset_server.load(get_web_asset_url("specular_room1.ktx2")),
326 intensity: LIGHT_PROBE_INTENSITY,
327 ..default()
328 },
329 Transform::from_scale(vec3(1.0, -1.0, 1.0) * LIGHT_PROBE_SIDE_LENGTH)
330 .with_rotation(Quat::from_rotation_x(PI)),
331 ParallaxCorrection::Custom(Vec3::splat(LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH)),
332 ));
333
334 commands.spawn((
336 LightProbe {
337 falloff: Vec3::splat(LIGHT_PROBE_FALLOFF),
338 },
339 EnvironmentMapLight {
340 diffuse_map: asset_server.load(get_web_asset_url("diffuse_room2.ktx2")),
341 specular_map: asset_server.load(get_web_asset_url("specular_room2.ktx2")),
342 intensity: LIGHT_PROBE_INTENSITY,
343 ..default()
344 },
345 Transform::from_scale(vec3(1.0, -1.0, 1.0) * LIGHT_PROBE_SIDE_LENGTH)
346 .with_rotation(Quat::from_rotation_x(PI))
347 .with_translation(vec3(0.0, 0.0, -ROOM_SEPARATION)),
348 ParallaxCorrection::Custom(Vec3::splat(LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH)),
349 ));
350}
351
352fn spawn_buttons(commands: &mut Commands) {
354 commands.spawn((
355 widgets::main_ui_node(),
356 children![
357 widgets::option_buttons(
358 "Gizmos",
359 &[(GizmosEnabled::On, "On"), (GizmosEnabled::Off, "Off"),]
360 ),
361 widgets::option_buttons(
362 "Object to Show",
363 &[
364 (ObjectToShow::Sphere, "Sphere"),
365 (ObjectToShow::Prism, "Prism"),
366 ]
367 ),
368 widgets::option_buttons(
369 "Camera Mode",
370 &[(CameraMode::Orbit, "Orbit"), (CameraMode::Free, "Free"),]
371 ),
372 ],
373 ));
374}
375
376fn spawn_help_text(commands: &mut Commands) {
378 commands.spawn((
379 Text::new(""),
380 Node {
381 position_type: PositionType::Absolute,
382 top: px(12),
383 left: px(12),
384 ..default()
385 },
386 HelpText,
387 ));
388}
389
390fn move_sphere(mut spheres: Query<&mut Transform, With<ReflectiveSphere>>, time: Res<Time>) {
392 let Some(t) = SmoothStepCurve
393 .ping_pong()
394 .unwrap()
395 .forever()
396 .unwrap()
397 .sample(time.elapsed_secs() * SPHERE_MOVEMENT_SPEED)
398 else {
399 return;
400 };
401 for mut sphere_transform in &mut spheres {
402 sphere_transform.translation.z = -ROOM_SEPARATION * t;
403 }
404}
405
406fn orbit_camera(
408 mut cameras: Query<(&mut Transform, &mut OrbitCamera)>,
409 spheres: Query<&Transform, (With<ReflectiveSphere>, Without<OrbitCamera>)>,
410 mouse_buttons: Res<ButtonInput<MouseButton>>,
411 mouse_motion: Res<AccumulatedMouseMotion>,
412 mouse_scroll: Res<AccumulatedMouseScroll>,
413) {
414 let Some(sphere_transform) = spheres.iter().next() else {
416 return;
417 };
418
419 for (mut camera_transform, mut orbit_camera) in &mut cameras {
420 if mouse_buttons.pressed(MouseButton::Left) {
422 let delta = mouse_motion.delta;
423 orbit_camera.azimuth -= delta.x * CAMERA_ORBIT_SPEED_AZIMUTH;
424 orbit_camera.inclination += delta.y * CAMERA_ORBIT_SPEED_INCLINATION;
425 }
426
427 orbit_camera.radius =
430 (orbit_camera.radius - CAMERA_ZOOM_SPEED * mouse_scroll.delta.y).max(0.01);
431
432 let new_translation = orbit_camera.radius
438 * vec3(
439 sin(orbit_camera.inclination) * cos(orbit_camera.azimuth),
440 cos(orbit_camera.inclination),
441 sin(orbit_camera.inclination) * sin(orbit_camera.azimuth),
442 );
443
444 *camera_transform =
446 Transform::from_translation(new_translation + sphere_transform.translation)
447 .looking_at(sphere_transform.translation, Vec3::Y);
448 }
449}
450
451fn handle_gizmos_enabled_change(
454 mut help_text_query: Query<&mut Text, With<HelpText>>,
455 mut app_status: ResMut<AppStatus>,
456 mut messages: MessageReader<WidgetClickEvent<GizmosEnabled>>,
457) {
458 let mut any_changes = false;
459 for message in messages.read() {
460 app_status.gizmos_enabled = **message;
461 any_changes = true;
462 }
463
464 if any_changes {
465 set_help_text(&app_status, &mut help_text_query);
466 }
467}
468
469fn handle_object_to_show_change(
472 mut spheres_query: Query<&mut Visibility, (With<ReflectiveSphere>, Without<ReflectivePrism>)>,
473 mut prisms_query: Query<&mut Visibility, (With<ReflectivePrism>, Without<ReflectiveSphere>)>,
474 mut app_status: ResMut<AppStatus>,
475 mut messages: MessageReader<WidgetClickEvent<ObjectToShow>>,
476) {
477 for message in messages.read() {
478 app_status.object_to_show = **message;
479
480 for mut sphere_visibility in &mut spheres_query {
481 *sphere_visibility = match **message {
482 ObjectToShow::Sphere => Visibility::Inherited,
483 ObjectToShow::Prism => Visibility::Hidden,
484 }
485 }
486 for mut prism_visibility in &mut prisms_query {
487 *prism_visibility = match **message {
488 ObjectToShow::Sphere => Visibility::Hidden,
489 ObjectToShow::Prism => Visibility::Inherited,
490 }
491 }
492 }
493}
494
495fn handle_camera_mode_change(
498 mut commands: Commands,
499 cameras_query: Query<(Entity, &Transform), With<Camera3d>>,
500 sphere_query: Query<&Transform, (With<ReflectiveSphere>, Without<Camera3d>)>,
501 mut help_text_query: Query<&mut Text, With<HelpText>>,
502 mut windows_query: Query<&mut CursorOptions>,
503 mut app_status: ResMut<AppStatus>,
504 mut messages: MessageReader<WidgetClickEvent<CameraMode>>,
505) {
506 let Some(sphere_transform) = sphere_query.iter().next() else {
507 return;
508 };
509
510 let mut any_changes = false;
511 for message in messages.read() {
512 app_status.camera_mode = **message;
513
514 match **message {
515 CameraMode::Orbit => {
516 for (camera_entity, camera_transform) in &cameras_query {
517 let relative_camera_position =
520 camera_transform.translation - sphere_transform.translation;
521 let radius = relative_camera_position.length();
522 let inclination = atan2(
523 relative_camera_position.xz().length() / radius,
524 relative_camera_position.y / radius,
525 );
526 let azimuth = atan2(
527 relative_camera_position.z * relative_camera_position.xz().length_recip(),
528 relative_camera_position.x * relative_camera_position.xz().length_recip(),
529 );
530
531 commands
532 .entity(camera_entity)
533 .remove::<FreeCamera>()
534 .insert(OrbitCamera {
535 radius,
536 inclination,
537 azimuth,
538 });
539 }
540 }
541
542 CameraMode::Free => {
543 for (camera_entity, _) in &cameras_query {
544 commands
545 .entity(camera_entity)
546 .remove::<OrbitCamera>()
547 .insert(FreeCamera::default());
548 }
549 }
550 }
551
552 any_changes = true;
553 }
554
555 if any_changes {
556 set_help_text(&app_status, &mut help_text_query);
557
558 for mut cursor_options in &mut windows_query {
561 cursor_options.grab_mode = CursorGrabMode::None;
562 cursor_options.visible = true;
563 }
564 }
565}
566
567fn update_radio_buttons(
570 mut widgets_query: Query<(
571 Entity,
572 Option<&mut BackgroundColor>,
573 Has<Text>,
574 AnyOf<(
575 &WidgetClickSender<GizmosEnabled>,
576 &WidgetClickSender<ObjectToShow>,
577 &WidgetClickSender<CameraMode>,
578 )>,
579 )>,
580 app_status: Res<AppStatus>,
581 mut text_ui_writer: TextUiWriter,
582) {
583 for (
584 entity,
585 maybe_bg_color,
586 has_text,
587 (maybe_gizmos_enabled, maybe_object_to_show, maybe_camera_mode),
588 ) in &mut widgets_query
589 {
590 let selected = if let Some(sender) = maybe_gizmos_enabled {
591 app_status.gizmos_enabled == **sender
592 } else if let Some(sender) = maybe_object_to_show {
593 app_status.object_to_show == **sender
594 } else if let Some(sender) = maybe_camera_mode {
595 app_status.camera_mode == **sender
596 } else {
597 continue;
598 };
599
600 if let Some(mut bg_color) = maybe_bg_color {
601 widgets::update_ui_radio_button(&mut bg_color, selected);
602 }
603 if has_text {
604 widgets::update_ui_radio_button_text(entity, &mut text_ui_writer, selected);
605 }
606 }
607}
608
609fn draw_gizmos(
612 light_probes: Query<(&LightProbe, &ParallaxCorrection, &Transform)>,
613 app_status: Res<AppStatus>,
614 mut gizmos: Gizmos,
615) {
616 if matches!(app_status.gizmos_enabled, GizmosEnabled::Off) {
618 return;
619 }
620
621 for (light_probe, parallax_correction, transform) in &light_probes {
622 gizmos.cube(*transform, TAN);
624
625 gizmos.cube(
627 Transform {
628 scale: transform.scale * (Vec3::ONE - light_probe.falloff),
629 ..*transform
630 },
631 CRIMSON,
632 );
633
634 if let ParallaxCorrection::Custom(parallax_correction_bounds) = *parallax_correction {
636 gizmos.cube(
637 Transform {
638 scale: transform.scale * parallax_correction_bounds,
639 ..*transform
640 },
641 CORNFLOWER_BLUE,
642 );
643 }
644 }
645}
646
647fn set_help_text(app_status: &AppStatus, help_text_query: &mut Query<&mut Text, With<HelpText>>) {
650 for mut ui_text in help_text_query {
651 let mut help_text = String::new();
652 match app_status.camera_mode {
653 CameraMode::Orbit => {
654 help_text.push_str(
655 "Click and drag to orbit the camera\nUse the mouse wheel to zoom the camera\n",
656 );
657 }
658 CameraMode::Free => {
659 help_text.push_str(
660 "Click and drag to rotate the camera\nUse WASDEQ to move the camera\n",
661 );
662 }
663 }
664
665 help_text.push('\n');
666
667 if matches!(app_status.gizmos_enabled, GizmosEnabled::On) {
668 help_text.push_str(
669 "\
670Gizmos:
671Tan: Light probe bounds
672Red: Light probe falloff bounds
673Blue: Parallax correction bounds",
674 );
675 }
676
677 *ui_text = Text::new(help_text);
678 }
679}
680
681fn get_web_asset_url(name: &str) -> String {
688 format!(
689 "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/\
690light_probe_blending/{}",
691 name
692 )
693}