docs/bevy/examples/3d/clustered_decal_maps.rs 14.2 K · 422 lines · raw · history

1 //! Demonstrates the normal map, metallic-roughness map, and emissive features
2 //! of clustered decals.
3
4 use std::{f32::consts::PI, time::Duration};
5
6 use 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 };
16 use chacha20::ChaCha8Rng;
17 use rand::{RngExt, SeedableRng};
18
19 use crate::radio::{feathers_option_buttons, main_ui_node_scene, RadioButtonOptionValue};
20
21 #[path = "../helpers/radio.rs"]
22 mod radio;
23
24 /// The demonstration textures that we use.
25 ///
26 /// We cache these for efficiency.
27 #[derive(Resource)]
28 struct 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
49 impl 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)]
71 struct 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.
81 enum 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)]
96 enum AppSetting {
97 /// True if newly-spawned decals have an emissive channel (i.e. they glow),
98 /// or false otherwise.
99 EmissiveDecals(bool),
100 }
101
102 impl 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)]
112 struct 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.
120 const 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.
124 const 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.
128 const DECAL_MAX_SIZE: f32 = 1.5;
129
130 /// How long it takes the decal to grow to its full size when animating in.
131 const DECAL_ANIMATE_IN_DURATION: Duration = Duration::from_millis(300);
132 /// How long a decal stays in the idle state before starting to animate out.
133 const DECAL_IDLE_DURATION: Duration = Duration::from_secs(10);
134 /// How long it takes the decal to shrink down to nothing when animating out.
135 const DECAL_ANIMATE_OUT_DURATION: Duration = Duration::from_millis(300);
136
137 /// The demo entry point.
138 fn 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)]
168 struct SeededRng(ChaCha8Rng);
169
170 /// Spawns all the objects in the scene.
171 fn 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.
184 fn 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.
228 fn 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.
240 fn 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.
249 fn 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.
266 fn 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.
284 fn 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.
348 fn 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.
397 fn 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
416 fn get_web_asset_url(name: &str) -> String {
417 format!(
418 "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/\
419 clustered_decal_maps/{}",
420 name
421 )
422 }