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    image::ImageLoaderSettings,
11    light::ClusteredDecal,
12    prelude::*,
13};
14use chacha20::ChaCha8Rng;
15use rand::{RngExt, SeedableRng};
16
17use crate::widgets::{RadioButton, RadioButtonText, WidgetClickEvent, WidgetClickSender};
18
19#[path = "../helpers/widgets.rs"]
20mod widgets;
21
22/// The demonstration textures that we use.
23///
24/// We cache these for efficiency.
25#[derive(Resource)]
26struct AppTextures {
27    /// The base color that all our decals have (the Bevy logo).
28    decal_base_color_texture: Handle<Image>,
29
30    /// A normal map that all our decals have.
31    ///
32    /// This provides a nice raised embossed look.
33    decal_normal_map_texture: Handle<Image>,
34
35    /// The metallic-roughness map that all our decals have.
36    ///
37    /// Metallic is in the blue channel and roughness is in the green channel,
38    /// like glTF requires.
39    decal_metallic_roughness_map_texture: Handle<Image>,
40
41    /// The emissive texture that can optionally be enabled.
42    ///
43    /// This causes the white bird to glow.
44    decal_emissive_texture: Handle<Image>,
45}
46
47impl FromWorld for AppTextures {
48    fn from_world(world: &mut World) -> Self {
49        // Load all the decal textures.
50        let asset_server = world.resource::<AssetServer>();
51        AppTextures {
52            decal_base_color_texture: asset_server.load("branding/bevy_bird_dark.png"),
53            decal_normal_map_texture: asset_server
54                .load_builder()
55                .with_settings(|settings: &mut ImageLoaderSettings| settings.is_srgb = false)
56                .load(get_web_asset_url("BevyLogo-Normal.png")),
57            decal_metallic_roughness_map_texture: asset_server
58                .load_builder()
59                .with_settings(|settings: &mut ImageLoaderSettings| settings.is_srgb = false)
60                .load(get_web_asset_url("BevyLogo-MetallicRoughness.png")),
61            decal_emissive_texture: asset_server.load(get_web_asset_url("BevyLogo-Emissive.png")),
62        }
63    }
64}
65
66/// A component that we place on our decals to track them for animation
67/// purposes.
68#[derive(Component)]
69struct ExampleDecal {
70    /// The width and height of the square decal in meters.
71    size: f32,
72    /// What state the decal is in (animating in, idling, or animating out).
73    state: ExampleDecalState,
74}
75
76/// The animation state of a decal.
77///
78/// When each [`Timer`] goes off, the decal advances to the next state.
79enum ExampleDecalState {
80    /// The decal has just been spawned and is animating in.
81    AnimatingIn(Timer),
82    /// The decal has animated in and is waiting to animate out.
83    Idling(Timer),
84    /// The decal is animating out.
85    ///
86    /// When this timer expires, the decal is despawned.
87    AnimatingOut(Timer),
88}
89
90/// All settings that the user can change.
91///
92/// This app only has one: whether newly-spawned decals are emissive.
93#[derive(Clone, Copy, PartialEq)]
94enum AppSetting {
95    /// True if newly-spawned decals have an emissive channel (i.e. they glow),
96    /// or false otherwise.
97    EmissiveDecals(bool),
98}
99
100/// The current values of the settings that the user can change.
101///
102/// This app only has one: whether newly-spawned decals are emissive.
103#[derive(Default, Resource)]
104struct AppStatus {
105    /// True if newly-spawned decals have an emissive channel (i.e. they glow),
106    /// or false otherwise.
107    emissive_decals: bool,
108}
109
110/// Half of the width and height of the plane onto which the decals are
111/// projected.
112const PLANE_HALF_SIZE: f32 = 2.0;
113/// The minimum width and height that a decal may have.
114///
115/// The actual size is determined randomly, using this value as a lower bound.
116const DECAL_MIN_SIZE: f32 = 0.5;
117/// The maximum width and height that a decal may have.
118///
119/// The actual size is determined randomly, using this value as an upper bound.
120const DECAL_MAX_SIZE: f32 = 1.5;
121
122/// How long it takes the decal to grow to its full size when animating in.
123const DECAL_ANIMATE_IN_DURATION: Duration = Duration::from_millis(300);
124/// How long a decal stays in the idle state before starting to animate out.
125const DECAL_IDLE_DURATION: Duration = Duration::from_secs(10);
126/// How long it takes the decal to shrink down to nothing when animating out.
127const DECAL_ANIMATE_OUT_DURATION: Duration = Duration::from_millis(300);
128
129/// The demo entry point.
130fn main() {
131    App::new()
132        .add_plugins(
133            DefaultPlugins
134                .set(WebAssetPlugin {
135                    silence_startup_warning: true,
136                })
137                .set(WindowPlugin {
138                    primary_window: Some(Window {
139                        title: "Bevy Clustered Decal Maps Example".into(),
140                        ..default()
141                    }),
142                    ..default()
143                }),
144        )
145        .add_message::<WidgetClickEvent<AppSetting>>()
146        .init_resource::<AppStatus>()
147        .init_resource::<AppTextures>()
148        .add_systems(Startup, setup)
149        .add_systems(Update, draw_gizmos)
150        .add_systems(Update, spawn_decal)
151        .add_systems(Update, animate_decals)
152        .add_systems(
153            Update,
154            (
155                widgets::handle_ui_interactions::<AppSetting>,
156                update_radio_buttons,
157            ),
158        )
159        .add_systems(
160            Update,
161            handle_emission_type_change.after(widgets::handle_ui_interactions::<AppSetting>),
162        )
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((
251        widgets::main_ui_node(),
252        children![widgets::option_buttons(
253            "Emissive Decals",
254            &[
255                (AppSetting::EmissiveDecals(true), "On"),
256                (AppSetting::EmissiveDecals(false), "Off"),
257            ],
258        ),],
259    ));
260}
261
262/// Draws the outlines that show the bounds of the clustered decals.
263fn draw_gizmos(mut gizmos: Gizmos, decals: Query<&GlobalTransform, With<ClusteredDecal>>) {
264    for global_transform in &decals {
265        gizmos.primitive_3d(
266            &Cuboid {
267                // Since the clustered decal is a 1×1×1 cube in model space, its
268                // half-size is half of the scaling part of its transform.
269                half_size: global_transform.scale() * 0.5,
270            },
271            Isometry3d {
272                rotation: global_transform.rotation(),
273                translation: global_transform.translation_vec3a(),
274            },
275            GOLD,
276        );
277    }
278}
279
280/// A system that spawns new decals at fixed intervals.
281fn spawn_decal(
282    mut commands: Commands,
283    app_status: Res<AppStatus>,
284    app_textures: Res<AppTextures>,
285    time: Res<Time>,
286    mut decal_spawn_timer: Local<Option<Timer>>,
287    mut seeded_rng: ResMut<SeededRng>,
288) {
289    // Tick the decal spawn timer. Check to see if we should spawn a new decal,
290    // and bail out if it's not yet time to.
291    let decal_spawn_timer = decal_spawn_timer
292        .get_or_insert_with(|| Timer::new(Duration::from_millis(1000), TimerMode::Repeating));
293    decal_spawn_timer.tick(time.delta());
294    if !decal_spawn_timer.just_finished() {
295        return;
296    }
297
298    // Generate a random position along the plane.
299    let decal_position = vec3(
300        seeded_rng.0.random_range(-PLANE_HALF_SIZE..PLANE_HALF_SIZE),
301        seeded_rng.0.random_range(-PLANE_HALF_SIZE..PLANE_HALF_SIZE),
302        0.0,
303    );
304
305    // Generate a random size for the decal.
306    let decal_size = seeded_rng.0.random_range(DECAL_MIN_SIZE..DECAL_MAX_SIZE);
307
308    // Generate a random rotation for the decal.
309    let theta = seeded_rng.0.random_range(0.0f32..PI);
310
311    // Now spawn the decal.
312    commands.spawn((
313        // Apply the textures.
314        ClusteredDecal {
315            base_color_texture: Some(app_textures.decal_base_color_texture.clone()),
316            normal_map_texture: Some(app_textures.decal_normal_map_texture.clone()),
317            metallic_roughness_texture: Some(
318                app_textures.decal_metallic_roughness_map_texture.clone(),
319            ),
320            emissive_texture: if app_status.emissive_decals {
321                Some(app_textures.decal_emissive_texture.clone())
322            } else {
323                None
324            },
325            ..ClusteredDecal::default()
326        },
327        // Spawn the decal at the right place. Note that the scale is initially
328        // zero; we'll animate it later.
329        Transform::from_translation(decal_position)
330            .with_scale(Vec3::ZERO)
331            .looking_to(Vec3::Z, Vec3::ZERO.with_xy(Vec2::from_angle(theta))),
332        // Create the component that tracks the animation state.
333        ExampleDecal {
334            size: decal_size,
335            state: ExampleDecalState::AnimatingIn(Timer::new(
336                DECAL_ANIMATE_IN_DURATION,
337                TimerMode::Once,
338            )),
339        },
340    ));
341}
342
343/// A system that animates the decals growing as they enter and shrinking as
344/// they leave.
345fn animate_decals(
346    mut commands: Commands,
347    mut decals_query: Query<(Entity, &mut ExampleDecal, &mut Transform)>,
348    time: Res<Time>,
349) {
350    for (decal_entity, mut example_decal, mut decal_transform) in decals_query.iter_mut() {
351        // Update the animation timers, and advance the animation state if the
352        // timer has expired.
353        match example_decal.state {
354            ExampleDecalState::AnimatingIn(ref mut timer) => {
355                timer.tick(time.delta());
356                if timer.just_finished() {
357                    example_decal.state =
358                        ExampleDecalState::Idling(Timer::new(DECAL_IDLE_DURATION, TimerMode::Once));
359                }
360            }
361            ExampleDecalState::Idling(ref mut timer) => {
362                timer.tick(time.delta());
363                if timer.just_finished() {
364                    example_decal.state = ExampleDecalState::AnimatingOut(Timer::new(
365                        DECAL_ANIMATE_OUT_DURATION,
366                        TimerMode::Once,
367                    ));
368                }
369            }
370            ExampleDecalState::AnimatingOut(ref mut timer) => {
371                timer.tick(time.delta());
372                if timer.just_finished() {
373                    commands.entity(decal_entity).despawn();
374                    continue;
375                }
376            }
377        }
378
379        // Actually animate the decal by adjusting its transform.
380        // All we have to do here is to compute the decal's scale as a fraction
381        // of its full size.
382        let new_decal_scale_factor = match example_decal.state {
383            ExampleDecalState::AnimatingIn(ref timer) => timer.fraction(),
384            ExampleDecalState::Idling(_) => 1.0,
385            ExampleDecalState::AnimatingOut(ref timer) => timer.fraction_remaining(),
386        };
387        decal_transform.scale =
388            Vec3::splat(example_decal.size * new_decal_scale_factor).with_z(1.0);
389    }
390}
391
392/// Updates the appearance of the radio buttons to reflect the current
393/// application status.
394fn update_radio_buttons(
395    mut widgets: Query<
396        (
397            Entity,
398            Option<&mut BackgroundColor>,
399            Has<Text>,
400            &WidgetClickSender<AppSetting>,
401        ),
402        Or<(With<RadioButton>, With<RadioButtonText>)>,
403    >,
404    app_status: Res<AppStatus>,
405    mut writer: TextUiWriter,
406) {
407    for (entity, image, has_text, sender) in widgets.iter_mut() {
408        // We only have one setting in this particular application.
409        let selected = match **sender {
410            AppSetting::EmissiveDecals(emissive_decals) => {
411                emissive_decals == app_status.emissive_decals
412            }
413        };
414
415        if let Some(mut bg_color) = image {
416            // Update the colors of the button itself.
417            widgets::update_ui_radio_button(&mut bg_color, selected);
418        }
419        if has_text {
420            // Update the colors of the button text.
421            widgets::update_ui_radio_button_text(entity, &mut writer, selected);
422        }
423    }
424}
425
426/// Handles the user's clicks on the radio button that determines whether the
427/// newly-spawned decals have an emissive map.
428fn handle_emission_type_change(
429    mut app_status: ResMut<AppStatus>,
430    mut events: MessageReader<WidgetClickEvent<AppSetting>>,
431) {
432    for event in events.read() {
433        let AppSetting::EmissiveDecals(on) = **event;
434        app_status.emissive_decals = on;
435    }
436}
437
438/// Returns the GitHub download URL for the given asset.
439///
440/// The files are expected to be in the `clustered_decal_maps` directory in the
441/// [repository].
442///
443/// [repository]: https://github.com/bevyengine/bevy_asset_files
444fn get_web_asset_url(name: &str) -> String {
445    format!(
446        "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/\
447clustered_decal_maps/{}",
448        name
449    )
450}