Skip to main content

light_probe_blending/
light_probe_blending.rs

1//! Demonstrates blending between multiple reflection probes.
2//!
3//! This example shows a reflective sphere that moves between two rooms, each of
4//! which contains a reflection probe with a falloff range. Bevy performs a
5//! blend between the two reflection probes as the sphere moves.
6
7use 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/// The settings that the user has chosen.
26#[derive(Resource, Default)]
27struct AppStatus {
28    /// Whether the gizmos that show the boundaries of the light probe regions
29    /// are to be shown.
30    gizmos_enabled: GizmosEnabled,
31    /// Which object to show: either a reflective sphere or a reflective prism.
32    object_to_show: ObjectToShow,
33    /// Whether to use an orbital pan/zoom camera or a free camera.
34    camera_mode: CameraMode,
35}
36
37/// Whether the gizmos that show the boundaries of the light probe regions are
38/// to be shown.
39#[derive(Clone, Copy, Default, PartialEq)]
40enum GizmosEnabled {
41    /// The gizmos are shown.
42    #[default]
43    On,
44    /// The gizmos are hidden.
45    Off,
46}
47
48/// Which reflective object to show.
49#[derive(Clone, Copy, Default, PartialEq)]
50enum ObjectToShow {
51    /// A reflective sphere that moves between rooms.
52    #[default]
53    Sphere,
54    /// A reflective prism that is static and stretches across the length of the
55    /// two rooms.
56    Prism,
57}
58
59/// How the user can control the camera.
60#[derive(Clone, Copy, Default, PartialEq)]
61enum CameraMode {
62    /// The camera is a pan/zoom orbital camera controllable with dragging and
63    /// the mouse wheel.
64    #[default]
65    Orbit,
66    /// The camera is a free camera controllable by clicking and dragging and
67    /// using the WASDEQ controls.
68    Free,
69}
70
71/// A marker component for the reflective sphere.
72#[derive(Clone, Copy, Component, Debug)]
73struct ReflectiveSphere;
74
75/// A marker component for the reflective prism.
76#[derive(Clone, Copy, Component, Debug)]
77struct ReflectivePrism;
78
79/// A marker component for the help text at the top of the screen.
80#[derive(Clone, Copy, Component, Debug)]
81struct HelpText;
82
83/// The speed at which the sphere moves, as a ratio of the total distance it
84/// travels to seconds.
85///
86/// Specifically, the value of 0.3 means that it moves 3/10 of the way to the
87/// other side per second.
88const SPHERE_MOVEMENT_SPEED: f32 = 0.3;
89
90/// The side length of each room, in meters.
91const ROOM_SIDE_LENGTH: f32 = 10.0;
92
93/// The number of meters that separates the center of each room.
94const ROOM_SEPARATION: f32 = 11.0;
95
96/// The side length of the light probe cube, in meters.
97const LIGHT_PROBE_SIDE_LENGTH: f32 = 15.0;
98
99/// The distance over which the light probe fades out, expressed as a fraction
100/// of the side length of the probe.
101const LIGHT_PROBE_FALLOFF: f32 = 0.5;
102
103/// The side length of the simulated reflected area for each light probe,
104/// specified as a half-extent in light probe space.
105///
106/// We want this side length, in world space, to be half of the world-space room
107/// side length. Since the light probe is scaled by `LIGHT_PROBE_SIDE_LENGTH`,
108/// we divide the room side length by the light probe side length to get this
109/// value, and multiply by 0.5 to convert from a full extent to a half-extent.
110/// That way, when Bevy applies the `LIGHT_PROBE_SIDE_LENGTH` scale, the light
111/// probe side length factor cancels, and we're left with a parallax correction
112/// side length of `ROOM_SIDE_LENGTH` in world space.
113///
114/// A small epsilon value of 0.01 is added in order to ensure that the light
115/// probe parallax bounds encompass the entire room. Otherwise, unsightly
116/// Z-fighting can occur on the room walls.
117const LIGHT_PROBE_PARALLAX_CORRECTION_SIDE_LENGTH: f32 =
118    ROOM_SIDE_LENGTH / LIGHT_PROBE_SIDE_LENGTH * 0.5 + 0.01;
119
120/// The number of radians of inclination (pitch) that one pixel of mouse
121/// movement corresponds to.
122const CAMERA_ORBIT_SPEED_INCLINATION: f32 = 0.003;
123
124/// The number of radians of azumith (yaw) that one pixel of mouse movement
125/// corresponds to.
126const CAMERA_ORBIT_SPEED_AZIMUTH: f32 = 0.004;
127
128/// The number of meters that one line of mouse scroll corresponds to.
129const CAMERA_ZOOM_SPEED: f32 = 0.15;
130
131/// Information about the orbital pan/zoom camera.
132///
133/// These are in [spherical coordinates].
134///
135/// [spherical coordinates]: https://en.wikipedia.org/wiki/Spherical_coordinate_system
136#[derive(Component)]
137struct OrbitCamera {
138    /// The distance between the camera and the sphere, in meters.
139    radius: f32,
140    /// The camera latitude in radians, relative to the sphere.
141    inclination: f32,
142    /// The camera longitude in radians, relative to the sphere.
143    azimuth: f32,
144}
145
146/// The brightness of the light probe.
147const LIGHT_PROBE_INTENSITY: f32 = 500.0;
148
149/// The entry point.
150fn 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
202/// Performs initial setup of the scene.
203fn 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
223/// Adjusts the gizmo settings so that the gizmos appear on top of all other
224/// geometry.
225///
226/// If we didn't do this, then the rooms would cover up many of the gizmos.
227fn 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
233/// Creates the perfectly-reflective material that the sphere and prism use.
234fn 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
246/// Spawns the orbital pan/zoom camera.
247fn 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
260/// Spawns the glTF scene that contains the two rooms.
261fn 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
267/// Spawns the reflective sphere, creating its mesh in the process.
268fn spawn_reflective_sphere(
269    commands: &mut Commands,
270    meshes: &mut Assets<Mesh>,
271    material: Handle<StandardMaterial>,
272) {
273    // Create a mesh.
274    let sphere = meshes.add(Sphere::default().mesh().uv(32, 18));
275
276    // Spawn the sphere.
277    commands.spawn((
278        Mesh3d(sphere),
279        MeshMaterial3d(material),
280        Transform::IDENTITY,
281        ReflectiveSphere,
282    ));
283}
284
285/// Spawns the reflective prism, creating its mesh in the process.
286///
287/// The reflective prism starts invisible, but the user can toggle it on and off
288/// as desired.
289fn spawn_reflective_prism(
290    commands: &mut Commands,
291    meshes: &mut Assets<Mesh>,
292    material: Handle<StandardMaterial>,
293) {
294    // Create a mesh.
295    let cube = meshes.add(
296        Cuboid {
297            half_size: vec3(2.0, 1.0, 10.0),
298        }
299        .mesh()
300        .build()
301        // We use flat normals so that the surface appears flat, not curved.
302        .with_duplicated_vertices()
303        .with_computed_flat_normals(),
304    );
305
306    // Spawn the cube.
307    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
316/// Spawns the two light probes, one for each room.
317fn spawn_light_probes(commands: &mut Commands, asset_server: &AssetServer) {
318    // Spawn the first room's light probe.
319    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    // Spawn the second room's light probe.
335    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
352/// Spawns the radio buttons at the bottom of the screen.
353fn 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
376/// Spawns the help text at the top of the screen.
377fn 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
390/// Moves the sphere a bit every frame.
391fn 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
406/// Processes requests from the user to move the camera.
407fn 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    // Grab the sphere transform.
415    let Some(sphere_transform) = spheres.iter().next() else {
416        return;
417    };
418
419    for (mut camera_transform, mut orbit_camera) in &mut cameras {
420        // Only pan if the left mouse button is pressed.
421        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        // Zooming doesn't require a mouse button press, as it uses the mouse
428        // wheel.
429        orbit_camera.radius =
430            (orbit_camera.radius - CAMERA_ZOOM_SPEED * mouse_scroll.delta.y).max(0.01);
431
432        // Calculate the new translation using the [spherical coordinates
433        // formula].
434        //
435        // [spherical coordinates formula]:
436        // https://en.wikipedia.org/wiki/Spherical_coordinate_system#Cartesian_coordinates
437        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        // Write in the new transform.
445        *camera_transform =
446            Transform::from_translation(new_translation + sphere_transform.translation)
447                .looking_at(sphere_transform.translation, Vec3::Y);
448    }
449}
450
451/// A system that toggles gizmos on or off when the user clicks on one of the
452/// corresponding radio buttons.
453fn 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
469/// A system that toggles object visibility when the user clicks on one of the
470/// corresponding radio buttons.
471fn 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
495/// A system that toggles the camera mode when the user clicks on one of the
496/// corresponding radio buttons.
497fn 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                    // Convert from Cartesian coordinates back to spherical
518                    // coordinates.
519                    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        // Reset the cursor grab mode, because the free camera controller may
559        // have enabled it, and we don't want the cursor to disappear.
560        for mut cursor_options in &mut windows_query {
561            cursor_options.grab_mode = CursorGrabMode::None;
562            cursor_options.visible = true;
563        }
564    }
565}
566
567/// A system that updates the radio buttons at the bottom of the screen to
568/// reflect whether gizmos are enabled or not.
569fn 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
609/// Draws gizmos that show the boundaries of the various boxes associated with
610/// the light probes in the scene.
611fn draw_gizmos(
612    light_probes: Query<(&LightProbe, &ParallaxCorrection, &Transform)>,
613    app_status: Res<AppStatus>,
614    mut gizmos: Gizmos,
615) {
616    // If the user has gizmos disabled, bail.
617    if matches!(app_status.gizmos_enabled, GizmosEnabled::Off) {
618        return;
619    }
620
621    for (light_probe, parallax_correction, transform) in &light_probes {
622        // Draw light probe bounds.
623        gizmos.cube(*transform, TAN);
624
625        // Draw light probe falloff.
626        gizmos.cube(
627            Transform {
628                scale: transform.scale * (Vec3::ONE - light_probe.falloff),
629                ..*transform
630            },
631            CRIMSON,
632        );
633
634        // Draw light probe parallax correction bounds.
635        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
647/// Updates the help text at the top of the screen to reflect a change in camera
648/// or gizmo application settings.
649fn 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
681/// Returns the GitHub download URL for the given asset.
682///
683/// The files are expected to be in the `light_probe_blending` directory in the
684/// [repository].
685///
686/// [repository]: https://github.com/bevyengine/bevy_asset_files
687fn 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}