Skip to content

Commit

Permalink
Implement basic clustered decal projectors.
Browse files Browse the repository at this point in the history
This commit adds support for *decal projectors* to Bevy, allowing for
textures to be projected on top of geometry. Decal projectors are
clusterable objects, just as punctual lights and light probes are. This
means that decals are only evaluated for objects within the conservative
bounds of the projector, and they don't require a second pass.

These clustered decals require support for bindless textures and as such
currently don't work on WebGL 2, WebGPU, macOS, or iOS. For an
alternative that doesn't require bindless, see PR #16600. I believe that
both contact projective decals in #16600 and clustered decals are
desirable to have in Bevy. Contact projective decals offer broader
hardware and driver support, while clustered decals don't require the
creation of bounding geometry.

A new example, `decal_projectors`, has been added, which demonstrates
multiple decals on a rotating object. The decal projectors can be scaled
and rotated with the mouse.

There are several limitations of this initial patch that can be
addressed in follow-ups:

1. There's no way to specify the Z-index of decals. That is, the order
   in which multiple decals are blended on top of one another is
   arbitrary. A follow-up could introduce some sort of Z-index field so
   that artists can specify that some decals should be blended on top of
   others.

2. Decals don't take the normal of the surface they're projected onto
   into account. Most decal implementations in other engines have a
   feature whereby the angle between the decal projector and the normal
   of the surface must be within some threshold for the decal to appear.
   Often, artists can specify a fade-off range for a smooth transition
   between oblique surfaces and aligned surfaces.

3. There's no distance-based fadeoff toward the end of the projector
   range. Many decal implementations have this.
  • Loading branch information
pcwalton committed Jan 11, 2025
1 parent 5c0e13f commit 4f6fa84
Show file tree
Hide file tree
Showing 17 changed files with 1,266 additions and 55 deletions.
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4028,3 +4028,14 @@ name = "Directional Navigation"
description = "Demonstration of Directional Navigation between UI elements"
category = "UI (User Interface)"
wasm = true

[[example]]
name = "decal_projectors"
path = "examples/3d/decal_projectors.rs"
doc-scrape-examples = true

[package.metadata.example.decal_projectors]
name = "Decal Projectors"
description = "Demonstrates decal projectors"
category = "3D Rendering"
wasm = false
52 changes: 46 additions & 6 deletions crates/bevy_pbr/src/cluster/assign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@ use bevy_render::{
camera::Camera,
primitives::{Aabb, Frustum, HalfSpace, Sphere},
render_resource::BufferBindingType,
renderer::RenderDevice,
renderer::{RenderAdapter, RenderDevice},
view::{RenderLayers, ViewVisibility},
};
use bevy_transform::components::GlobalTransform;
use bevy_utils::prelude::default;
use tracing::warn;

use crate::{
prelude::EnvironmentMapLight, ClusterConfig, ClusterFarZMode, Clusters, ExtractedPointLight,
GlobalVisibleClusterableObjects, LightProbe, PointLight, SpotLight, ViewClusterBindings,
VisibleClusterableObjects, VolumetricLight, CLUSTERED_FORWARD_STORAGE_BUFFER_COUNT,
binding_arrays_are_usable, decal::projector::DecalProjector, prelude::EnvironmentMapLight,
ClusterConfig, ClusterFarZMode, Clusters, ExtractedPointLight, GlobalVisibleClusterableObjects,
LightProbe, PointLight, SpotLight, ViewClusterBindings, VisibleClusterableObjects,
VolumetricLight, CLUSTERED_FORWARD_STORAGE_BUFFER_COUNT,
MAX_UNIFORM_BUFFER_CLUSTERABLE_OBJECTS,
};

Expand All @@ -37,6 +38,8 @@ const VEC2_HALF_NEGATIVE_Y: Vec2 = Vec2::new(0.5, -0.5);
#[derive(Clone, Debug)]
pub(crate) struct ClusterableObjectAssignmentData {
entity: Entity,
// TODO: We currently ignore the scale on the transform. This is confusing.
// Replace with an `Isometry3d`.
transform: GlobalTransform,
range: f32,
object_type: ClusterableObjectType,
Expand Down Expand Up @@ -90,6 +93,9 @@ pub(crate) enum ClusterableObjectType {

/// Marks that the clusterable object is an irradiance volume.
IrradianceVolume,

/// Marks that the clusterable object is a decal.
Decal,
}

impl ClusterableObjectType {
Expand All @@ -113,6 +119,7 @@ impl ClusterableObjectType {
} => (1, !shadows_enabled, !volumetric),
ClusterableObjectType::ReflectionProbe => (2, false, false),
ClusterableObjectType::IrradianceVolume => (3, false, false),
ClusterableObjectType::Decal => (4, false, false),
}
}

