Skip to content

Commit 185712f

Browse files
authored
Add support for normal maps, metallic-roughness maps, and emissive maps to clustered decals. (#22039)
This commit expands the number of textures associated with each clustered decal from 1 to 4. The additional 3 textures apply normal maps, metallic-roughness maps, and emissive maps respectively to the surfaces onto which decals are projected. Normal maps are combined using the [*Whiteout* blending method] from SIGGRAPH 2007. This approach was chosen because, subjectively, it appeared better than the more complex [*reoriented normal mapping* (RNM)] approach. Additionally, *Whiteout* normal map blending is commutative and associative, unlike RNM, which is a useful property for our decals, which are currently applied in an unspecified order. (The fact that the order in which our decals are applied is unspecified is unfortunate, but is a long-standing issue and should probably be fixed in a followup.) In particular, commutativity is desirable because otherwise one must specify which normal map is the *base* normal map and which normal map is the *detail* normal map, but that's not a policy decision that Bevy can unconditionally make, as decals aren't necessary more detailed than the base normal map. (For instance, consider a bullet hole decal embedded in a wall with a subtle rough texture; one might reasonably argue that the base material's normal map is the detail map and the bullet hole is the base map, even though the bullet hole's normal map comes from a decal.) Note that, with a custom material shader, it's possible for application code to use the decal images for arbitrary other purposes. For example, with a custom shader an application might use the metallic-roughness map as a clearcoat map instead if it has no need for a metallic-roughness map on a decal. And, of course, a custom material shader could adopt RNM blending for decals if it wishes. A new example, `clustered_decal_maps`, has been added. This example demonstrates the new maps by spawning clustered decals with maps randomly over time and projecting them onto a wall. <img width="2564" height="1500" alt="Screenshot 2025-12-05 095953" src="https://github.com/user-attachments/assets/255fca64-2b42-4794-a367-14336d023310" />
1 parent 695d9d4 commit 185712f

File tree

12 files changed

+760
-77
lines changed

12 files changed

+760
-77
lines changed

Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4900,3 +4900,15 @@ name = "Pan Camera"
49004900
description = "Example Pan-Camera Styled Camera Controller for 2D scenes"
49014901
category = "Camera"
49024902
wasm = true
4903+
4904+
[[example]]
4905+
name = "clustered_decal_maps"
4906+
path = "examples/3d/clustered_decal_maps.rs"
4907+
doc-scrape-examples = true
4908+
required-features = ["pbr_clustered_decals", "https"]
4909+
4910+
[package.metadata.example.clustered_decal_maps]
4911+
name = "Clustered Decal Maps"
4912+
description = "Demonstrates normal and metallic-roughness maps of decals"
4913+
category = "3D Rendering"
4914+
wasm = false

assets/shaders/custom_clustered_decal.wgsl

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,7 @@ fn fragment(
2222
pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color);
2323

2424
// Apply the normal decals.
25-
pbr_input.material.base_color = clustered::apply_decal_base_color(
26-
in.world_position.xyz,
27-
in.position.xy,
28-
pbr_input.material.base_color
29-
);
25+
clustered::apply_decals(&pbr_input);
3026

3127
// Here we tint the color based on the tag of the decal.
3228
// We could optionally do other things, such as adjust the normal based on a normal map.
@@ -42,7 +38,7 @@ fn fragment(
4238
);
4339
while (clustered::clustered_decal_iterator_next(&decal_iterator)) {
4440
var decal_base_color = textureSampleLevel(
45-
mesh_view_bindings::clustered_decal_textures[decal_iterator.texture_index],
41+
mesh_view_bindings::clustered_decal_textures[decal_iterator.base_color_texture_index],
4642
mesh_view_bindings::clustered_decal_sampler,
4743
decal_iterator.uv,
4844
0.0

crates/bevy_light/src/cluster/mod.rs

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,26 +148,71 @@ pub struct ClusterableObjectCounts {
148148
/// An object that projects a decal onto surfaces within its bounds.
149149
///
150150
/// Conceptually, a clustered decal is a 1×1×1 cube centered on its origin. It
151-
/// projects the given [`Self::image`] onto surfaces in the -Z direction (thus
152-
/// you may find [`Transform::looking_at`] useful).
151+
/// projects its images onto surfaces in the -Z direction (thus you may find
152+
/// [`Transform::looking_at`] useful).
153+
///
154+
/// Each decal may project any of a base color texture, a normal map, a
155+
/// metallic/roughness map, and/or a texture that specifies emissive light. In
156+
/// addition, you may associate an arbitrary integer [`Self::tag`] with each
157+
/// clustered decal, which Bevy doesn't use, but that you can use in your
158+
/// shaders in order to associate application-specific data with your decals.
153159
///
154160
/// Clustered decals are the highest-quality types of decals that Bevy supports,
155161
/// but they require bindless textures. This means that they presently can't be
156162
/// used on WebGL 2, WebGPU, macOS, or iOS. Bevy's clustered decals can be used
157163
/// with forward or deferred rendering and don't require a prepass.
158-
#[derive(Component, Debug, Clone, Reflect)]
159-
#[reflect(Component, Debug, Clone)]
164+
#[derive(Component, Debug, Clone, Default, Reflect)]
165+
#[reflect(Component, Debug, Clone, Default)]
160166
#[require(Transform, Visibility, VisibilityClass)]
161167
#[component(on_add = visibility::add_visibility_class::<ClusterVisibilityClass>)]
162168
pub struct ClusteredDecal {
163-
/// The image that the clustered decal projects.
169+
/// The image that the clustered decal projects onto the base color of the
170+
/// surface material.
164171
///
165172
/// This must be a 2D image. If it has an alpha channel, it'll be alpha
166173
/// blended with the underlying surface and/or other decals. All decal
167174
/// images in the scene must use the same sampler.
168-
pub image: Handle<Image>,
175+
pub base_color_texture: Option<Handle<Image>>,
176+
177+
/// The normal map that the clustered decal projects onto surfaces.
178+
///
179+
/// Bevy uses the *Whiteout* method to combine normal maps from decals with
180+
/// any normal map that the surface has, as described in the
181+
/// [*Blending in Detail* article].
182+
///
183+
/// Note that the normal map must be three-channel and must be in OpenGL
184+
/// format, not DirectX format. That is, the green channel must point up,
185+
/// not down.
186+
///
187+
/// [*Blending in Detail* article]: https://blog.selfshadow.com/publications/blending-in-detail/
188+
pub normal_map_texture: Option<Handle<Image>>,
189+
190+
/// The metallic-roughness map that the clustered decal projects onto
191+
/// surfaces.
192+
///
193+
/// Metallic and roughness PBR parameters are blended onto the base surface
194+
/// using the alpha channel of the base color.
195+
///
196+
/// Metallic is expected to be in the blue channel, while roughness is
197+
/// expected to be in the green channel, following glTF conventions.
198+
pub metallic_roughness_texture: Option<Handle<Image>>,
199+
200+
/// The emissive map that the clustered decal projects onto surfaces.
201+
///
202+
/// Including this texture effectively causes the decal to glow. The
203+
/// emissive component is blended onto the surface according to the alpha
204+
/// channel.
205+
pub emissive_texture: Option<Handle<Image>>,
169206

170-
/// An application-specific tag you can use for any purpose you want.
207+
/// An application-specific tag you can use for any purpose you want, in
208+
/// conjunction with a custom shader.
209+
///
210+
/// This value is exposed to the shader via the iterator API
211+
/// (`bevy_pbr::decal::clustered::clustered_decal_iterator_new` and
212+
/// `bevy_pbr::decal::clustered::clustered_decal_iterator_next`).
213+
///
214+
/// For example, you might use the tag to restrict the set of surfaces to
215+
/// which a decal can be rendered.
171216
///
172217
/// See the `clustered_decals` example for an example of use.
173218
pub tag: u32,

crates/bevy_pbr/src/decal/clustered.rs

Lines changed: 117 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@
99
//! used on WebGL 2 or WebGPU. Bevy's clustered decals can be used
1010
//! with forward or deferred rendering and don't require a prepass.
1111
//!
12-
//! On their own, clustered decals only project the base color of a texture. You
13-
//! can, however, use the built-in *tag* field to customize the appearance of a
14-
//! clustered decal arbitrarily. See the documentation in `clustered.wgsl` for
15-
//! more information and the `clustered_decals` example for an example of use.
12+
//! Each clustered decal may contain up to 4 textures. By default, the 4
13+
//! textures correspond to the base color, a normal map, a metallic-roughness
14+
//! map, and an emissive map respectively. However, with a custom shader, you
15+
//! can use these 4 textures for whatever you wish. Additionally, you can use
16+
//! the built-in *tag* field to store additional application-specific data; by
17+
//! reading the tag in the shader, you can modify the appearance of a clustered
18+
//! decal arbitrarily. See the documentation in `clustered.wgsl` for more
19+
//! information and the `clustered_decals` example for an example of use.
1620
1721
use core::{num::NonZero, ops::Deref};
1822

1923
use bevy_app::{App, Plugin};
20-
use bevy_asset::AssetId;
24+
use bevy_asset::{AssetId, Handle};
2125
use bevy_camera::visibility::ViewVisibility;
2226
use bevy_derive::{Deref, DerefMut};
2327
use bevy_ecs::{
@@ -50,6 +54,9 @@ use bytemuck::{Pod, Zeroable};
5054

5155
use crate::{binding_arrays_are_usable, prepare_lights, GlobalClusterableObjectMeta};
5256

57+
/// The number of textures that can be associated with each clustered decal.
58+
const IMAGES_PER_DECAL: usize = 4;
59+
5360
/// A plugin that adds support for clustered decals.
5461
///
5562
/// In environments where bindless textures aren't available, clustered decals
@@ -66,7 +73,7 @@ pub struct RenderClusteredDecals {
6673
/// Maps a decal image to the shader binding array.
6774
///
6875
/// [`Self::binding_index_to_textures`] holds the inverse mapping.
69-
texture_to_binding_index: HashMap<AssetId<Image>, u32>,
76+
texture_to_binding_index: HashMap<AssetId<Image>, i32>,
7077
/// The information concerning each decal that we provide to the shader.
7178
decals: Vec<RenderClusteredDecal>,
7279
/// Maps the [`bevy_render::sync_world::RenderEntity`] of each decal to the
@@ -87,18 +94,22 @@ impl RenderClusteredDecals {
8794
pub fn insert_decal(
8895
&mut self,
8996
entity: Entity,
90-
image: &AssetId<Image>,
97+
images: [Option<AssetId<Image>>; IMAGES_PER_DECAL],
9198
local_from_world: Mat4,
9299
tag: u32,
93100
) {
94-
let image_index = self.get_or_insert_image(image);
101+
let image_indices = images.map(|maybe_image_id| match maybe_image_id {
102+
Some(ref image_id) => self.get_or_insert_image(image_id),
103+
None => -1,
104+
});
95105
let decal_index = self.decals.len();
96106
self.decals.push(RenderClusteredDecal {
97107
local_from_world,
98-
image_index,
108+
image_indices,
99109
tag,
100110
pad_a: 0,
101111
pad_b: 0,
112+
pad_c: 0,
102113
});
103114
self.entity_to_decal_index.insert(entity, decal_index);
104115
}
@@ -183,14 +194,23 @@ pub struct RenderClusteredDecal {
183194
/// The shader uses this in order to back-transform world positions into
184195
/// model space.
185196
local_from_world: Mat4,
186-
/// The index of the decal texture in the binding array.
187-
image_index: u32,
197+
/// The index of each decal texture in the binding array.
198+
///
199+
/// These are in the order of the base color texture, the normal map
200+
/// texture, the metallic-roughness map texture, and finally the emissive
201+
/// texture.
202+
///
203+
/// If the decal doesn't have a texture assigned to a slot, the index at
204+
/// that slot will be -1.
205+
image_indices: [i32; 4],
188206
/// A custom tag available for application-defined purposes.
189207
tag: u32,
190208
/// Padding.
191209
pad_a: u32,
192210
/// Padding.
193211
pad_b: u32,
212+
/// Padding.
213+
pad_c: u32,
194214
}
195215

196216
/// Extracts decals from the main world into the render world.
@@ -232,58 +252,129 @@ pub fn extract_decals(
232252
// Clear out the `RenderDecals` in preparation for a new frame.
233253
render_decals.clear();
234254

255+
extract_clustered_decals(&decals, &mut render_decals);
256+
extract_spot_light_textures(&spot_light_textures, &mut render_decals);
257+
extract_point_light_textures(&point_light_textures, &mut render_decals);
258+
extract_directional_light_textures(&directional_light_textures, &mut render_decals);
259+
}
260+
261+
/// Extracts all clustered decals and light textures from the scene and transfers
262+
/// them to the render world.
263+
fn extract_clustered_decals(
264+
decals: &Extract<
265+
Query<(
266+
RenderEntity,
267+
&ClusteredDecal,
268+
&GlobalTransform,
269+
&ViewVisibility,
270+
)>,
271+
>,
272+
render_decals: &mut RenderClusteredDecals,
273+
) {
235274
// Loop over each decal.
236-
for (decal_entity, clustered_decal, global_transform, view_visibility) in &decals {
275+
for (decal_entity, clustered_decal, global_transform, view_visibility) in decals {
237276
// If the decal is invisible, skip it.
238277
if !view_visibility.get() {
239278
continue;
240279
}
241280

281+
// Insert the decal, grabbing the ID of every associated texture as we
282+
// do.
242283
render_decals.insert_decal(
243284
decal_entity,
244-
&clustered_decal.image.id(),
285+
[
286+
clustered_decal.base_color_texture.as_ref().map(Handle::id),
287+
clustered_decal.normal_map_texture.as_ref().map(Handle::id),
288+
clustered_decal
289+
.metallic_roughness_texture
290+
.as_ref()
291+
.map(Handle::id),
292+
clustered_decal.emissive_texture.as_ref().map(Handle::id),
293+
],
245294
global_transform.affine().inverse().into(),
246295
clustered_decal.tag,
247296
);
248297
}
298+
}
249299

250-
for (decal_entity, texture, global_transform, view_visibility) in &spot_light_textures {
251-
// If the decal is invisible, skip it.
300+
/// Extracts all textures from spot lights from the main world to the render
301+
/// world as clustered decals.
302+
fn extract_spot_light_textures(
303+
spot_light_textures: &Extract<
304+
Query<(
305+
RenderEntity,
306+
&SpotLightTexture,
307+
&GlobalTransform,
308+
&ViewVisibility,
309+
)>,
310+
>,
311+
render_decals: &mut RenderClusteredDecals,
312+
) {
313+
for (decal_entity, texture, global_transform, view_visibility) in spot_light_textures {
314+
// If the texture is invisible, skip it.
252315
if !view_visibility.get() {
253316
continue;
254317
}
255318

256319
render_decals.insert_decal(
257320
decal_entity,
258-
&texture.image.id(),
321+
[Some(texture.image.id()), None, None, None],
259322
global_transform.affine().inverse().into(),
260323
0,
261324
);
262325
}
326+
}
263327

264-
for (decal_entity, texture, global_transform, view_visibility) in &point_light_textures {
265-
// If the decal is invisible, skip it.
328+
/// Extracts all textures from point lights from the main world to the render
329+
/// world as clustered decals.
330+
fn extract_point_light_textures(
331+
point_light_textures: &Extract<
332+
Query<(
333+
RenderEntity,
334+
&PointLightTexture,
335+
&GlobalTransform,
336+
&ViewVisibility,
337+
)>,
338+
>,
339+
render_decals: &mut RenderClusteredDecals,
340+
) {
341+
for (decal_entity, texture, global_transform, view_visibility) in point_light_textures {
342+
// If the texture is invisible, skip it.
266343
if !view_visibility.get() {
267344
continue;
268345
}
269346

270347
render_decals.insert_decal(
271348
decal_entity,
272-
&texture.image.id(),
349+
[Some(texture.image.id()), None, None, None],
273350
global_transform.affine().inverse().into(),
274351
texture.cubemap_layout as u32,
275352
);
276353
}
354+
}
277355

278-
for (decal_entity, texture, global_transform, view_visibility) in &directional_light_textures {
279-
// If the decal is invisible, skip it.
356+
/// Extracts all textures from directional lights from the main world to the
357+
/// render world as clustered decals.
358+
fn extract_directional_light_textures(
359+
directional_light_textures: &Extract<
360+
Query<(
361+
RenderEntity,
362+
&DirectionalLightTexture,
363+
&GlobalTransform,
364+
&ViewVisibility,
365+
)>,
366+
>,
367+
render_decals: &mut RenderClusteredDecals,
368+
) {
369+
for (decal_entity, texture, global_transform, view_visibility) in directional_light_textures {
370+
// If the texture is invisible, skip it.
280371
if !view_visibility.get() {
281372
continue;
282373
}
283374

284375
render_decals.insert_decal(
285376
decal_entity,
286-
&texture.image.id(),
377+
[Some(texture.image.id()), None, None, None],
287378
global_transform.affine().inverse().into(),
288379
if texture.tiled { 1 } else { 0 },
289380
);
@@ -376,6 +467,8 @@ impl<'a> RenderViewClusteredDecalBindGroupEntries<'a> {
376467
while texture_views.len() < max_view_decals as usize {
377468
texture_views.push(&*fallback_image.d2.texture_view);
378469
}
470+
} else if texture_views.is_empty() {
471+
texture_views.push(&*fallback_image.d2.texture_view);
379472
}
380473

381474
Some(RenderViewClusteredDecalBindGroupEntries {
@@ -389,12 +482,12 @@ impl<'a> RenderViewClusteredDecalBindGroupEntries<'a> {
389482
impl RenderClusteredDecals {
390483
/// Returns the index of the given image in the decal texture binding array,
391484
/// adding it to the list if necessary.
392-
fn get_or_insert_image(&mut self, image_id: &AssetId<Image>) -> u32 {
485+
fn get_or_insert_image(&mut self, image_id: &AssetId<Image>) -> i32 {
393486
*self
394487
.texture_to_binding_index
395488
.entry(*image_id)
396489
.or_insert_with(|| {
397-
let index = self.binding_index_to_textures.len() as u32;
490+
let index = self.binding_index_to_textures.len() as i32;
398491
self.binding_index_to_textures.push(*image_id);
399492
index
400493
})

0 commit comments

Comments
 (0)