use std::f32::consts::{FRAC_PI_4, PI};
use bevy::{
camera::Hdr,
camera_controller::free_camera::{self, FreeCamera, FreeCameraPlugin},
color::palettes::css::{CORNFLOWER_BLUE, CRIMSON, TAN, WHITE},
input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll},
light::ParallaxCorrection,
math::ops::{atan2, cos, sin},
prelude::*,
window::{CursorGrabMode, CursorOptions},
};
use crate::widgets::{WidgetClickEvent, WidgetClickSender};
#[path = "../helpers/widgets.rs"]
mod widgets;
#[derive(Resource, Default)]
struct AppStatus {
gizmos_enabled: GizmosEnabled,
object_to_show: ObjectToShow,
camera_mode: CameraMode,
}
#[derive(Clone, Copy, Default, PartialEq)]
enum GizmosEnabled {
#[default]
On,
Off,
}
#[derive(Clone, Copy, Default, PartialEq)]
enum ObjectToShow {
#[default]
Sphere,
Prism,
}
#[derive(Clone, Copy, Default, PartialEq)]
enum CameraMode {
#[default]
Orbit,
Free,
}
#[derive(Clone, Copy, Component, Debug)]
struct ReflectiveSphere;
#[derive(Clone, Copy, Component, Debug)]
struct ReflectivePrism;
#[derive(Clone, Copy, Component, Debug)]
struct HelpText;
const SPHERE_MOVEMENT_SPEED: f32 = 0.3;
const ROOM_SIDE_LENGTH: f32 = 10.0;
const ROOM_SEPARATION: f32 = 11.0;
const LIGHT_PROBE_SIDE_LENGTH: f32 = 15.0;
const LIGHT_PROBE_FALLOFF: f32 = 0.5;
const LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH: f32 =
ROOM_SIDE_LENGTH / LIGHT_PROBE_SIDE_LENGTH * 0.5 + 0.01;
const CAMERA_ORBIT_SPEED_INCLINATION: f32 = 0.003;
const CAMERA_ORBIT_SPEED_AZIMUTH: f32 = 0.004;
const CAMERA_ZOOM_SPEED: f32 = 0.15;
#[derive(Component)]
struct OrbitCamera {
radius: f32,
inclination: f32,
azimuth: f32,
}
const LIGHT_PROBE_INTENSITY: f32 = 500.0;
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Light Probe Blending Example".into(),
..default()
}),
..default()
}))
.add_plugins(FreeCameraPlugin)
.init_resource::<AppStatus>()
.add_message::<WidgetClickEvent<GizmosEnabled>>()
.add_message::<WidgetClickEvent<ObjectToShow>>()
.add_message::<WidgetClickEvent<CameraMode>>()
.add_systems(Startup, setup)
.add_systems(Update, (move_sphere, orbit_camera).chain())
.add_systems(
Update,
(
widgets::handle_ui_interactions::<GizmosEnabled>,
handle_gizmos_enabled_change,
)
.chain(),
)
.add_systems(
Update,
(
widgets::handle_ui_interactions::<ObjectToShow>,
handle_object_to_show_change,
)
.chain(),
)
.add_systems(
Update,
(
widgets::handle_ui_interactions::<CameraMode>,
handle_camera_mode_change,
)
.chain()
.after(free_camera::run_freecamera_controller),
)
.add_systems(
Update,
update_radio_buttons
.after(widgets::handle_ui_interactions::<GizmosEnabled>)
.after(widgets::handle_ui_interactions::<ObjectToShow>)
.after(widgets::handle_ui_interactions::<CameraMode>),
)
.add_systems(Update, draw_gizmos)
.run();
}
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut gizmo_config_store: ResMut<GizmoConfigStore>,
) {
adjust_gizmo_settings(&mut gizmo_config_store);
let reflective_material = create_reflective_material(&mut materials);
spawn_camera(&mut commands);
spawn_gltf_scene(&mut commands, &asset_server);
spawn_reflective_sphere(&mut commands, &mut meshes, reflective_material.clone());
spawn_reflective_prism(&mut commands, &mut meshes, reflective_material);
spawn_light_probes(&mut commands, &asset_server);
spawn_buttons(&mut commands);
spawn_help_text(&mut commands);
}
fn adjust_gizmo_settings(gizmo_config_store: &mut GizmoConfigStore) {
for (_, gizmo_config, _) in &mut gizmo_config_store.iter_mut() {
gizmo_config.depth_bias = -1.0;
}
}
fn create_reflective_material(
materials: &mut Assets<StandardMaterial>,
) -> Handle<StandardMaterial> {
materials.add(StandardMaterial {
base_color: WHITE.into(),
metallic: 1.0,
reflectance: 1.0,
perceptual_roughness: 0.0,
..default()
})
}
fn spawn_camera(commands: &mut Commands) {
commands.spawn((
Camera3d::default(),
Transform::IDENTITY,
Hdr,
OrbitCamera {
radius: 3.0,
inclination: 7.0 * FRAC_PI_4,
azimuth: FRAC_PI_4,
},
));
}
fn spawn_gltf_scene(commands: &mut Commands, asset_server: &AssetServer) {
commands.spawn(SceneRoot(asset_server.load(
GltfAssetLabel::Scene(0).from_asset(get_web_asset_url("two_rooms.glb")),
)));
}
fn spawn_reflective_sphere(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
material: Handle<StandardMaterial>,
) {
let sphere = meshes.add(Sphere::default().mesh().uv(32, 18));
commands.spawn((
Mesh3d(sphere),
MeshMaterial3d(material),
Transform::IDENTITY,
ReflectiveSphere,
));
}
fn spawn_reflective_prism(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
material: Handle<StandardMaterial>,
) {
let cube = meshes.add(
Cuboid {
half_size: vec3(2.0, 1.0, 10.0),
}
.mesh()
.build()
.with_duplicated_vertices()
.with_computed_flat_normals(),
);
commands.spawn((
Mesh3d(cube),
MeshMaterial3d(material),
Transform::from_xyz(0.0, -4.0, -5.5),
ReflectivePrism,
Visibility::Hidden,
));
}
fn spawn_light_probes(commands: &mut Commands, asset_server: &AssetServer) {
commands.spawn((
LightProbe {
falloff: Vec3::splat(LIGHT_PROBE_FALLOFF),
},
EnvironmentMapLight {
diffuse_map: asset_server.load(get_web_asset_url("diffuse_room1.ktx2")),
specular_map: asset_server.load(get_web_asset_url("specular_room1.ktx2")),
intensity: LIGHT_PROBE_INTENSITY,
..default()
},
Transform::from_scale(vec3(1.0, -1.0, 1.0) * LIGHT_PROBE_SIDE_LENGTH)
.with_rotation(Quat::from_rotation_x(PI)),
ParallaxCorrection::Custom(Vec3::splat(LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH)),
));
commands.spawn((
LightProbe {
falloff: Vec3::splat(LIGHT_PROBE_FALLOFF),
},
EnvironmentMapLight {
diffuse_map: asset_server.load(get_web_asset_url("diffuse_room2.ktx2")),
specular_map: asset_server.load(get_web_asset_url("specular_room2.ktx2")),
intensity: LIGHT_PROBE_INTENSITY,
..default()
},
Transform::from_scale(vec3(1.0, -1.0, 1.0) * LIGHT_PROBE_SIDE_LENGTH)
.with_rotation(Quat::from_rotation_x(PI))
.with_translation(vec3(0.0, 0.0, -ROOM_SEPARATION)),
ParallaxCorrection::Custom(Vec3::splat(LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH)),
));
}
fn spawn_buttons(commands: &mut Commands) {
commands.spawn((
widgets::main_ui_node(),
children![
widgets::option_buttons(
"Gizmos",
&[(GizmosEnabled::On, "On"), (GizmosEnabled::Off, "Off"),]
),
widgets::option_buttons(
"Object to Show",
&[
(ObjectToShow::Sphere, "Sphere"),
(ObjectToShow::Prism, "Prism"),
]
),
widgets::option_buttons(
"Camera Mode",
&[(CameraMode::Orbit, "Orbit"), (CameraMode::Free, "Free"),]
),
],
));
}
fn spawn_help_text(commands: &mut Commands) {
commands.spawn((
Text::new(""),
Node {
position_type: PositionType::Absolute,
top: px(12),
left: px(12),
..default()
},
HelpText,
));
}
fn move_sphere(mut spheres: Query<&mut Transform, With<ReflectiveSphere>>, time: Res<Time>) {
let Some(t) = SmoothStepCurve
.ping_pong()
.unwrap()
.forever()
.unwrap()
.sample(time.elapsed_secs() * SPHERE_MOVEMENT_SPEED)
else {
return;
};
for mut sphere_transform in &mut spheres {
sphere_transform.translation.z = -ROOM_SEPARATION * t;
}
}
fn orbit_camera(
mut cameras: Query<(&mut Transform, &mut OrbitCamera)>,
spheres: Query<&Transform, (With<ReflectiveSphere>, Without<OrbitCamera>)>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
mouse_motion: Res<AccumulatedMouseMotion>,
mouse_scroll: Res<AccumulatedMouseScroll>,
) {
let Some(sphere_transform) = spheres.iter().next() else {
return;
};
for (mut camera_transform, mut orbit_camera) in &mut cameras {
if mouse_buttons.pressed(MouseButton::Left) {
let delta = mouse_motion.delta;
orbit_camera.azimuth -= delta.x * CAMERA_ORBIT_SPEED_AZIMUTH;
orbit_camera.inclination += delta.y * CAMERA_ORBIT_SPEED_INCLINATION;
}
orbit_camera.radius =
(orbit_camera.radius - CAMERA_ZOOM_SPEED * mouse_scroll.delta.y).max(0.01);
let new_translation = orbit_camera.radius
* vec3(
sin(orbit_camera.inclination) * cos(orbit_camera.azimuth),
cos(orbit_camera.inclination),
sin(orbit_camera.inclination) * sin(orbit_camera.azimuth),
);
*camera_transform =
Transform::from_translation(new_translation + sphere_transform.translation)
.looking_at(sphere_transform.translation, Vec3::Y);
}
}
fn handle_gizmos_enabled_change(
mut help_text_query: Query<&mut Text, With<HelpText>>,
mut app_status: ResMut<AppStatus>,
mut messages: MessageReader<WidgetClickEvent<GizmosEnabled>>,
) {
let mut any_changes = false;
for message in messages.read() {
app_status.gizmos_enabled = **message;
any_changes = true;
}
if any_changes {
set_help_text(&app_status, &mut help_text_query);
}
}
fn handle_object_to_show_change(
mut spheres_query: Query<&mut Visibility, (With<ReflectiveSphere>, Without<ReflectivePrism>)>,
mut prisms_query: Query<&mut Visibility, (With<ReflectivePrism>, Without<ReflectiveSphere>)>,
mut app_status: ResMut<AppStatus>,
mut messages: MessageReader<WidgetClickEvent<ObjectToShow>>,
) {
for message in messages.read() {
app_status.object_to_show = **message;
for mut sphere_visibility in &mut spheres_query {
*sphere_visibility = match **message {
ObjectToShow::Sphere => Visibility::Inherited,
ObjectToShow::Prism => Visibility::Hidden,
}
}
for mut prism_visibility in &mut prisms_query {
*prism_visibility = match **message {
ObjectToShow::Sphere => Visibility::Hidden,
ObjectToShow::Prism => Visibility::Inherited,
}
}
}
}
fn handle_camera_mode_change(
mut commands: Commands,
cameras_query: Query<(Entity, &Transform), With<Camera3d>>,
sphere_query: Query<&Transform, (With<ReflectiveSphere>, Without<Camera3d>)>,
mut help_text_query: Query<&mut Text, With<HelpText>>,
mut windows_query: Query<&mut CursorOptions>,
mut app_status: ResMut<AppStatus>,
mut messages: MessageReader<WidgetClickEvent<CameraMode>>,
) {
let Some(sphere_transform) = sphere_query.iter().next() else {
return;
};
let mut any_changes = false;
for message in messages.read() {
app_status.camera_mode = **message;
match **message {
CameraMode::Orbit => {
for (camera_entity, camera_transform) in &cameras_query {
let relative_camera_position =
camera_transform.translation - sphere_transform.translation;
let radius = relative_camera_position.length();
let inclination = atan2(
relative_camera_position.xz().length() / radius,
relative_camera_position.y / radius,
);
let azimuth = atan2(
relative_camera_position.z * relative_camera_position.xz().length_recip(),
relative_camera_position.x * relative_camera_position.xz().length_recip(),
);
commands
.entity(camera_entity)
.remove::<FreeCamera>()
.insert(OrbitCamera {
radius,
inclination,
azimuth,
});
}
}
CameraMode::Free => {
for (camera_entity, _) in &cameras_query {
commands
.entity(camera_entity)
.remove::<OrbitCamera>()
.insert(FreeCamera::default());
}
}
}
any_changes = true;
}
if any_changes {
set_help_text(&app_status, &mut help_text_query);
for mut cursor_options in &mut windows_query {
cursor_options.grab_mode = CursorGrabMode::None;
cursor_options.visible = true;
}
}
}
fn update_radio_buttons(
mut widgets_query: Query<(
Entity,
Option<&mut BackgroundColor>,
Has<Text>,
AnyOf<(
&WidgetClickSender<GizmosEnabled>,
&WidgetClickSender<ObjectToShow>,
&WidgetClickSender<CameraMode>,
)>,
)>,
app_status: Res<AppStatus>,
mut text_ui_writer: TextUiWriter,
) {
for (
entity,
maybe_bg_color,
has_text,
(maybe_gizmos_enabled, maybe_object_to_show, maybe_camera_mode),
) in &mut widgets_query
{
let selected = if let Some(sender) = maybe_gizmos_enabled {
app_status.gizmos_enabled == **sender
} else if let Some(sender) = maybe_object_to_show {
app_status.object_to_show == **sender
} else if let Some(sender) = maybe_camera_mode {
app_status.camera_mode == **sender
} else {
continue;
};
if let Some(mut bg_color) = maybe_bg_color {
widgets::update_ui_radio_button(&mut bg_color, selected);
}
if has_text {
widgets::update_ui_radio_button_text(entity, &mut text_ui_writer, selected);
}
}
}
fn draw_gizmos(
light_probes: Query<(&LightProbe, &ParallaxCorrection, &Transform)>,
app_status: Res<AppStatus>,
mut gizmos: Gizmos,
) {
if matches!(app_status.gizmos_enabled, GizmosEnabled::Off) {
return;
}
for (light_probe, parallax_correction, transform) in &light_probes {
gizmos.cube(*transform, TAN);
gizmos.cube(
Transform {
scale: transform.scale * (Vec3::ONE - light_probe.falloff),
..*transform
},
CRIMSON,
);
if let ParallaxCorrection::Custom(parallax_correction_bounds) = *parallax_correction {
gizmos.cube(
Transform {
scale: transform.scale * parallax_correction_bounds,
..*transform
},
CORNFLOWER_BLUE,
);
}
}
}
fn set_help_text(app_status: &AppStatus, help_text_query: &mut Query<&mut Text, With<HelpText>>) {
for mut ui_text in help_text_query {
let mut help_text = String::new();
match app_status.camera_mode {
CameraMode::Orbit => {
help_text.push_str(
"Click and drag to orbit the camera\nUse the mouse wheel to zoom the camera\n",
);
}
CameraMode::Free => {
help_text.push_str(
"Click and drag to rotate the camera\nUse WASDEQ to move the camera\n",
);
}
}
help_text.push('\n');
if matches!(app_status.gizmos_enabled, GizmosEnabled::On) {
help_text.push_str(
"\
Gizmos:
Tan: Light probe bounds
Red: Light probe falloff bounds
Blue: Parallax correction bounds",
);
}
*ui_text = Text::new(help_text);
}
}
fn get_web_asset_url(name: &str) -> String {
format!(
"https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/\
light_probe_blending/{}",
name
)
}