bevy/examples/3d/mixed_lighting.rs
Carter Anderson 21f1e3045c
Relationships (non-fragmenting, one-to-many) (#17398)
This adds support for one-to-many non-fragmenting relationships (with
planned paths for fragmenting and non-fragmenting many-to-many
relationships). "Non-fragmenting" means that entities with the same
relationship type, but different relationship targets, are not forced
into separate tables (which would cause "table fragmentation").

Functionally, this fills a similar niche as the current Parent/Children
system. The biggest differences are:

1. Relationships have simpler internals and significantly improved
performance and UX. Commands and specialized APIs are no longer
necessary to keep everything in sync. Just spawn entities with the
relationship components you want and everything "just works".
2. Relationships are generalized. Bevy can provide additional built in
relationships, and users can define their own.

**REQUEST TO REVIEWERS**: _please don't leave top level comments and
instead comment on specific lines of code. That way we can take
advantage of threaded discussions. Also dont leave comments simply
pointing out CI failures as I can read those just fine._

## Built on top of what we have

Relationships are implemented on top of the Bevy ECS features we already
have: components, immutability, and hooks. This makes them immediately
compatible with all of our existing (and future) APIs for querying,
spawning, removing, scenes, reflection, etc. The fewer specialized APIs
we need to build, maintain, and teach, the better.

## Why focus on one-to-many non-fragmenting first?

1. This allows us to improve Parent/Children relationships immediately,
in a way that is reasonably uncontroversial. Switching our hierarchy to
fragmenting relationships would have significant performance
implications. ~~Flecs is heavily considering a switch to non-fragmenting
relations after careful considerations of the performance tradeoffs.~~
_(Correction from @SanderMertens: Flecs is implementing non-fragmenting
storage specialized for asset hierarchies, where asset hierarchies are
many instances of small trees that have a well defined structure)_
2. Adding generalized one-to-many relationships is currently a priority
for the [Next Generation Scene / UI
effort](https://github.com/bevyengine/bevy/discussions/14437).
Specifically, we're interested in building reactions and observers on
top.

## The changes

This PR does the following:

1. Adds a generic one-to-many Relationship system
3. Ports the existing Parent/Children system to Relationships, which now
lives in `bevy_ecs::hierarchy`. The old `bevy_hierarchy` crate has been
removed.
4. Adds on_despawn component hooks
5. Relationships can opt-in to "despawn descendants" behavior, meaning
that the entire relationship hierarchy is despawned when
`entity.despawn()` is called. The built in Parent/Children hierarchies
enable this behavior, and `entity.despawn_recursive()` has been removed.
6. `world.spawn` now applies commands after spawning. This ensures that
relationship bookkeeping happens immediately and removes the need to
manually flush. This is in line with the equivalent behaviors recently
added to the other APIs (ex: insert).
7. Removes the ValidParentCheckPlugin (system-driven / poll based) in
favor of a `validate_parent_has_component` hook.

## Using Relationships

The `Relationship` trait looks like this:

```rust
pub trait Relationship: Component + Sized {
    type RelationshipSources: RelationshipSources<Relationship = Self>;
    fn get(&self) -> Entity;
    fn from(entity: Entity) -> Self;
}
```

A relationship is a component that:

1. Is a simple wrapper over a "target" Entity.
2. Has a corresponding `RelationshipSources` component, which is a
simple wrapper over a collection of entities. Every "target entity"
targeted by a "source entity" with a `Relationship` has a
`RelationshipSources` component, which contains every "source entity"
that targets it.

For example, the `Parent` component (as it currently exists in Bevy) is
the `Relationship` component and the entity containing the Parent is the
"source entity". The entity _inside_ the `Parent(Entity)` component is
the "target entity". And that target entity has a `Children` component
(which implements `RelationshipSources`).

In practice, the Parent/Children relationship looks like this:

```rust
#[derive(Relationship)]
#[relationship(relationship_sources = Children)]
pub struct Parent(pub Entity);

#[derive(RelationshipSources)]
#[relationship_sources(relationship = Parent)]
pub struct Children(Vec<Entity>);
```

The Relationship and RelationshipSources derives automatically implement
Component with the relevant configuration (namely, the hooks necessary
to keep everything in sync).

The most direct way to add relationships is to spawn entities with
relationship components:

```rust
let a = world.spawn_empty().id();
let b = world.spawn(Parent(a)).id();

assert_eq!(world.entity(a).get::<Children>().unwrap(), &[b]);
```

There are also convenience APIs for spawning more than one entity with
the same relationship:

```rust
world.spawn_empty().with_related::<Children>(|s| {
    s.spawn_empty();
    s.spawn_empty();
})
```

The existing `with_children` API is now a simpler wrapper over
`with_related`. This makes this change largely non-breaking for existing
spawn patterns.

```rust
world.spawn_empty().with_children(|s| {
    s.spawn_empty();
    s.spawn_empty();
})
```

There are also other relationship APIs, such as `add_related` and
`despawn_related`.

## Automatic recursive despawn via the new on_despawn hook

`RelationshipSources` can opt-in to "despawn descendants" behavior,
which will despawn all related entities in the relationship hierarchy:

```rust
#[derive(RelationshipSources)]
#[relationship_sources(relationship = Parent, despawn_descendants)]
pub struct Children(Vec<Entity>);
```

This means that `entity.despawn_recursive()` is no longer required.
Instead, just use `entity.despawn()` and the relevant related entities
will also be despawned.

To despawn an entity _without_ despawning its parent/child descendants,
you should remove the `Children` component first, which will also remove
the related `Parent` components:

```rust
entity
    .remove::<Children>()
    .despawn()
```

This builds on the on_despawn hook introduced in this PR, which is fired
when an entity is despawned (before other hooks).

## Relationships are the source of truth

`Relationship` is the _single_ source of truth component.
`RelationshipSources` is merely a reflection of what all the
`Relationship` components say. By embracing this, we are able to
significantly improve the performance of the system as a whole. We can
rely on component lifecycles to protect us against duplicates, rather
than needing to scan at runtime to ensure entities don't already exist
(which results in quadratic runtime). A single source of truth gives us
constant-time inserts. This does mean that we cannot directly spawn
populated `Children` components (or directly add or remove entities from
those components). I personally think this is a worthwhile tradeoff,
both because it makes the performance much better _and_ because it means
theres exactly one way to do things (which is a philosophy we try to
employ for Bevy APIs).

As an aside: treating both sides of the relationship as "equivalent
source of truth relations" does enable building simple and flexible
many-to-many relationships. But this introduces an _inherent_ need to
scan (or hash) to protect against duplicates.
[`evergreen_relations`](https://github.com/EvergreenNest/evergreen_relations)
has a very nice implementation of the "symmetrical many-to-many"
approach. Unfortunately I think the performance issues inherent to that
approach make it a poor choice for Bevy's default relationship system.

## Followup Work

* Discuss renaming `Parent` to `ChildOf`. I refrained from doing that in
this PR to keep the diff reasonable, but I'm personally biased toward
this change (and using that naming pattern generally for relationships).
* [Improved spawning
ergonomics](https://github.com/bevyengine/bevy/discussions/16920)
* Consider adding relationship observers/triggers for "relationship
targets" whenever a source is added or removed. This would replace the
current "hierarchy events" system, which is unused upstream but may have
existing users downstream. I think triggers are the better fit for this
than a buffered event queue, and would prefer not to add that back.
* Fragmenting relations: My current idea hinges on the introduction of
"value components" (aka: components whose type _and_ value determines
their ComponentId, via something like Hashing / PartialEq). By labeling
a Relationship component such as `ChildOf(Entity)` as a "value
component", `ChildOf(e1)` and `ChildOf(e2)` would be considered
"different components". This makes the transition between fragmenting
and non-fragmenting a single flag, and everything else continues to work
as expected.
* Many-to-many support
* Non-fragmenting: We can expand Relationship to be a list of entities
instead of a single entity. I have largely already written the code for
this.
* Fragmenting: With the "value component" impl mentioned above, we get
many-to-many support "for free", as it would allow inserting multiple
copies of a Relationship component with different target entities.

Fixes #3742 (If this PR is merged, I think we should open more targeted
followup issues for the work above, with a fresh tracking issue free of
the large amount of less-directed historical context)
Fixes #17301
Fixes #12235 
Fixes #15299
Fixes #15308 

## Migration Guide

* Replace `ChildBuilder` with `ChildSpawnerCommands`.
* Replace calls to `.set_parent(parent_id)` with
`.insert(Parent(parent_id))`.
* Replace calls to `.replace_children()` with `.remove::<Children>()`
followed by `.add_children()`. Note that you'll need to manually despawn
any children that are not carried over.
* Replace calls to `.despawn_recursive()` with `.despawn()`.
* Replace calls to `.despawn_descendants()` with
`.despawn_related::<Children>()`.
* If you have any calls to `.despawn()` which depend on the children
being preserved, you'll need to remove the `Children` component first.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
2025-01-18 22:20:30 +00:00

526 lines
18 KiB
Rust

//! Demonstrates how to combine baked and dynamic lighting.
use bevy::{
pbr::Lightmap,
picking::{backend::HitData, pointer::PointerInteraction},
prelude::*,
scene::SceneInstanceReady,
};
use crate::widgets::{RadioButton, RadioButtonText, WidgetClickEvent, WidgetClickSender};
#[path = "../helpers/widgets.rs"]
mod widgets;
/// How bright the lightmaps are.
const LIGHTMAP_EXPOSURE: f32 = 600.0;
/// How far above the ground the sphere's origin is when moved, in scene units.
const SPHERE_OFFSET: f32 = 0.2;
/// The settings that the user has currently chosen for the app.
#[derive(Clone, Default, Resource)]
struct AppStatus {
/// The lighting mode that the user currently has set: baked, mixed, or
/// real-time.
lighting_mode: LightingMode,
}
/// The type of lighting to use in the scene.
#[derive(Clone, Copy, PartialEq, Default)]
enum LightingMode {
/// All light is computed ahead of time; no lighting takes place at runtime.
///
/// In this mode, the sphere can't be moved, as the light shining on it was
/// precomputed. On the plus side, the sphere has indirect lighting in this
/// mode, as the red hue on the bottom of the sphere demonstrates.
Baked,
/// All light for the static objects is computed ahead of time, but the
/// light for the dynamic sphere is computed at runtime.
///
/// In this mode, the sphere can be moved, and the light will be computed
/// for it as you do so. The sphere loses indirect illumination; notice the
/// lack of a red hue at the base of the sphere. However, the rest of the
/// scene has indirect illumination. Note also that the sphere doesn't cast
/// a shadow on the static objects in this mode, because shadows are part of
/// the lighting computation.
MixedDirect,
/// Indirect light for the static objects is computed ahead of time, and
/// direct light for all objects is computed at runtime.
///
/// In this mode, the sphere can be moved, and the light will be computed
/// for it as you do so. The sphere loses indirect illumination; notice the
/// lack of a red hue at the base of the sphere. However, the rest of the
/// scene has indirect illumination. The sphere does cast a shadow on
/// objects in this mode, because the direct light for all objects is being
/// computed dynamically.
#[default]
MixedIndirect,
/// Light is computed at runtime for all objects.
///
/// In this mode, no lightmaps are used at all. All objects are dynamically
/// lit, which provides maximum flexibility. However, the downside is that
/// global illumination is lost; note that the base of the sphere isn't red
/// as it is in baked mode.
RealTime,
}
/// An event that's fired whenever the user changes the lighting mode.
///
/// This is also fired when the scene loads for the first time.
#[derive(Clone, Copy, Default, Event)]
struct LightingModeChanged;
#[derive(Clone, Copy, Component, Debug)]
struct HelpText;
/// The name of every static object in the scene that has a lightmap, as well as
/// the UV rect of its lightmap.
///
/// Storing this as an array and doing a linear search through it is rather
/// inefficient, but we do it anyway for clarity's sake.
static LIGHTMAPS: [(&str, Rect); 5] = [
(
"Plane",
uv_rect_opengl(Vec2::splat(0.026), Vec2::splat(0.710)),
),
(
"SheenChair_fabric",
uv_rect_opengl(vec2(0.7864, 0.02377), vec2(0.1910, 0.1912)),
),
(
"SheenChair_label",
uv_rect_opengl(vec2(0.275, -0.016), vec2(0.858, 0.486)),
),
(
"SheenChair_metal",
uv_rect_opengl(vec2(0.998, 0.506), vec2(-0.029, -0.067)),
),
(
"SheenChair_wood",
uv_rect_opengl(vec2(0.787, 0.257), vec2(0.179, 0.177)),
),
];
static SPHERE_UV_RECT: Rect = uv_rect_opengl(vec2(0.788, 0.484), Vec2::splat(0.062));
/// The initial position of the sphere.
///
/// When the user sets the light mode to [`LightingMode::Baked`], we reset the
/// position to this point.
const INITIAL_SPHERE_POSITION: Vec3 = vec3(0.0, 0.5233223, 0.0);
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Mixed Lighting Example".into(),
..default()
}),
..default()
}))
.add_plugins(MeshPickingPlugin)
.insert_resource(AmbientLight {
color: ClearColor::default().0,
brightness: 10000.0,
affects_lightmapped_meshes: true,
})
.init_resource::<AppStatus>()
.add_event::<WidgetClickEvent<LightingMode>>()
.add_event::<LightingModeChanged>()
.add_systems(Startup, setup)
.add_systems(Update, update_lightmaps)
.add_systems(Update, update_directional_light)
.add_systems(Update, make_sphere_nonpickable)
.add_systems(Update, update_radio_buttons)
.add_systems(Update, handle_lighting_mode_change)
.add_systems(Update, widgets::handle_ui_interactions::<LightingMode>)
.add_systems(Update, reset_sphere_position)
.add_systems(Update, move_sphere)
.add_systems(Update, adjust_help_text)
.run();
}
/// Creates the scene.
fn setup(mut commands: Commands, asset_server: Res<AssetServer>, app_status: Res<AppStatus>) {
spawn_camera(&mut commands);
spawn_scene(&mut commands, &asset_server);
spawn_buttons(&mut commands);
spawn_help_text(&mut commands, &app_status);
}
/// Spawns the 3D camera.
fn spawn_camera(commands: &mut Commands) {
commands
.spawn(Camera3d::default())
.insert(Transform::from_xyz(-0.7, 0.7, 1.0).looking_at(vec3(0.0, 0.3, 0.0), Vec3::Y));
}
/// Spawns the scene.
///
/// The scene is loaded from a glTF file.
fn spawn_scene(commands: &mut Commands, asset_server: &AssetServer) {
commands
.spawn(SceneRoot(
asset_server.load(
GltfAssetLabel::Scene(0)
.from_asset("models/MixedLightingExample/MixedLightingExample.gltf"),
),
))
.observe(
|_: Trigger<SceneInstanceReady>,
mut lighting_mode_change_event_writer: EventWriter<LightingModeChanged>| {
// When the scene loads, send a `LightingModeChanged` event so
// that we set up the lightmaps.
lighting_mode_change_event_writer.send(LightingModeChanged);
},
);
}
/// Spawns the buttons that allow the user to change the lighting mode.
fn spawn_buttons(commands: &mut Commands) {
commands
.spawn(widgets::main_ui_node())
.with_children(|parent| {
widgets::spawn_option_buttons(
parent,
"Lighting",
&[
(LightingMode::Baked, "Baked"),
(LightingMode::MixedDirect, "Mixed (Direct)"),
(LightingMode::MixedIndirect, "Mixed (Indirect)"),
(LightingMode::RealTime, "Real-Time"),
],
);
});
}
/// Spawns the help text at the top of the window.
fn spawn_help_text(commands: &mut Commands, app_status: &AppStatus) {
commands.spawn((
create_help_text(app_status),
Node {
position_type: PositionType::Absolute,
top: Val::Px(12.0),
left: Val::Px(12.0),
..default()
},
HelpText,
));
}
/// Adds lightmaps to and/or removes lightmaps from objects in the scene when
/// the lighting mode changes.
///
/// This is also called right after the scene loads in order to set up the
/// lightmaps.
fn update_lightmaps(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<StandardMaterial>>,
meshes: Query<(Entity, &Name, &MeshMaterial3d<StandardMaterial>), With<Mesh3d>>,
mut lighting_mode_change_event_reader: EventReader<LightingModeChanged>,
app_status: Res<AppStatus>,
) {
// Only run if the lighting mode changed. (Note that a change event is fired
// when the scene first loads.)
if lighting_mode_change_event_reader.read().next().is_none() {
return;
}
// Select the lightmap to use, based on the lighting mode.
let lightmap: Option<Handle<Image>> = match app_status.lighting_mode {
LightingMode::Baked => {
Some(asset_server.load("lightmaps/MixedLightingExample-Baked.zstd.ktx2"))
}
LightingMode::MixedDirect => {
Some(asset_server.load("lightmaps/MixedLightingExample-MixedDirect.zstd.ktx2"))
}
LightingMode::MixedIndirect => {
Some(asset_server.load("lightmaps/MixedLightingExample-MixedIndirect.zstd.ktx2"))
}
LightingMode::RealTime => None,
};
'outer: for (entity, name, material) in &meshes {
// Add lightmaps to or remove lightmaps from the scenery objects in the
// scene (all objects but the sphere).
//
// Note that doing a linear search through the `LIGHTMAPS` array is
// inefficient, but we do it anyway in this example to improve clarity.
for (lightmap_name, uv_rect) in LIGHTMAPS {
if &**name != lightmap_name {
continue;
}
// Lightmap exposure defaults to zero, so we need to set it.
if let Some(ref mut material) = materials.get_mut(material) {
material.lightmap_exposure = LIGHTMAP_EXPOSURE;
}
// Add or remove the lightmap.
match lightmap {
Some(ref lightmap) => {
commands.entity(entity).insert(Lightmap {
image: (*lightmap).clone(),
uv_rect,
bicubic_sampling: false,
});
}
None => {
commands.entity(entity).remove::<Lightmap>();
}
}
continue 'outer;
}
// Add lightmaps to or remove lightmaps from the sphere.
if &**name == "Sphere" {
// Lightmap exposure defaults to zero, so we need to set it.
if let Some(ref mut material) = materials.get_mut(material) {
material.lightmap_exposure = LIGHTMAP_EXPOSURE;
}
// Add or remove the lightmap from the sphere. We only apply the
// lightmap in fully-baked mode.
match (&lightmap, app_status.lighting_mode) {
(Some(lightmap), LightingMode::Baked) => {
commands.entity(entity).insert(Lightmap {
image: (*lightmap).clone(),
uv_rect: SPHERE_UV_RECT,
bicubic_sampling: false,
});
}
_ => {
commands.entity(entity).remove::<Lightmap>();
}
}
}
}
}
/// Converts a uv rectangle from the OpenGL coordinate system (origin in the
/// lower left) to the Vulkan coordinate system (origin in the upper left) that
/// Bevy uses.
///
/// For this particular example, the baking tool happened to use the OpenGL
/// coordinate system, so it was more convenient to do the conversion at compile
/// time than to pre-calculate and hard-code the values.
const fn uv_rect_opengl(gl_min: Vec2, size: Vec2) -> Rect {
let min = vec2(gl_min.x, 1.0 - gl_min.y - size.y);
Rect {
min,
max: vec2(min.x + size.x, min.y + size.y),
}
}
/// Ensures that clicking on the scene to move the sphere doesn't result in a
/// hit on the sphere itself.
fn make_sphere_nonpickable(
mut commands: Commands,
mut query: Query<(Entity, &Name), (With<Mesh3d>, Without<Pickable>)>,
) {
for (sphere, name) in &mut query {
if &**name == "Sphere" {
commands.entity(sphere).insert(Pickable::IGNORE);
}
}
}
/// Updates the directional light settings as necessary when the lighting mode
/// changes.
fn update_directional_light(
mut lights: Query<&mut DirectionalLight>,
mut lighting_mode_change_event_reader: EventReader<LightingModeChanged>,
app_status: Res<AppStatus>,
) {
// Only run if the lighting mode changed. (Note that a change event is fired
// when the scene first loads.)
if lighting_mode_change_event_reader.read().next().is_none() {
return;
}
// Real-time direct light is used on the scenery if we're using mixed
// indirect or real-time mode.
let scenery_is_lit_in_real_time = matches!(
app_status.lighting_mode,
LightingMode::MixedIndirect | LightingMode::RealTime
);
for mut light in &mut lights {
light.affects_lightmapped_mesh_diffuse = scenery_is_lit_in_real_time;
// Don't bother enabling shadows if they won't show up on the scenery.
light.shadows_enabled = scenery_is_lit_in_real_time;
}
}
/// Updates the state of the selection widgets at the bottom of the window when
/// the lighting mode changes.
fn update_radio_buttons(
mut widgets: Query<
(
Entity,
Option<&mut BackgroundColor>,
Has<Text>,
&WidgetClickSender<LightingMode>,
),
Or<(With<RadioButton>, With<RadioButtonText>)>,
>,
app_status: Res<AppStatus>,
mut writer: TextUiWriter,
) {
for (entity, image, has_text, sender) in &mut widgets {
let selected = **sender == app_status.lighting_mode;
if let Some(mut bg_color) = image {
widgets::update_ui_radio_button(&mut bg_color, selected);
}
if has_text {
widgets::update_ui_radio_button_text(entity, &mut writer, selected);
}
}
}
/// Handles clicks on the widgets at the bottom of the screen and fires
/// [`LightingModeChanged`] events.
fn handle_lighting_mode_change(
mut widget_click_event_reader: EventReader<WidgetClickEvent<LightingMode>>,
mut lighting_mode_change_event_writer: EventWriter<LightingModeChanged>,
mut app_status: ResMut<AppStatus>,
) {
for event in widget_click_event_reader.read() {
app_status.lighting_mode = **event;
lighting_mode_change_event_writer.send(LightingModeChanged);
}
}
/// Moves the sphere to its original position when the user selects the baked
/// lighting mode.
///
/// As the light from the sphere is precomputed and depends on the sphere's
/// original position, the sphere must be placed there in order for the lighting
/// to be correct.
fn reset_sphere_position(
mut objects: Query<(&Name, &mut Transform)>,
mut lighting_mode_change_event_reader: EventReader<LightingModeChanged>,
app_status: Res<AppStatus>,
) {
// Only run if the lighting mode changed and if the lighting mode is
// `LightingMode::Baked`. (Note that a change event is fired when the scene
// first loads.)
if lighting_mode_change_event_reader.read().next().is_none()
|| app_status.lighting_mode != LightingMode::Baked
{
return;
}
for (name, mut transform) in &mut objects {
if &**name == "Sphere" {
transform.translation = INITIAL_SPHERE_POSITION;
break;
}
}
}
/// Updates the position of the sphere when the user clicks on a spot in the
/// scene.
///
/// Note that the position of the sphere is locked in baked lighting mode.
fn move_sphere(
mouse_button_input: Res<ButtonInput<MouseButton>>,
pointers: Query<&PointerInteraction>,
mut meshes: Query<(&Name, &Parent), With<Mesh3d>>,
mut transforms: Query<&mut Transform>,
app_status: Res<AppStatus>,
) {
// Only run when the left button is clicked and we're not in baked lighting
// mode.
if app_status.lighting_mode == LightingMode::Baked
|| !mouse_button_input.pressed(MouseButton::Left)
{
return;
}
// Find the sphere.
let Some(parent) = meshes
.iter_mut()
.filter_map(|(name, parent)| {
if &**name == "Sphere" {
Some(parent)
} else {
None
}
})
.next()
else {
return;
};
// Grab its transform.
let Ok(mut transform) = transforms.get_mut(parent.0) else {
return;
};
// Set its transform to the appropriate position, as determined by the
// picking subsystem.
for interaction in pointers.iter() {
if let Some(&(
_,
HitData {
position: Some(position),
..
},
)) = interaction.get_nearest_hit()
{
transform.translation = position + vec3(0.0, SPHERE_OFFSET, 0.0);
}
}
}
/// Changes the help text at the top of the screen when the lighting mode
/// changes.
fn adjust_help_text(
mut commands: Commands,
help_texts: Query<Entity, With<HelpText>>,
app_status: Res<AppStatus>,
mut lighting_mode_change_event_reader: EventReader<LightingModeChanged>,
) {
if lighting_mode_change_event_reader.read().next().is_none() {
return;
}
for help_text in &help_texts {
commands
.entity(help_text)
.insert(create_help_text(&app_status));
}
}
/// Returns appropriate text to display at the top of the screen.
fn create_help_text(app_status: &AppStatus) -> Text {
match app_status.lighting_mode {
LightingMode::Baked => Text::new(
"Scenery: Static, baked direct light, baked indirect light
Sphere: Static, baked direct light, baked indirect light",
),
LightingMode::MixedDirect => Text::new(
"Scenery: Static, baked direct light, baked indirect light
Sphere: Dynamic, real-time direct light, no indirect light
Click in the scene to move the sphere",
),
LightingMode::MixedIndirect => Text::new(
"Scenery: Static, real-time direct light, baked indirect light
Sphere: Dynamic, real-time direct light, no indirect light
Click in the scene to move the sphere",
),
LightingMode::RealTime => Text::new(
"Scenery: Dynamic, real-time direct light, no indirect light
Sphere: Dynamic, real-time direct light, no indirect light
Click in the scene to move the sphere",
),
}
}