Expand Down Expand Up @@ -168,12 +175,13 @@ pub(crate) fn assign_objects_to_clusters(
(Entity, &GlobalTransform, Has<EnvironmentMapLight>),
With<LightProbe>,
>,
decals_query: Query<(Entity, &GlobalTransform), With<DecalProjector>>,
mut clusterable_objects: Local<Vec<ClusterableObjectAssignmentData>>,
mut cluster_aabb_spheres: Local<Vec<Option<Sphere>>>,
mut max_clusterable_objects_warning_emitted: Local<bool>,
render_device: Option<Res<RenderDevice>>,
(render_device, render_adapter): (Option<Res<RenderDevice>>, Option<Res<RenderAdapter>>),
) {
let Some(render_device) = render_device else {
let (Some(render_device), Some(render_adapter)) = (render_device, render_adapter) else {
return;
};

Expand Down Expand Up @@ -249,6 +257,19 @@ pub(crate) fn assign_objects_to_clusters(
));
}

// TODO: this should be "if supports_bindless" or something
if binding_arrays_are_usable(&render_device, &render_adapter) {
clusterable_objects.extend(decals_query.iter().map(|(entity, transform)| {
ClusterableObjectAssignmentData {
entity,
transform: *transform,
range: transform.scale().length(),
object_type: ClusterableObjectType::Decal,
render_layers: RenderLayers::default(),
}
}));
}

if clusterable_objects.len() > MAX_UNIFORM_BUFFER_CLUSTERABLE_OBJECTS
&& !supports_storage_buffers
{
Expand Down Expand Up @@ -608,6 +629,10 @@ pub(crate) fn assign_objects_to_clusters(
angle_cos,
))
}
ClusterableObjectType::Decal => {
// TODO: cull via a frustum
None
}
ClusterableObjectType::PointLight { .. }
| ClusterableObjectType::ReflectionProbe
| ClusterableObjectType::IrradianceVolume => None,
Expand Down Expand Up @@ -818,6 +843,21 @@ pub(crate) fn assign_objects_to_clusters(
cluster_index += clusters.dimensions.z as usize;
}
}

ClusterableObjectType::Decal { .. } => {
// Decals currently affect all clusters in their
// bounding sphere.
//
// TODO: Cull more aggressively based on the
// decal's OBB.
for _ in min_x..=max_x {
clusters.clusterable_objects[cluster_index]
.entities
.push(clusterable_object.entity);
clusters.clusterable_objects[cluster_index].counts.decals += 1;
cluster_index += clusters.dimensions.z as usize;
}
}
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion crates/bevy_pbr/src/cluster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ struct ClusterableObjectCounts {
reflection_probes: u32,
/// The number of irradiance volumes in the cluster.
irradiance_volumes: u32,
/// The number of decal projectors in the cluster.
decals: u32,
}

enum ExtractedClusterableObjectElement {
Expand Down Expand Up @@ -672,7 +674,7 @@ impl ViewClusterBindings {
counts.spot_lights,
counts.reflection_probes,
),
uvec4(counts.irradiance_volumes, 0, 0, 0),
uvec4(counts.irradiance_volumes, counts.decals, 0, 0),
]);
}
}
Expand Down
178 changes: 178 additions & 0 deletions crates/bevy_pbr/src/decal/clustered.wgsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Support code for clustered decal projectors.
//
// This module provides an iterator API, which you may wish to use in your own
// shaders if you want clustered decals to provide textures other than the base
// color. The iterator API allows you to iterate over all decals affecting the
// current fragment. Use `clustered_decal_iterator_new()` and
// `clustered_decal_iterator_next()` as follows:
//
// let view_z = get_view_z(vec4(world_position, 1.0));
// let is_orthographic = view_is_orthographic();
//
// let cluster_index =
// clustered_forward::fragment_cluster_index(frag_coord, view_z, is_orthographic);
// var clusterable_object_index_ranges =
// clustered_forward::unpack_clusterable_object_index_ranges(cluster_index);
//
// var iterator = clustered_decal_iterator_new(world_position, &clusterable_object_index_ranges);
// while (clustered_decal_iterator_next(&iterator)) {
// ... sample from the texture at iterator.texture_index at iterator.uv ...
// }
//
// In this way, in conjunction with a custom material, you can provide your own
// texture arrays that mirror `mesh_view_bindings::clustered_decal_textures` in
// order to support decals with normal maps, etc.
//
// Note that the order in which decals are returned is currently unpredictable,
// though generally stable from frame to frame.

#define_import_path bevy_pbr::decal::clustered

#import bevy_pbr::clustered_forward
#import bevy_pbr::clustered_forward::ClusterableObjectIndexRanges
#import bevy_pbr::mesh_view_bindings
#import bevy_render::maths

// An object that allows stepping through all clustered decals that affect a
// single fragment.
struct ClusteredDecalIterator {
// Public fields follow:
// The index of the decal texture in the binding array.
texture_index: i32,
// The UV coordinates at which to sample that decal texture.
uv: vec2<f32>,

// Private fields follow:
// The current offset of the index in the `ClusterableObjectIndexRanges` list.
decal_index_offset: i32,
// The end offset of the index in the `ClusterableObjectIndexRanges` list.
end_offset: i32,
// The world-space position of the fragment.
world_position: vec3<f32>,
}

