Skip to main content

light_textures/
light_textures.rs

1//! Demonstrates light textures, which modulate light sources.
2
3use std::f32::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_4, PI};
4use std::fmt::{self, Formatter};
5
6use bevy::ui_widgets::radio_self_update;
7use bevy::{
8    camera::primitives::CubemapLayout,
9    color::palettes::css::{SILVER, YELLOW},
10    feathers::{
11        controls::{FeathersNumberInput, FeathersRadio, NumberInputPrecision, NumberInputValue},
12        theme::UiTheme,
13        FeathersPlugins,
14    },
15    input::mouse::AccumulatedMouseMotion,
16    light::{DirectionalLightTexture, NotShadowCaster, PointLightTexture, SpotLightTexture},
17    pbr::decal,
18    prelude::*,
19    render::renderer::{RenderAdapter, RenderDevice},
20    ui::Checked,
21    ui_widgets::ValueChange,
22};
23use light_consts::lux::{AMBIENT_DAYLIGHT, CLEAR_SUNRISE};
24use number_input_f32::number_input_f32;
25use ops::{acos, cos, sin};
26use radio::{feathers_option_buttons, main_ui_node_scene, RadioButtonOptionValue};
27
28#[path = "../helpers/radio.rs"]
29mod radio;
30
31#[path = "../helpers/number_input_f32.rs"]
32mod number_input_f32;
33
34#[path = "../helpers/theme.rs"]
35mod theme;
36
37/// The speed at which the cube rotates, in radians per frame.
38const CUBE_ROTATION_SPEED: f32 = 0.02;
39
40/// The speed at which the selection can be moved, in spherical coordinate
41/// radians per mouse unit.
42const MOVE_SPEED: f32 = 0.008;
43
44/// Various settings for the demo.
45#[derive(Resource, Default)]
46struct AppStatus {
47    /// The object that will be moved, scaled, or rotated when the mouse is
48    /// dragged.
49    selection: Selection,
50}
51
52/// The object that will be moved, scaled, or rotated when the mouse is dragged.
53#[derive(Clone, Copy, Component, Default, PartialEq)]
54enum Selection {
55    /// The camera.
56    ///
57    /// The camera can only be moved, not scaled or rotated.
58    #[default]
59    Camera,
60    /// The spotlight, which uses a torch-like light texture
61    SpotLight,
62    /// The point light, which uses a light texture cubemap constructed from the faces mesh
63    PointLight,
64    /// The directional light, which uses a caustic-like texture
65    DirectionalLight,
66}
67
68impl fmt::Display for Selection {
69    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
70        match *self {
71            Selection::Camera => f.write_str("camera"),
72            Selection::SpotLight => f.write_str("spotlight"),
73            Selection::PointLight => f.write_str("point light"),
74            Selection::DirectionalLight => f.write_str("directional light"),
75        }
76    }
77}
78
79/// Indicates which aspect of the light the `FeathersNumberInput` in the app influences.
80#[derive(Clone, Copy, Component, Default, PartialEq, Debug)]
81enum AppNumberInput {
82    /// The mouse scales the current selection.
83    ///
84    /// This only applies to lights, not cameras.
85    #[default]
86    Scale,
87    /// The mouse rotates the current selection around its local Z axis.
88    ///
89    /// This only applies to lights, not cameras.
90    Roll,
91}
92
93/// A component that stores the base transformation scale of a light.
94#[derive(Clone, Copy, Component, Default, PartialEq, Debug)]
95struct BaseScale(Vec3);
96
97/// A marker component for the help text in the top left corner of the window.
98#[derive(Clone, Copy, Component)]
99struct HelpText;
100
101/// Entry point.
102fn main() {
103    App::new()
104        .add_plugins((
105            DefaultPlugins.set(WindowPlugin {
106                primary_window: Some(Window {
107                    title: "Bevy Light Textures Example".into(),
108                    ..default()
109                }),
110                ..default()
111            }),
112            FeathersPlugins,
113        ))
114        .insert_resource(UiTheme(theme::basic_example_theme(Color::WHITE)))
115        .init_resource::<AppStatus>()
116        .add_systems(Startup, setup)
117        .add_systems(Update, draw_gizmos)
118        .add_systems(Update, rotate_cube)
119        .add_systems(Update, hide_shadows)
120        .add_observer(handle_selection_change)
121        .add_observer(handle_visibility_change)
122        .add_observer(radio_self_update)
123        .add_observer(handle_value_change_number_input)
124        .add_observer(handle_drag_as_movement)
125        .add_systems(Update, update_directional_light)
126        .add_systems(Update, update_help_text)
127        .run();
128}
129
130/// Creates the scene.
131fn setup(
132    mut commands: Commands,
133    asset_server: Res<AssetServer>,
134    app_status: Res<AppStatus>,
135    render_device: Res<RenderDevice>,
136    render_adapter: Res<RenderAdapter>,
137    mut meshes: ResMut<Assets<Mesh>>,
138    mut materials: ResMut<Assets<StandardMaterial>>,
139) {
140    // Error out if clustered decals (and so light textures) aren't supported on the current platform.
141    if !decal::clustered::clustered_decals_are_usable(&render_device, &render_adapter) {
142        error!("Light textures aren't usable on this platform.");
143        commands.write_message(AppExit::error());
144    }
145
146    spawn_cubes(&mut commands, &mut meshes, &mut materials);
147    spawn_camera(&mut commands);
148    spawn_light(&mut commands, &asset_server);
149    spawn_buttons(&mut commands);
150    spawn_help_text(&mut commands, &app_status);
151    spawn_light_textures(&mut commands, &asset_server, &mut meshes, &mut materials);
152}
153
154#[derive(Component)]
155struct Rotate;
156
157/// Spawns the cube onto which the decals are projected.
158fn spawn_cubes(
159    commands: &mut Commands,
160    meshes: &mut Assets<Mesh>,
161    materials: &mut Assets<StandardMaterial>,
162) {
163    // Rotate the cube a bit just to make it more interesting.
164    let mut transform = Transform::IDENTITY;
165    transform.rotate_y(FRAC_PI_3);
166
167    commands.spawn((
168        Mesh3d(meshes.add(Cuboid::new(3.0, 3.0, 3.0))),
169        MeshMaterial3d(materials.add(StandardMaterial {
170            base_color: SILVER.into(),
171            ..default()
172        })),
173        transform,
174        Rotate,
175    ));
176
177    commands.spawn((
178        Mesh3d(meshes.add(Cuboid::new(-13.0, -13.0, -13.0))),
179        MeshMaterial3d(materials.add(StandardMaterial {
180            base_color: SILVER.into(),
181            ..default()
182        })),
183        transform,
184    ));
185}
186
187/// Spawns the directional light.
188fn spawn_light(commands: &mut Commands, asset_server: &AssetServer) {
189    commands.spawn((
190        Visibility::Hidden,
191        Transform::from_xyz(8.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y),
192        BaseScale(Vec3::ONE),
193        Selection::DirectionalLight,
194        children![(
195            DirectionalLight {
196                illuminance: AMBIENT_DAYLIGHT,
197                ..default()
198            },
199            DirectionalLightTexture {
200                image: asset_server.load("lightmaps/caustic_directional_texture.png"),
201                tiled: true,
202            },
203            Visibility::Visible,
204        )],
205    ));
206}
207
208/// Spawns the camera.
209fn spawn_camera(commands: &mut Commands) {
210    commands
211        .spawn(Camera3d::default())
212        .insert(Transform::from_xyz(0.0, 2.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y))
213        // Tag the camera with `Selection::Camera`.
214        .insert(Selection::Camera);
215}
216
217fn spawn_light_textures(
218    commands: &mut Commands,
219    asset_server: &AssetServer,
220    meshes: &mut Assets<Mesh>,
221    materials: &mut Assets<StandardMaterial>,
222) {
223    commands.spawn((
224        SpotLight {
225            color: Color::srgb(1.0, 1.0, 0.8),
226            intensity: 10e6,
227            outer_angle: 0.25,
228            inner_angle: 0.25,
229            shadow_maps_enabled: true,
230            ..default()
231        },
232        Transform::from_translation(Vec3::new(6.0, 1.0, 2.0)).looking_at(Vec3::ZERO, Vec3::Y),
233        BaseScale(Vec3::ONE),
234        SpotLightTexture {
235            image: asset_server.load("lightmaps/torch_spotlight_texture.png"),
236        },
237        Visibility::Inherited,
238        Selection::SpotLight,
239    ));
240
241    commands.spawn((
242        Visibility::Hidden,
243        Transform::from_translation(Vec3::new(0.0, 1.8, 0.01)).with_scale(Vec3::splat(0.25)),
244        BaseScale(Vec3::splat(0.25)),
245        Selection::PointLight,
246        children![
247            WorldAssetRoot(
248                asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/Faces/faces.glb")),
249            ),
250            (
251                Mesh3d(meshes.add(Sphere::new(1.0))),
252                MeshMaterial3d(materials.add(StandardMaterial {
253                    emissive: Color::srgb(0.0, 0.0, 300.0).to_linear(),
254                    ..default()
255                })),
256            ),
257            (
258                PointLight {
259                    color: Color::srgb(0.0, 0.0, 1.0),
260                    intensity: 1e6,
261                    shadow_maps_enabled: true,
262                    ..default()
263                },
264                PointLightTexture {
265                    image: asset_server.load("lightmaps/faces_pointlight_texture_blurred.png"),
266                    cubemap_layout: CubemapLayout::CrossVertical,
267                },
268            )
269        ],
270    ));
271}
272
273/// Spawns the buttons at the bottom of the screen.
274fn spawn_buttons(commands: &mut Commands) {
275    commands.spawn_scene(bsn! {
276        @main_ui_node_scene()
277        Children [
278            @feathers_option_buttons(
279                "Drag to Move",
280                &[
281                    (Selection::Camera, "Camera"),
282                    (Selection::SpotLight, "Spotlight"),
283                    (Selection::PointLight, "Point Light"),
284                    (Selection::DirectionalLight, "Directional Light"),
285                ],
286                0,
287            )
288            --
289            // Camera's visibility cannot be toggled.
290            Visibility::Hidden
291            @feathers_option_buttons(
292                "Visibility",
293                &[
294                    (Visibility::Inherited, "Show"),
295                    (Visibility::Hidden, "Hide"),
296                ],
297                0,
298            )
299            --
300            // The number inputs start off hidden because Camera is selected first.
301            Visibility::Hidden
302            @number_input_f32("Scale Multiplier", Some(AppNumberInput::Scale), 1.0, NumberInputPrecision(2), 0.01..=5.)
303            --
304            Visibility::Hidden
305            // + epsilon and next_down are used since roll recalculation likes to switch between -PI and PI upon recalculating roll.
306            @number_input_f32("Roll (-π to π)", Some(AppNumberInput::Roll), 0.0, NumberInputPrecision(2), -PI + f32::EPSILON ..=PI.next_down())
307        ]
308    });
309}
310
311/// Observer that handles changes to number inputs.
312/// The number inputs affect the scale or rotation of the currently selected light, if any.
313fn handle_value_change_number_input(
314    value_change: On<ValueChange<f32>>,
315    mut commands: Commands,
316    number_input_q: Query<&AppNumberInput, With<FeathersNumberInput>>,
317    app_status: ResMut<AppStatus>,
318    mut selections: Query<(
319        &mut Transform,
320        Option<&mut SpotLight>,
321        &BaseScale,
322        &Selection,
323    )>,
324) {
325    if app_status.selection == Selection::Camera {
326        return;
327    }
328    if let Ok(app_number_input) = number_input_q.get(value_change.source) {
329        for (mut transform, spotlight_option, base_scale, selection) in &mut selections {
330            if app_status.selection != *selection {
331                continue;
332            }
333            match app_number_input {
334                AppNumberInput::Scale => {
335                    transform.scale = base_scale.0 * value_change.value;
336
337                    if let Some(mut spotlight) = spotlight_option {
338                        // The spotlight's base outer and inner angle are 0.25.
339                        spotlight.outer_angle = (0.25 * value_change.value).clamp(0.01, FRAC_PI_4);
340                        spotlight.inner_angle = spotlight.outer_angle;
341                    }
342                }
343                AppNumberInput::Roll => {
344                    let (yaw, pitch, mut _roll) = transform.rotation.to_euler(EulerRot::YXZ);
345                    // Keep yaw and pitch the same, but change the roll.
346                    transform.rotation =
347                        Quat::from_euler(EulerRot::YXZ, yaw, pitch, value_change.value);
348                }
349            }
350        }
351        commands
352            .entity(value_change.source)
353            .insert(NumberInputValue::F32(value_change.value));
354    }
355}
356
357/// Handles requests from the user to change the selected object to control and expose
358/// the appropriate controls.
359/// The `radio_self_update` observer handles setting the `Checked` state on the radio buttons.
360fn handle_selection_change(
361    event: On<ValueChange<Entity>>,
362    new_value_query: Query<&RadioButtonOptionValue<Selection>>,
363    mut app_status: ResMut<AppStatus>,
364    mut commands: Commands,
365    selections: Query<(&Transform, &BaseScale, &Visibility, &Selection)>,
366    visibility_radio: Query<
367        (Entity, &ChildOf, &RadioButtonOptionValue<Visibility>),
368        With<FeathersRadio>,
369    >,
370    number_inputs: Query<(Entity, &ChildOf, &AppNumberInput)>,
371) {
372    let Ok(RadioButtonOptionValue(selection)) = new_value_query.get(event.value) else {
373        return;
374    };
375    app_status.selection = *selection;
376
377    // Update the visibility of the visibility-setting radio group.
378    if app_status.selection == Selection::Camera
379        && let Some((_, child_of, _)) = visibility_radio.iter().next()
380    {
381        commands
382            .entity(child_of.parent())
383            .insert(Visibility::Hidden);
384    } else if let Some((_, child_of, _)) = visibility_radio.iter().next() {
385        commands
386            .entity(child_of.parent())
387            .insert(Visibility::Inherited);
388
389        // Add the `Checked` component to the correct visibility option for this selected light.
390        for (_transform, _base_scale, selected_visibility, _selection) in selections
391            .iter()
392            .filter(|&(_, _, _, selection)| *selection == app_status.selection)
393        {
394            for (entity, _, visibility_option_value) in visibility_radio.iter() {
395                if visibility_option_value.0 == *selected_visibility {
396                    commands.entity(entity).insert(Checked);
397                } else {
398                    commands.entity(entity).remove::<Checked>();
399                }
400            }
401        }
402    }
403
404    // Update the visibility of the scale and roll number inputs so that they aren't visible
405    // if the camera is selected.
406    for (input_entity, child_of, app_number_input) in number_inputs.iter() {
407        match app_status.selection {
408            Selection::Camera => {
409                commands
410                    .entity(child_of.parent())
411                    .insert(Visibility::Hidden);
412            }
413            _ => {
414                commands
415                    .entity(child_of.parent())
416                    .insert(Visibility::Inherited);
417
418                // Update the input values to the correct ones for this light.
419                for (transform, base_scale, _, _) in selections
420                    .iter()
421                    .filter(|&(_, _, _, selection)| *selection == app_status.selection)
422                {
423                    if AppNumberInput::Scale == *app_number_input {
424                        // Scale should be uniformly multiplied.
425                        let scale_multiplier = transform.scale.x / base_scale.0.x;
426                        commands
427                            .entity(input_entity)
428                            .insert(NumberInputValue::F32(scale_multiplier));
429                    } else {
430                        let roll = transform.rotation.to_euler(EulerRot::YXZ).2;
431                        commands
432                            .entity(input_entity)
433                            .insert(NumberInputValue::F32(roll));
434                    }
435                }
436            }
437        };
438    }
439}
440
441/// Handles requests from the user to change the selected object to control and expose
442/// the appropriate controls.
443/// The `radio_self_update` observer handles setting the `Checked` state on the radio buttons.
444fn handle_visibility_change(
445    event: On<ValueChange<Entity>>,
446    new_value_query: Query<&RadioButtonOptionValue<Visibility>>,
447    app_status: Res<AppStatus>,
448    mut visibility_q: Query<(&mut Visibility, &Selection)>,
449) {
450    let Ok(RadioButtonOptionValue(new_visibility)) = new_value_query.get(event.value) else {
451        return;
452    };
453
454    for (mut visibility, selection) in visibility_q.iter_mut() {
455        if *selection == app_status.selection {
456            *visibility = *new_visibility;
457        }
458    }
459}
460
461/// Spawns the help text at the top of the screen.
462fn spawn_help_text(commands: &mut Commands, app_status: &AppStatus) {
463    commands.spawn((
464        Text::new(create_help_string(app_status)),
465        Node {
466            position_type: PositionType::Absolute,
467            top: px(12),
468            left: px(12),
469            ..default()
470        },
471        HelpText,
472    ));
473}
474
475/// Draws the outlines that show the bounds of the spotlight.
476fn draw_gizmos(mut gizmos: Gizmos, spotlight: Query<(&GlobalTransform, &SpotLight, &Visibility)>) {
477    if let Ok((global_transform, spotlight, visibility)) = spotlight.single()
478        && visibility != Visibility::Hidden
479    {
480        gizmos.primitive_3d(
481            &Cone::new(7.0 * spotlight.outer_angle, 7.0),
482            Isometry3d {
483                rotation: global_transform.rotation() * Quat::from_rotation_x(FRAC_PI_2),
484                translation: global_transform.translation_vec3a() * 0.5,
485            },
486            YELLOW,
487        );
488    }
489}
490
491/// Rotates the cube a bit every frame.
492fn rotate_cube(mut meshes: Query<&mut Transform, With<Rotate>>) {
493    for mut transform in &mut meshes {
494        transform.rotate_y(CUBE_ROTATION_SPEED);
495    }
496}
497
498/// Hide shadows on all meshes except the main cube
499fn hide_shadows(
500    mut commands: Commands,
501    meshes: Query<Entity, (With<Mesh3d>, Without<NotShadowCaster>, Without<Rotate>)>,
502) {
503    for ent in &meshes {
504        commands.entity(ent).insert(NotShadowCaster);
505    }
506}
507
508/// Process a drag event that moves the selected object.
509fn handle_drag_as_movement(
510    event: On<PointerDrag>,
511    parent_q: Query<&ChildOf>,
512    number_input_q: Query<(), With<FeathersNumberInput>>,
513    mut selections: Query<(&mut Transform, &Selection)>,
514    mouse_motion: Res<AccumulatedMouseMotion>,
515    app_status: Res<AppStatus>,
516) {
517    // If we are currently dragging the number input, do not interpret it as movement
518    // of the selection.
519    if parent_q
520        .iter_ancestors(event.entity)
521        .any(|parent| number_input_q.contains(parent))
522    {
523        return;
524    }
525
526    for (mut transform, selection) in &mut selections {
527        if app_status.selection != *selection {
528            continue;
529        }
530
531        // use simple movement for the point light
532        if *selection == Selection::PointLight {
533            transform.translation +=
534                (mouse_motion.delta * Vec2::new(1.0, -1.0) * MOVE_SPEED).extend(0.0);
535            return;
536        }
537
538        let position = transform.translation;
539
540        // Convert to spherical coordinates.
541        let radius = position.length();
542        let mut theta = acos(position.y / radius);
543        let mut phi = position.z.signum() * acos(position.x * position.xz().length_recip());
544
545        // Camera movement is the inverse of object movement.
546        let (phi_factor, theta_factor) = match *selection {
547            Selection::Camera => (1.0, -1.0),
548            _ => (-1.0, 1.0),
549        };
550
551        // Adjust the spherical coordinates. Clamp the inclination to (0, π).
552        phi += phi_factor * mouse_motion.delta.x * MOVE_SPEED;
553        theta = f32::clamp(
554            theta + theta_factor * mouse_motion.delta.y * MOVE_SPEED,
555            0.001,
556            PI - 0.001,
557        );
558
559        // Convert spherical coordinates back to Cartesian coordinates.
560        transform.translation =
561            radius * vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi));
562
563        // Look at the center, but preserve the previous roll angle.
564        let roll = transform.rotation.to_euler(EulerRot::YXZ).2;
565        transform.look_at(Vec3::ZERO, Vec3::Y);
566        let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ);
567        transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll);
568    }
569}
570
571/// Creates the help string at the top left of the screen.
572fn create_help_string(app_status: &AppStatus) -> String {
573    if app_status.selection == Selection::Camera {
574        format!("Click and drag to move {}.", app_status.selection)
575    } else {
576        format!(
577            "Click and drag to move/scale/rotate {}.\n\
578            To scale/rotate, start the drag within the corresponding number input.\n\
579            To move, start the drag anywhere else in the example.",
580            app_status.selection
581        )
582    }
583}
584
585/// Updates the help text in the top left of the screen to reflect the current
586/// selection and drag mode.
587fn update_help_text(mut help_text: Query<&mut Text, With<HelpText>>, app_status: Res<AppStatus>) {
588    for mut text in &mut help_text {
589        text.0 = create_help_string(&app_status);
590    }
591}
592
593fn update_directional_light(
594    mut commands: Commands,
595    asset_server: Res<AssetServer>,
596    selections: Query<(&Selection, &Visibility)>,
597    mut light: Query<(
598        Entity,
599        &mut DirectionalLight,
600        Option<&DirectionalLightTexture>,
601    )>,
602) {
603    let directional_visible = selections
604        .iter()
605        .filter(|(selection, _)| **selection == Selection::DirectionalLight)
606        .any(|(_, visibility)| visibility != Visibility::Hidden);
607    let any_texture_light_visible = selections
608        .iter()
609        .filter(|(selection, _)| {
610            **selection == Selection::PointLight || **selection == Selection::SpotLight
611        })
612        .any(|(_, visibility)| visibility != Visibility::Hidden);
613
614    let (entity, mut light, maybe_texture) = light
615        .single_mut()
616        .expect("there should be a single directional light");
617
618    if directional_visible {
619        light.illuminance = AMBIENT_DAYLIGHT;
620        if maybe_texture.is_none() {
621            commands.entity(entity).insert(DirectionalLightTexture {
622                image: asset_server.load("lightmaps/caustic_directional_texture.png"),
623                tiled: true,
624            });
625        }
626    } else if any_texture_light_visible {
627        light.illuminance = CLEAR_SUNRISE;
628        if maybe_texture.is_some() {
629            commands.entity(entity).remove::<DirectionalLightTexture>();
630        }
631    } else {
632        light.illuminance = AMBIENT_DAYLIGHT;
633        if maybe_texture.is_some() {
634            commands.entity(entity).remove::<DirectionalLightTexture>();
635        }
636    }
637}