Skip to main content

mixed_lighting/
mixed_lighting.rs

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