#ifdef CLUSTERED_DECALS_ARE_USABLE

// Creates a new iterator over the decals at the current fragment.
//
// You can retrieve `clusterable_object_index_ranges` as follows:
//
// let view_z = get_view_z(world_position);
// let is_orthographic = view_is_orthographic();
//
// let cluster_index =
// clustered_forward::fragment_cluster_index(frag_coord, view_z, is_orthographic);
// var clusterable_object_index_ranges =
// clustered_forward::unpack_clusterable_object_index_ranges(cluster_index);
fn clustered_decal_iterator_new(
world_position: vec3<f32>,
clusterable_object_index_ranges: ptr<function, ClusterableObjectIndexRanges>
) -> ClusteredDecalIterator {
return ClusteredDecalIterator(
-1,
vec2(0.0),
// We subtract 1 because the first thing `decal_iterator_next` does is
// add 1.
i32((*clusterable_object_index_ranges).first_decal_offset) - 1,
i32((*clusterable_object_index_ranges).last_clusterable_object_index_offset),
world_position,
);
}

// Populates the `iterator.texture_index` and `iterator.uv` fields for the next
// decal overlapping the current world position.
//
// Returns true if another decal was found or false if no more decals were found
// for this position.
fn clustered_decal_iterator_next(iterator: ptr<function, ClusteredDecalIterator>) -> bool {
if ((*iterator).decal_index_offset == (*iterator).end_offset) {
return false;
}

(*iterator).decal_index_offset += 1;

while ((*iterator).decal_index_offset < (*iterator).end_offset) {
let decal_index = i32(clustered_forward::get_clusterable_object_id(
u32((*iterator).decal_index_offset)
));
let decal_space_vector =
(mesh_view_bindings::clustered_decals.decals[decal_index].local_from_world *
vec4((*iterator).world_position, 1.0)).xyz;

if (all(decal_space_vector >= vec3(-0.5)) && all(decal_space_vector <= vec3(0.5))) {
(*iterator).texture_index =
i32(mesh_view_bindings::clustered_decals.decals[decal_index].image_index);
(*iterator).uv = decal_space_vector.xy * vec2(1.0, -1.0) + vec2(0.5);
return true;
}

(*iterator).decal_index_offset += 1;
}

return false;
}

#endif // CLUSTERED_DECALS_ARE_USABLE

// Returns the view-space Z coordinate for the given world position.
fn get_view_z(world_position: vec3<f32>) -> f32 {
return dot(vec4<f32>(
mesh_view_bindings::view.view_from_world[0].z,
mesh_view_bindings::view.view_from_world[1].z,
mesh_view_bindings::view.view_from_world[2].z,
mesh_view_bindings::view.view_from_world[3].z
), vec4(world_position, 1.0));
}

// Returns true if the current view describes an orthographic projection or
// false otherwise.
fn view_is_orthographic() -> bool {
return mesh_view_bindings::view.clip_from_view[3].w == 1.0;
}

// Modifies the base color at the given position to account for decals.
//
// Returns the new base color with decals taken into account. If no decals
// overlap the current world position, returns the supplied base color
// unmodified.
fn apply_decal_base_color(
world_position: vec3<f32>,
frag_coord: vec2<f32>,
initial_base_color: vec4<f32>,
) -> vec4<f32> {
var base_color = initial_base_color;

#ifdef CLUSTERED_DECALS_ARE_USABLE
// Fetch the clusterable object index ranges for this world position.

let view_z = get_view_z(world_position);
let is_orthographic = view_is_orthographic();

let cluster_index =
clustered_forward::fragment_cluster_index(frag_coord, view_z, is_orthographic);
var clusterable_object_index_ranges =
clustered_forward::unpack_clusterable_object_index_ranges(cluster_index);

// Iterate over decals.

var iterator = clustered_decal_iterator_new(world_position, &clusterable_object_index_ranges);
while (clustered_decal_iterator_next(&iterator)) {
// Sample the current decal.
let decal_base_color = textureSampleLevel(
mesh_view_bindings::clustered_decal_textures[iterator.texture_index],
mesh_view_bindings::clustered_decal_sampler,
iterator.uv,
0.0
);

// Blend with the accumulated fragment.
base_color = vec4(
mix(base_color.rgb, decal_base_color.rgb, decal_base_color.a),
base_color.a + decal_base_color.a
);
}
#endif // CLUSTERED_DECALS_ARE_USABLE

return base_color;
}

3 changes: 3 additions & 0 deletions crates/bevy_pbr/src/decal/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Decals, textures that can be projected onto surfaces.
pub mod projector;
Loading

0 comments on commit 4f6fa84

Please sign in to comment.