Skip to main content

clustered_decal_maps/
clustered_decal_maps.rs

1//! Demonstrates the normal map, metallic-roughness map, and emissive features
2//! of clustered decals.
3
4use std::{f32::consts::PI, time::Duration};
5
6use bevy::{
7    asset::io::web::WebAssetPlugin,
8    camera::Hdr,
9    color::palettes::css::{CRIMSON, GOLD},
10    feathers::{dark_theme::create_dark_theme, theme::UiTheme, FeathersPlugins},
11    image::ImageLoaderSettings,
12    light::ClusteredDecal,
13    prelude::*,
14    ui_widgets::{radio_self_update, ValueChange},
15};
16use chacha20::ChaCha8Rng;
17use rand::{RngExt, SeedableRng};
18
19use crate::radio::{feathers_option_buttons, main_ui_node_scene, RadioButtonOptionValue};
20
21#[path = "../helpers/radio.rs"]
22mod radio;
23
24/// The demonstration textures that we use.
25///
26/// We cache these for efficiency.
27#[derive(Resource)]
28struct AppTextures {
29    /// The base color that all our decals have (the Bevy logo).
30    decal_base_color_texture: Handle<Image>,
31
32    /// A normal map that all our decals have.
33    ///
34    /// This provides a nice raised embossed look.
35    decal_normal_map_texture: Handle<Image>,
36
37    /// The metallic-roughness map that all our decals have.
38    ///
39    /// Metallic is in the blue channel and roughness is in the green channel,
40    /// like glTF requires.
41    decal_metallic_roughness_map_texture: Handle<Image>,
42
43    /// The emissive texture that can optionally be enabled.
44    ///
45    /// This causes the white bird to glow.
46    decal_emissive_texture: Handle<Image>,
47}
48
49impl FromWorld for AppTextures {
50    fn from_world(world: &mut World) -> Self {
51        // Load all the decal textures.
52        let asset_server = world.resource::<AssetServer>();
53        AppTextures {
54            decal_base_color_texture: asset_server.load("branding/bevy_bird_dark.png"),
55            decal_normal_map_texture: asset_server
56                .load_builder()
57                .with_settings(|settings: &mut ImageLoaderSettings| settings.is_srgb = false)
58                .load(get_web_asset_url("BevyLogo-Normal.png")),
59            decal_metallic_roughness_map_texture: asset_server
60                .load_builder()
61                .with_settings(|settings: &mut ImageLoaderSettings| settings.is_srgb = false)
62                .load(get_web_asset_url("BevyLogo-MetallicRoughness.png")),
63            decal_emissive_texture: asset_server.load(get_web_asset_url("BevyLogo-Emissive.png")),
64        }
65    }
66}
67
68/// A component that we place on our decals to track them for animation
69/// purposes.
70#[derive(Component)]
71struct ExampleDecal {
72    /// The width and height of the square decal in meters.
73    size: f32,
74    /// What state the decal is in (animating in, idling, or animating out).
75    state: ExampleDecalState,
76}
77
78/// The animation state of a decal.
79///
80/// When each [`Timer`] goes off, the decal advances to the next state.
81enum ExampleDecalState {
82    /// The decal has just been spawned and is animating in.
83    AnimatingIn(Timer),
84    /// The decal has animated in and is waiting to animate out.
85    Idling(Timer),
86    /// The decal is animating out.
87    ///
88    /// When this timer expires, the decal is despawned.
89    AnimatingOut(Timer),
90}
91
92/// All settings that the user can change.
93///
94/// This app only has one: whether newly-spawned decals are emissive.
95#[derive(Clone, Copy, Component, PartialEq)]
96enum AppSetting {
97    /// True if newly-spawned decals have an emissive channel (i.e. they glow),
98    /// or false otherwise.
99    EmissiveDecals(bool),
100}
101
102impl Default for AppSetting {
103    fn default() -> Self {
104        Self::EmissiveDecals(false)
105    }
106}
107
108/// The current values of the settings that the user can change.
109///
110/// This app only has one: whether newly-spawned decals are emissive.
111#[derive(Default, Resource)]
112struct AppStatus {
113    /// True if newly-spawned decals have an emissive channel (i.e. they glow),
114    /// or false otherwise.
115    emissive_decals: bool,
116}
117
118/// Half of the width and height of the plane onto which the decals are
119/// projected.
120const PLANE_HALF_SIZE: f32 = 2.0;
121/// The minimum width and height that a decal may have.
122///
123/// The actual size is determined randomly, using this value as a lower bound.
124const DECAL_MIN_SIZE: f32 = 0.5;
125/// The maximum width and height that a decal may have.
126///
127/// The actual size is determined randomly, using this value as an upper bound.
128const DECAL_MAX_SIZE: f32 = 1.5;
129
130/// How long it takes the decal to grow to its full size when animating in.
131const DECAL_ANIMATE_IN_DURATION: Duration = Duration::from_millis(300);
132/// How long a decal stays in the idle state before starting to animate out.
133const DECAL_IDLE_DURATION: Duration = Duration::from_secs(10);
134/// How long it takes the decal to shrink down to nothing when animating out.
135const DECAL_ANIMATE_OUT_DURATION: Duration = Duration::from_millis(300);
136
137/// The demo entry point.
138fn main() {
139    App::new()
140        .add_plugins((
141            DefaultPlugins
142                .set(WebAssetPlugin {
143                    silence_startup_warning: true,
144                })
145                .set(WindowPlugin {
146                    primary_window: Some(Window {
147                        title: "Bevy Clustered Decal Maps Example".into(),
148                        ..default()
149                    }),
150                    ..default()
151                }),
152            FeathersPlugins,
153        ))
154        .init_resource::<AppStatus>()
155        .init_resource::<AppTextures>()
156        .insert_resource(UiTheme(create_dark_theme()))
157        .add_systems(Startup, setup)
158        .add_systems(Update, draw_gizmos)
159        .add_systems(Update, spawn_decal)
160        .add_systems(Update, animate_decals)
161        .add_observer(handle_emission_type_change)
162        .add_observer(radio_self_update)
163        .insert_resource(SeededRng(ChaCha8Rng::seed_from_u64(19878367467712)))
164        .run();
165}
166
167#[derive(Resource)]
168struct SeededRng(ChaCha8Rng);
169
170/// Spawns all the objects in the scene.
171fn setup(
172    mut commands: Commands,
173    asset_server: Res<AssetServer>,
174    mut meshes: ResMut<Assets<Mesh>>,
175    mut materials: ResMut<Assets<StandardMaterial>>,
176) {
177    spawn_plane_mesh(&mut commands, &asset_server, &mut meshes, &mut materials);
178    spawn_light(&mut commands);
179    spawn_camera(&mut commands);
180    spawn_buttons(&mut commands);
181}
182
183/// Spawns the plane onto which the decals are projected.
184fn spawn_plane_mesh(
185    commands: &mut Commands,
186    asset_server: &AssetServer,
187    meshes: &mut Assets<Mesh>,
188    materials: &mut Assets<StandardMaterial>,
189) {
190    // Create a plane onto which we project decals.
191    //
192    // As the plane has a normal map, we must generate tangents for the
193    // vertices.
194    let plane_mesh = meshes.add(
195        Plane3d {
196            normal: Dir3::NEG_Z,
197            half_size: Vec2::splat(PLANE_HALF_SIZE),
198        }
199        .mesh()
200        .build()
201        .with_duplicated_vertices()
202        .with_computed_flat_normals()
203        .with_generated_tangents()
204        .unwrap(),
205    );
206
207    // Give the plane some texture.
208    //
209    // Note that, as this is a normal map, we must disable sRGB when loading.
210    let normal_map_texture = asset_server
211        .load_builder()
212        .with_settings(|settings: &mut ImageLoaderSettings| settings.is_srgb = false)
213        .load("textures/ScratchedGold-Normal.png");
214
215    // Actually spawn the plane.
216    commands.spawn((
217        Mesh3d(plane_mesh),
218        MeshMaterial3d(materials.add(StandardMaterial {
219            base_color: Color::from(CRIMSON),
220            normal_map_texture: Some(normal_map_texture),
221            ..StandardMaterial::default()
222        })),
223        Transform::IDENTITY,
224    ));
225}
226
227/// Spawns a light to illuminate the scene.
228fn spawn_light(commands: &mut Commands) {
229    commands.spawn((
230        PointLight {
231            intensity: 10_000_000.,
232            range: 100.0,
233            ..default()
234        },
235        Transform::from_xyz(8.0, 16.0, -8.0),
236    ));
237}
238
239/// Spawns a camera.
240fn spawn_camera(commands: &mut Commands) {
241    commands.spawn((
242        Camera3d::default(),
243        Transform::from_xyz(2.0, 0.0, -7.0).looking_at(Vec3::ZERO, Vec3::Y),
244        Hdr,
245    ));
246}
247
248/// Spawns all the buttons at the bottom of the screen.
249fn spawn_buttons(commands: &mut Commands) {
250    commands.spawn_scene(bsn! {
251        @main_ui_node_scene()
252        Children [
253            @feathers_option_buttons(
254                "Emissive Decals",
255                &[
256                    (AppSetting::EmissiveDecals(false), "Off"),
257                    (AppSetting::EmissiveDecals(true), "On"),
258                ],
259                0,
260            )
261        ]
262    });
263}
264
265/// Draws the outlines that show the bounds of the clustered decals.
266fn draw_gizmos(mut gizmos: Gizmos, decals: Query<&GlobalTransform, With<ClusteredDecal>>) {
267    for global_transform in &decals {
268        gizmos.primitive_3d(
269            &Cuboid {
270                // Since the clustered decal is a 1×1×1 cube in model space, its
271                // half-size is half of the scaling part of its transform.
272                half_size: global_transform.scale() * 0.5,
273            },
274            Isometry3d {
275                rotation: global_transform.rotation(),
276                translation: global_transform.translation_vec3a(),
277            },
278            GOLD,
279        );
280    }
281}
282
283/// A system that spawns new decals at fixed intervals.
284fn spawn_decal(
285    mut commands: Commands,
286    app_status: Res<AppStatus>,
287    app_textures: Res<AppTextures>,
288    time: Res<Time>,
289    mut decal_spawn_timer: Local<Option<Timer>>,
290    mut seeded_rng: ResMut<SeededRng>,
291) {
292    // Tick the decal spawn timer. Check to see if we should spawn a new decal,
293    // and bail out if it's not yet time to.
294    let decal_spawn_timer = decal_spawn_timer
295        .get_or_insert_with(|| Timer::new(Duration::from_millis(1000), TimerMode::Repeating));
296    decal_spawn_timer.tick(time.delta());
297    if !decal_spawn_timer.just_finished() {
298        return;
299    }
300
301    // Generate a random position along the plane.
302    let decal_position = vec3(
303        seeded_rng.0.random_range(-PLANE_HALF_SIZE..PLANE_HALF_SIZE),
304        seeded_rng.0.random_range(-PLANE_HALF_SIZE..PLANE_HALF_SIZE),
305        0.0,
306    );
307
308    // Generate a random size for the decal.
309    let decal_size = seeded_rng.0.random_range(DECAL_MIN_SIZE..DECAL_MAX_SIZE);
310
311    // Generate a random rotation for the decal.
312    let theta = seeded_rng.0.random_range(0.0f32..PI);
313
314    // Now spawn the decal.
315    commands.spawn((
316        // Apply the textures.
317        ClusteredDecal {
318            base_color_texture: Some(app_textures.decal_base_color_texture.clone()),
319            normal_map_texture: Some(app_textures.decal_normal_map_texture.clone()),
320            metallic_roughness_texture: Some(
321                app_textures.decal_metallic_roughness_map_texture.clone(),
322            ),
323            emissive_texture: if app_status.emissive_decals {
324                Some(app_textures.decal_emissive_texture.clone())
325            } else {
326                None
327            },
328            ..ClusteredDecal::default()
329        },
330        // Spawn the decal at the right place. Note that the scale is initially
331        // zero; we'll animate it later.
332        Transform::from_translation(decal_position)
333            .with_scale(Vec3::ZERO)
334            .looking_to(Vec3::Z, Vec3::ZERO.with_xy(Vec2::from_angle(theta))),
335        // Create the component that tracks the animation state.
336        ExampleDecal {
337            size: decal_size,
338            state: ExampleDecalState::AnimatingIn(Timer::new(
339                DECAL_ANIMATE_IN_DURATION,
340                TimerMode::Once,
341            )),
342        },
343    ));
344}
345
346/// A system that animates the decals growing as they enter and shrinking as
347/// they leave.
348fn animate_decals(
349    mut commands: Commands,
350    mut decals_query: Query<(Entity, &mut ExampleDecal, &mut Transform)>,
351    time: Res<Time>,
352) {
353    for (decal_entity, mut example_decal, mut decal_transform) in decals_query.iter_mut() {
354        // Update the animation timers, and advance the animation state if the
355        // timer has expired.
356        match example_decal.state {
357            ExampleDecalState::AnimatingIn(ref mut timer) => {
358                timer.tick(time.delta());
359                if timer.just_finished() {
360                    example_decal.state =
361                        ExampleDecalState::Idling(Timer::new(DECAL_IDLE_DURATION, TimerMode::Once));
362                }
363            }
364            ExampleDecalState::Idling(ref mut timer) => {
365                timer.tick(time.delta());
366                if timer.just_finished() {
367                    example_decal.state = ExampleDecalState::AnimatingOut(Timer::new(
368                        DECAL_ANIMATE_OUT_DURATION,
369                        TimerMode::Once,
370                    ));
371                }
372            }
373            ExampleDecalState::AnimatingOut(ref mut timer) => {
374                timer.tick(time.delta());
375                if timer.just_finished() {
376                    commands.entity(decal_entity).despawn();
377                    continue;
378                }
379            }
380        }
381
382        // Actually animate the decal by adjusting its transform.
383        // All we have to do here is to compute the decal's scale as a fraction
384        // of its full size.
385        let new_decal_scale_factor = match example_decal.state {
386            ExampleDecalState::AnimatingIn(ref timer) => timer.fraction(),
387            ExampleDecalState::Idling(_) => 1.0,
388            ExampleDecalState::AnimatingOut(ref timer) => timer.fraction_remaining(),
389        };
390        decal_transform.scale =
391            Vec3::splat(example_decal.size * new_decal_scale_factor).with_z(1.0);
392    }
393}
394
395/// Handles the user's clicks on the radio button that determines whether the
396/// newly-spawned decals have an emissive map.
397fn handle_emission_type_change(
398    event: On<ValueChange<Entity>>,
399    new_value_q: Query<&RadioButtonOptionValue<AppSetting>>,
400    mut app_status: ResMut<AppStatus>,
401) {
402    let Ok(RadioButtonOptionValue(setting)) = new_value_q.get(event.value) else {
403        return;
404    };
405
406    let AppSetting::EmissiveDecals(on) = *setting;
407    app_status.emissive_decals = on;
408}
409
410/// Returns the GitHub download URL for the given asset.
411///
412/// The files are expected to be in the `clustered_decal_maps` directory in the
413/// [repository].
414///
415/// [repository]: https://github.com/bevyengine/bevy_asset_files
416fn get_web_asset_url(name: &str) -> String {
417    format!(
418        "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/\
419clustered_decal_maps/{}",
420        name
421    )
422}