mixed_lighting/
mixed_lighting.rs

1//! Demonstrates how to combine baked and dynamic lighting.
2
3use bevy::{
4    gltf::GltfMeshName,
5    pbr::Lightmap,
6    picking::{backend::HitData, pointer::PointerInteraction},
7    prelude::*,
8    scene::SceneInstanceReady,
9};
10
11use crate::widgets::{RadioButton, RadioButtonText, WidgetClickEvent, WidgetClickSender};
12
13#[path = "../helpers/widgets.rs"]
14mod widgets;
15
16/// How bright the lightmaps are.
17const LIGHTMAP_EXPOSURE: f32 = 600.0;
18
19/// How far above the ground the sphere's origin is when moved, in scene units.
20const SPHERE_OFFSET: f32 = 0.2;
21
22/// The settings that the user has currently chosen for the app.
23#[derive(Clone, Default, Resource)]
24struct AppStatus {
25    /// The lighting mode that the user currently has set: baked, mixed, or
26    /// real-time.
27    lighting_mode: LightingMode,
28}
29
30/// The type of lighting to use in the scene.
31#[derive(Clone, Copy, PartialEq, Default)]
32enum LightingMode {
33    /// All light is computed ahead of time; no lighting takes place at runtime.
34    ///
35    /// In this mode, the sphere can't be moved, as the light shining on it was
36    /// precomputed. On the plus side, the sphere has indirect lighting in this
37    /// mode, as the red hue on the bottom of the sphere demonstrates.
38    Baked,
39
40    /// All light for the static objects is computed ahead of time, but the
41    /// light for the dynamic sphere is computed at runtime.
42    ///
43    /// In this mode, the sphere can be moved, and the light will be computed
44    /// for it as you do so. The sphere loses indirect illumination; notice the
45    /// lack of a red hue at the base of the sphere. However, the rest of the
46    /// scene has indirect illumination. Note also that the sphere doesn't cast
47    /// a shadow on the static objects in this mode, because shadows are part of
48    /// the lighting computation.
49    MixedDirect,
50
51    /// Indirect light for the static objects is computed ahead of time, and
52    /// direct light for all objects is computed at runtime.
53    ///
54    /// In this mode, the sphere can be moved, and the light will be computed
55    /// for it as you do so. The sphere loses indirect illumination; notice the
56    /// lack of a red hue at the base of the sphere. However, the rest of the
57    /// scene has indirect illumination. The sphere does cast a shadow on
58    /// objects in this mode, because the direct light for all objects is being
59    /// computed dynamically.
60    #[default]
61    MixedIndirect,
62
63    /// Light is computed at runtime for all objects.
64    ///
65    /// In this mode, no lightmaps are used at all. All objects are dynamically
66    /// lit, which provides maximum flexibility. However, the downside is that
67    /// global illumination is lost; note that the base of the sphere isn't red
68    /// as it is in baked mode.
69    RealTime,
70}
71
72/// A message that's written whenever the user changes the lighting mode.
73///
74/// This is also written when the scene loads for the first time.
75#[derive(Clone, Copy, Default, Message)]
76struct LightingModeChanged;
77
78#[derive(Clone, Copy, Component, Debug)]
79struct HelpText;
80
81/// The name of every static object in the scene that has a lightmap, as well as
82/// the UV rect of its lightmap.
83///
84/// Storing this as an array and doing a linear search through it is rather
85/// inefficient, but we do it anyway for clarity's sake.
86static LIGHTMAPS: [(&str, Rect); 5] = [
87    (
88        "Plane",
89        uv_rect_opengl(Vec2::splat(0.026), Vec2::splat(0.710)),
90    ),
91    (
92        "SheenChair_fabric",
93        uv_rect_opengl(vec2(0.7864, 0.02377), vec2(0.1910, 0.1912)),
94    ),
95    (
96        "SheenChair_label",
97        uv_rect_opengl(vec2(0.275, -0.016), vec2(0.858, 0.486)),
98    ),
99    (
100        "SheenChair_metal",
101        uv_rect_opengl(vec2(0.998, 0.506), vec2(-0.029, -0.067)),
102    ),
103    (
104        "SheenChair_wood",
105        uv_rect_opengl(vec2(0.787, 0.257), vec2(0.179, 0.177)),
106    ),
107];
108
109static SPHERE_UV_RECT: Rect = uv_rect_opengl(vec2(0.788, 0.484), Vec2::splat(0.062));
110
111/// The initial position of the sphere.
112///
113/// When the user sets the light mode to [`LightingMode::Baked`], we reset the
114/// position to this point.
115const INITIAL_SPHERE_POSITION: Vec3 = vec3(0.0, 0.5233223, 0.0);
116
117fn main() {
118    App::new()
119        .add_plugins(DefaultPlugins.set(WindowPlugin {
120            primary_window: Some(Window {
121                title: "Bevy Mixed Lighting Example".into(),
122                ..default()
123            }),
124            ..default()
125        }))
126        .add_plugins(MeshPickingPlugin)
127        .insert_resource(AmbientLight {
128            color: ClearColor::default().0,
129            brightness: 10000.0,
130            affects_lightmapped_meshes: true,
131        })
132        .init_resource::<AppStatus>()
133        .add_message::<WidgetClickEvent<LightingMode>>()
134        .add_message::<LightingModeChanged>()
135        .add_systems(Startup, setup)
136        .add_systems(Update, update_lightmaps)
137        .add_systems(Update, update_directional_light)
138        .add_systems(Update, make_sphere_nonpickable)
139        .add_systems(Update, update_radio_buttons)
140        .add_systems(Update, handle_lighting_mode_change)
141        .add_systems(Update, widgets::handle_ui_interactions::<LightingMode>)
142        .add_systems(Update, reset_sphere_position)
143        .add_systems(Update, move_sphere)
144        .add_systems(Update, adjust_help_text)
145        .run();
146}
147
148/// Creates the scene.
149fn setup(mut commands: Commands, asset_server: Res<AssetServer>, app_status: Res<AppStatus>) {
150    spawn_camera(&mut commands);
151    spawn_scene(&mut commands, &asset_server);
152    spawn_buttons(&mut commands);
153    spawn_help_text(&mut commands, &app_status);
154}
155
156/// Spawns the 3D camera.
157fn spawn_camera(commands: &mut Commands) {
158    commands
159        .spawn(Camera3d::default())
160        .insert(Transform::from_xyz(-0.7, 0.7, 1.0).looking_at(vec3(0.0, 0.3, 0.0), Vec3::Y));
161}
162
163/// Spawns the scene.
164///
165/// The scene is loaded from a glTF file.
166fn spawn_scene(commands: &mut Commands, asset_server: &AssetServer) {
167    commands
168        .spawn(SceneRoot(
169            asset_server.load(
170                GltfAssetLabel::Scene(0)
171                    .from_asset("models/MixedLightingExample/MixedLightingExample.gltf"),
172            ),
173        ))
174        .observe(
175            |_: On<SceneInstanceReady>,
176             mut lighting_mode_changed_writer: MessageWriter<LightingModeChanged>| {
177                // When the scene loads, send a `LightingModeChanged` event so
178                // that we set up the lightmaps.
179                lighting_mode_changed_writer.write(LightingModeChanged);
180            },
181        );
182}
183
184/// Spawns the buttons that allow the user to change the lighting mode.
185fn spawn_buttons(commands: &mut Commands) {
186    commands.spawn((
187        widgets::main_ui_node(),
188        children![widgets::option_buttons(
189            "Lighting",
190            &[
191                (LightingMode::Baked, "Baked"),
192                (LightingMode::MixedDirect, "Mixed (Direct)"),
193                (LightingMode::MixedIndirect, "Mixed (Indirect)"),
194                (LightingMode::RealTime, "Real-Time"),
195            ],
196        )],
197    ));
198}
199
200/// Spawns the help text at the top of the window.
201fn spawn_help_text(commands: &mut Commands, app_status: &AppStatus) {
202    commands.spawn((
203        create_help_text(app_status),
204        Node {
205            position_type: PositionType::Absolute,
206            top: px(12),
207            left: px(12),
208            ..default()
209        },
210        HelpText,
211    ));
212}
213
214/// Adds lightmaps to and/or removes lightmaps from objects in the scene when
215/// the lighting mode changes.
216///
217/// This is also called right after the scene loads in order to set up the
218/// lightmaps.
219fn update_lightmaps(
220    mut commands: Commands,
221    asset_server: Res<AssetServer>,
222    mut materials: ResMut<Assets<StandardMaterial>>,
223    meshes: Query<(Entity, &GltfMeshName, &MeshMaterial3d<StandardMaterial>), With<Mesh3d>>,
224    mut lighting_mode_changed_reader: MessageReader<LightingModeChanged>,
225    app_status: Res<AppStatus>,
226) {
227    // Only run if the lighting mode changed. (Note that a change event is fired
228    // when the scene first loads.)
229    if lighting_mode_changed_reader.read().next().is_none() {
230        return;
231    }
232
233    // Select the lightmap to use, based on the lighting mode.
234    let lightmap: Option<Handle<Image>> = match app_status.lighting_mode {
235        LightingMode::Baked => {
236            Some(asset_server.load("lightmaps/MixedLightingExample-Baked.zstd.ktx2"))
237        }
238        LightingMode::MixedDirect => {
239            Some(asset_server.load("lightmaps/MixedLightingExample-MixedDirect.zstd.ktx2"))
240        }
241        LightingMode::MixedIndirect => {
242            Some(asset_server.load("lightmaps/MixedLightingExample-MixedIndirect.zstd.ktx2"))
243        }
244        LightingMode::RealTime => None,
245    };
246
247    'outer: for (entity, name, material) in &meshes {
248        // Add lightmaps to or remove lightmaps from the scenery objects in the
249        // scene (all objects but the sphere).
250        //
251        // Note that doing a linear search through the `LIGHTMAPS` array is
252        // inefficient, but we do it anyway in this example to improve clarity.
253        for (lightmap_name, uv_rect) in LIGHTMAPS {
254            if &**name != lightmap_name {
255                continue;
256            }
257
258            // Lightmap exposure defaults to zero, so we need to set it.
259            if let Some(ref mut material) = materials.get_mut(material) {
260                material.lightmap_exposure = LIGHTMAP_EXPOSURE;
261            }
262
263            // Add or remove the lightmap.
264            match lightmap {
265                Some(ref lightmap) => {
266                    commands.entity(entity).insert(Lightmap {
267                        image: (*lightmap).clone(),
268                        uv_rect,
269                        bicubic_sampling: false,
270                    });
271                }
272                None => {
273                    commands.entity(entity).remove::<Lightmap>();
274                }
275            }
276            continue 'outer;
277        }
278
279        // Add lightmaps to or remove lightmaps from the sphere.
280        if &**name == "Sphere" {
281            // Lightmap exposure defaults to zero, so we need to set it.
282            if let Some(ref mut material) = materials.get_mut(material) {
283                material.lightmap_exposure = LIGHTMAP_EXPOSURE;
284            }
285
286            // Add or remove the lightmap from the sphere. We only apply the
287            // lightmap in fully-baked mode.
288            match (&lightmap, app_status.lighting_mode) {
289                (Some(lightmap), LightingMode::Baked) => {
290                    commands.entity(entity).insert(Lightmap {
291                        image: (*lightmap).clone(),
292                        uv_rect: SPHERE_UV_RECT,
293                        bicubic_sampling: false,
294                    });
295                }
296                _ => {
297                    commands.entity(entity).remove::<Lightmap>();
298                }
299            }
300        }
301    }
302}
303
304/// Converts a uv rectangle from the OpenGL coordinate system (origin in the
305/// lower left) to the Vulkan coordinate system (origin in the upper left) that
306/// Bevy uses.
307///
308/// For this particular example, the baking tool happened to use the OpenGL
309/// coordinate system, so it was more convenient to do the conversion at compile
310/// time than to pre-calculate and hard-code the values.
311const fn uv_rect_opengl(gl_min: Vec2, size: Vec2) -> Rect {
312    let min = vec2(gl_min.x, 1.0 - gl_min.y - size.y);
313    Rect {
314        min,
315        max: vec2(min.x + size.x, min.y + size.y),
316    }
317}
318
319/// Ensures that clicking on the scene to move the sphere doesn't result in a
320/// hit on the sphere itself.
321fn make_sphere_nonpickable(
322    mut commands: Commands,
323    mut query: Query<(Entity, &Name), (With<Mesh3d>, Without<Pickable>)>,
324) {
325    for (sphere, name) in &mut query {
326        if &**name == "Sphere" {
327            commands.entity(sphere).insert(Pickable::IGNORE);
328        }
329    }
330}
331
332/// Updates the directional light settings as necessary when the lighting mode
333/// changes.
334fn update_directional_light(
335    mut lights: Query<&mut DirectionalLight>,
336    mut lighting_mode_changed_reader: MessageReader<LightingModeChanged>,
337    app_status: Res<AppStatus>,
338) {
339    // Only run if the lighting mode changed. (Note that a change event is fired
340    // when the scene first loads.)
341    if lighting_mode_changed_reader.read().next().is_none() {
342        return;
343    }
344
345    // Real-time direct light is used on the scenery if we're using mixed
346    // indirect or real-time mode.
347    let scenery_is_lit_in_real_time = matches!(
348        app_status.lighting_mode,
349        LightingMode::MixedIndirect | LightingMode::RealTime
350    );
351
352    for mut light in &mut lights {
353        light.affects_lightmapped_mesh_diffuse = scenery_is_lit_in_real_time;
354        // Don't bother enabling shadows if they won't show up on the scenery.
355        light.shadows_enabled = scenery_is_lit_in_real_time;
356    }
357}
358
359/// Updates the state of the selection widgets at the bottom of the window when
360/// the lighting mode changes.
361fn update_radio_buttons(
362    mut widgets: Query<
363        (
364            Entity,
365            Option<&mut BackgroundColor>,
366            Has<Text>,
367            &WidgetClickSender<LightingMode>,
368        ),
369        Or<(With<RadioButton>, With<RadioButtonText>)>,
370    >,
371    app_status: Res<AppStatus>,
372    mut writer: TextUiWriter,
373) {
374    for (entity, image, has_text, sender) in &mut widgets {
375        let selected = **sender == app_status.lighting_mode;
376
377        if let Some(mut bg_color) = image {
378            widgets::update_ui_radio_button(&mut bg_color, selected);
379        }
380        if has_text {
381            widgets::update_ui_radio_button_text(entity, &mut writer, selected);
382        }
383    }
384}
385
386/// Handles clicks on the widgets at the bottom of the screen and fires
387/// [`LightingModeChanged`] events.
388fn handle_lighting_mode_change(
389    mut widget_click_event_reader: MessageReader<WidgetClickEvent<LightingMode>>,
390    mut lighting_mode_changed_writer: MessageWriter<LightingModeChanged>,
391    mut app_status: ResMut<AppStatus>,
392) {
393    for event in widget_click_event_reader.read() {
394        app_status.lighting_mode = **event;
395        lighting_mode_changed_writer.write(LightingModeChanged);
396    }
397}
398
399/// Moves the sphere to its original position when the user selects the baked
400/// lighting mode.
401///
402/// As the light from the sphere is precomputed and depends on the sphere's
403/// original position, the sphere must be placed there in order for the lighting
404/// to be correct.
405fn reset_sphere_position(
406    mut objects: Query<(&Name, &mut Transform)>,
407    mut lighting_mode_changed_reader: MessageReader<LightingModeChanged>,
408    app_status: Res<AppStatus>,
409) {
410    // Only run if the lighting mode changed and if the lighting mode is
411    // `LightingMode::Baked`. (Note that a change event is fired when the scene
412    // first loads.)
413    if lighting_mode_changed_reader.read().next().is_none()
414        || app_status.lighting_mode != LightingMode::Baked
415    {
416        return;
417    }
418
419    for (name, mut transform) in &mut objects {
420        if &**name == "Sphere" {
421            transform.translation = INITIAL_SPHERE_POSITION;
422            break;
423        }
424    }
425}
426
427/// Updates the position of the sphere when the user clicks on a spot in the
428/// scene.
429///
430/// Note that the position of the sphere is locked in baked lighting mode.
431fn move_sphere(
432    mouse_button_input: Res<ButtonInput<MouseButton>>,
433    pointers: Query<&PointerInteraction>,
434    mut meshes: Query<(&GltfMeshName, &ChildOf), With<Mesh3d>>,
435    mut transforms: Query<&mut Transform>,
436    app_status: Res<AppStatus>,
437) {
438    // Only run when the left button is clicked and we're not in baked lighting
439    // mode.
440    if app_status.lighting_mode == LightingMode::Baked
441        || !mouse_button_input.pressed(MouseButton::Left)
442    {
443        return;
444    }
445
446    // Find the sphere.
447    let Some(child_of) = meshes
448        .iter_mut()
449        .filter_map(|(name, child_of)| {
450            if &**name == "Sphere" {
451                Some(child_of)
452            } else {
453                None
454            }
455        })
456        .next()
457    else {
458        return;
459    };
460
461    // Grab its transform.
462    let Ok(mut transform) = transforms.get_mut(child_of.parent()) else {
463        return;
464    };
465
466    // Set its transform to the appropriate position, as determined by the
467    // picking subsystem.
468    for interaction in pointers.iter() {
469        if let Some(&(
470            _,
471            HitData {
472                position: Some(position),
473                ..
474            },
475        )) = interaction.get_nearest_hit()
476        {
477            transform.translation = position + vec3(0.0, SPHERE_OFFSET, 0.0);
478        }
479    }
480}
481
482/// Changes the help text at the top of the screen when the lighting mode
483/// changes.
484fn adjust_help_text(
485    mut commands: Commands,
486    help_texts: Query<Entity, With<HelpText>>,
487    app_status: Res<AppStatus>,
488    mut lighting_mode_changed_reader: MessageReader<LightingModeChanged>,
489) {
490    if lighting_mode_changed_reader.read().next().is_none() {
491        return;
492    }
493
494    for help_text in &help_texts {
495        commands
496            .entity(help_text)
497            .insert(create_help_text(&app_status));
498    }
499}
500
501/// Returns appropriate text to display at the top of the screen.
502fn create_help_text(app_status: &AppStatus) -> Text {
503    match app_status.lighting_mode {
504        LightingMode::Baked => Text::new(
505            "Scenery: Static, baked direct light, baked indirect light
506Sphere: Static, baked direct light, baked indirect light",
507        ),
508        LightingMode::MixedDirect => Text::new(
509            "Scenery: Static, baked direct light, baked indirect light
510Sphere: Dynamic, real-time direct light, no indirect light
511Click in the scene to move the sphere",
512        ),
513        LightingMode::MixedIndirect => Text::new(
514            "Scenery: Static, real-time direct light, baked indirect light
515Sphere: Dynamic, real-time direct light, no indirect light
516Click in the scene to move the sphere",
517        ),
518        LightingMode::RealTime => Text::new(
519            "Scenery: Dynamic, real-time direct light, no indirect light
520Sphere: Dynamic, real-time direct light, no indirect light
521Click in the scene to move the sphere",
522        ),
523    }
524}