From 4f6fa84cdad1ef3ee8b330031d16eeb542e368e5 Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Tue, 7 Jan 2025 17:45:03 -0800 Subject: [PATCH] Implement basic clustered decal projectors. 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. --- Cargo.toml | 11 + crates/bevy_pbr/src/cluster/assign.rs | 52 +- crates/bevy_pbr/src/cluster/mod.rs | 4 +- crates/bevy_pbr/src/decal/clustered.wgsl | 178 ++++++ crates/bevy_pbr/src/decal/mod.rs | 3 + crates/bevy_pbr/src/decal/projector.rs | 362 +++++++++++++ crates/bevy_pbr/src/lib.rs | 7 +- .../bevy_pbr/src/light_probe/light_probe.wgsl | 2 +- crates/bevy_pbr/src/prepass/mod.rs | 16 +- .../src/render/clustered_forward.wgsl | 7 +- crates/bevy_pbr/src/render/mesh.rs | 4 + .../bevy_pbr/src/render/mesh_view_bindings.rs | 80 ++- .../src/render/mesh_view_bindings.wgsl | 34 +- .../bevy_pbr/src/render/mesh_view_types.wgsl | 14 +- crates/bevy_pbr/src/render/pbr.wgsl | 7 + examples/3d/decal_projectors.rs | 506 ++++++++++++++++++ examples/helpers/widgets.rs | 34 +- 17 files changed, 1266 insertions(+), 55 deletions(-) create mode 100644 crates/bevy_pbr/src/decal/clustered.wgsl create mode 100644 crates/bevy_pbr/src/decal/mod.rs create mode 100644 crates/bevy_pbr/src/decal/projector.rs create mode 100644 examples/3d/decal_projectors.rs diff --git a/Cargo.toml b/Cargo.toml index d649d14573787..abede8603c3b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/bevy_pbr/src/cluster/assign.rs b/crates/bevy_pbr/src/cluster/assign.rs index 297672a78f995..4a95b7e0f2efd 100644 --- a/crates/bevy_pbr/src/cluster/assign.rs +++ b/crates/bevy_pbr/src/cluster/assign.rs @@ -13,7 +13,7 @@ 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; @@ -21,9 +21,10 @@ 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, }; @@ -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, @@ -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 { @@ -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), } } @@ -168,12 +175,13 @@ pub(crate) fn assign_objects_to_clusters( (Entity, &GlobalTransform, Has), With, >, + decals_query: Query<(Entity, &GlobalTransform), With>, mut clusterable_objects: Local>, mut cluster_aabb_spheres: Local>>, mut max_clusterable_objects_warning_emitted: Local, - render_device: Option>, + (render_device, render_adapter): (Option>, Option>), ) { - let Some(render_device) = render_device else { + let (Some(render_device), Some(render_adapter)) = (render_device, render_adapter) else { return; }; @@ -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 { @@ -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, @@ -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; + } + } } } } diff --git a/crates/bevy_pbr/src/cluster/mod.rs b/crates/bevy_pbr/src/cluster/mod.rs index eb7834871ba8b..7e1275fb7ef47 100644 --- a/crates/bevy_pbr/src/cluster/mod.rs +++ b/crates/bevy_pbr/src/cluster/mod.rs @@ -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 { @@ -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), ]); } } diff --git a/crates/bevy_pbr/src/decal/clustered.wgsl b/crates/bevy_pbr/src/decal/clustered.wgsl new file mode 100644 index 0000000000000..6d97fea110e86 --- /dev/null +++ b/crates/bevy_pbr/src/decal/clustered.wgsl @@ -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, + + // 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, +} + +#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, + clusterable_object_index_ranges: ptr +) -> 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) -> 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 { + return dot(vec4( + 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, + frag_coord: vec2, + initial_base_color: vec4, +) -> vec4 { + 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; +} + diff --git a/crates/bevy_pbr/src/decal/mod.rs b/crates/bevy_pbr/src/decal/mod.rs new file mode 100644 index 0000000000000..837e2e922e99d --- /dev/null +++ b/crates/bevy_pbr/src/decal/mod.rs @@ -0,0 +1,3 @@ +//! Decals, textures that can be projected onto surfaces. + +pub mod projector; diff --git a/crates/bevy_pbr/src/decal/projector.rs b/crates/bevy_pbr/src/decal/projector.rs new file mode 100644 index 0000000000000..4f0c0425ebb1a --- /dev/null +++ b/crates/bevy_pbr/src/decal/projector.rs @@ -0,0 +1,362 @@ +//! Decal projectors, bounding regions that project textures onto surfaces. +//! +//! A *decal projector* is a bounding box that projects a texture onto any +//! surface within its bounds along the positive Z axis. In Bevy, decal +//! projectors use the clustered forward rendering technique, so the types of +//! decals they project are known as *clustered decals*. +//! +//! Clustered decals are the highest-quality types of decals that Bevy supports, +//! but they require bindless textures. This means that they presently can't be +//! used on WebGL 2, WebGPU, macOS, or iOS. Bevy's clustered decals can be used +//! with forward or deferred rendering and don't require a prepass. +//! +//! On their own, decal projectors only project a base color. You can, however, +//! write a custom material in order to project other PBR texture types, like +//! normal and emissive maps. See the documentation in `clustered.wgsl` for more +//! information. + +use core::{num::NonZero, ops::Deref}; + +use bevy_app::{App, Plugin}; +use bevy_asset::{load_internal_asset, AssetId, Handle}; +use bevy_derive::{Deref, DerefMut}; +use bevy_ecs::{ + component::{require, Component}, + entity::{Entity, EntityHashMap}, + prelude::ReflectComponent, + query::With, + schedule::IntoSystemConfigs as _, + system::{Query, Res, ResMut, Resource}, +}; +use bevy_image::Image; +use bevy_math::Mat4; +use bevy_reflect::Reflect; +use bevy_render::{ + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_asset::RenderAssets, + render_resource::{ + binding_types, BindGroupLayoutEntryBuilder, Buffer, BufferUsages, RawBufferVec, Sampler, + SamplerBindingType, Shader, ShaderType, TextureSampleType, TextureView, + }, + renderer::{RenderAdapter, RenderDevice, RenderQueue}, + sync_world::RenderEntity, + texture::{FallbackImage, GpuImage}, + view::{self, ViewVisibility, Visibility, VisibilityClass}, + Extract, ExtractSchedule, Render, RenderApp, RenderSet, +}; +use bevy_transform::{components::GlobalTransform, prelude::Transform}; +use bevy_utils::HashMap; +use bytemuck::{Pod, Zeroable}; + +use crate::{ + binding_arrays_are_usable, prepare_lights, GlobalClusterableObjectMeta, LightVisibilityClass, +}; + +/// The handle to the `clustered.wgsl` shader. +pub(crate) const CLUSTERED_DECAL_SHADER_HANDLE: Handle = + Handle::weak_from_u128(2881025580737984685); + +/// The maximum number of decals that can be present in a view. +/// +/// This number is currently relatively low in order to work around the lack of +/// first-class binding arrays in `wgpu`. When that feature is implemented, this +/// limit can be increased. +pub(crate) const MAX_VIEW_DECALS: usize = 16; + +/// A plugin that adds support for projected decals. +/// +/// In environments where bindless textures aren't available, decal projectors +/// can still be added to a scene, but they won't project any decals. +pub struct DecalProjectorPlugin; + +/// An object that projects a decal onto surfaces within its bounds. +/// +/// Conceptually, a decal projector is a 1×1×1 cube centered on its origin. It +/// projects the given [`Self::image`] onto surfaces in the +Z direction (thus +/// you may find [`Transform::looking_at`] useful). +/// +/// Clustered decals are the highest-quality types of decals that Bevy supports, +/// but they require bindless textures. This means that they presently can't be +/// used on WebGL 2, WebGPU, macOS, or iOS. Bevy's clustered decals can be used +/// with forward or deferred rendering and don't require a prepass. +#[derive(Component, Debug, Clone, Reflect, ExtractComponent)] +#[reflect(Component, Debug)] +#[require(Transform, Visibility, VisibilityClass)] +#[component(on_add = view::add_visibility_class::)] +pub struct DecalProjector { + /// The image that the decal projector projects. + /// + /// This must be a 2D image. If it has an alpha channel, it'll be alpha + /// blended with the underlying surface and/or other decals. All images in + /// the scene must use the same sampler. + pub image: Handle, +} + +/// Stores information about all the clustered decals in the scene. +#[derive(Resource, Default)] +pub struct RenderClusteredDecals { + /// Maps an index in the shader binding array to the associated decal image. + /// + /// [`Self::texture_to_binding_index`] holds the inverse mapping. + binding_index_to_textures: Vec>, + /// Maps a decal image to the shader binding array. + /// + /// [`Self::binding_index_to_texture`] holds the inverse mapping. + texture_to_binding_index: HashMap, u32>, + /// The information concerning each decal that we provide to the shader. + decals: Vec, + /// Maps the [`bevy_render::sync_world::RenderEntity`] of each decal to the + /// index of that decal in the [`Self::decals`] list. + entity_to_decal_index: EntityHashMap, +} + +impl RenderClusteredDecals { + /// Clears out this [`RenderDecals`] in preparation for a new frame. + fn clear(&mut self) { + self.binding_index_to_textures.clear(); + self.texture_to_binding_index.clear(); + self.decals.clear(); + self.entity_to_decal_index.clear(); + } +} + +/// The per-view bind group entries pertaining to decals. +pub(crate) struct RenderViewClusteredDecalBindGroupEntries<'a> { + /// The list of decals, corresponding to `mesh_view_bindings::decals` in the + /// shader. + pub(crate) decals: &'a Buffer, + /// The list of textures, corresponding to + /// `mesh_view_bindings::decal_textures` in the shader. + pub(crate) texture_views: Vec<&'a ::Target>, + /// The sampler that the shader uses to sample decals, corresponding to + /// `mesh_view_bindings::decal_sampler` in the shader. + pub(crate) sampler: &'a Sampler, +} + +/// A render-world resource that holds the buffer of [`Decal`]s ready to upload +/// to the GPU. +#[derive(Resource, Deref, DerefMut)] +pub struct DecalsBuffer(RawBufferVec); + +impl Default for DecalsBuffer { + fn default() -> Self { + DecalsBuffer(RawBufferVec::new(BufferUsages::STORAGE)) + } +} + +impl Plugin for DecalProjectorPlugin { + fn build(&self, app: &mut App) { + load_internal_asset!( + app, + CLUSTERED_DECAL_SHADER_HANDLE, + "clustered.wgsl", + Shader::from_wgsl + ); + + app.add_plugins(ExtractComponentPlugin::::default()) + .register_type::(); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + + render_app + .init_resource::() + .init_resource::() + .add_systems(ExtractSchedule, extract_decals) + .add_systems( + Render, + prepare_decals + .in_set(RenderSet::ManageViews) + .after(prepare_lights), + ) + .add_systems(Render, upload_decals.in_set(RenderSet::PrepareResources)); + } +} + +/// The GPU data structure that stores information about each decal. +#[derive(Clone, Copy, Default, ShaderType, Pod, Zeroable)] +#[repr(C)] +pub struct ClusteredDecal { + /// The inverse of the model matrix. + /// + /// The shader uses this in order to back-transform world positions into + /// model space. + local_from_world: Mat4, + /// The index of the decal texture in the binding array. + image_index: u32, + /// Padding. + pad_a: u32, + /// Padding. + pad_b: u32, + /// Padding. + pad_c: u32, +} + +/// Extracts decals from the main world into the render world. +pub fn extract_decals( + decals: Extract< + Query<( + RenderEntity, + &DecalProjector, + &GlobalTransform, + &ViewVisibility, + )>, + >, + mut render_decals: ResMut, +) { + // Clear out the `RenderDecals` in preparation for a new frame. + render_decals.clear(); + + // Loop over each decal. + for (decal_entity, decal_projector, global_transform, view_visibility) in &decals { + // If the decal is invisible, skip it. + if !view_visibility.get() { + continue; + } + + // Insert or add the image. + let image_index = render_decals.get_or_insert_image(&decal_projector.image.id()); + + // Record the decal. + let decal_index = render_decals.decals.len(); + render_decals + .entity_to_decal_index + .insert(decal_entity, decal_index); + + render_decals.decals.push(ClusteredDecal { + local_from_world: global_transform.affine().inverse().into(), + image_index, + pad_a: 0, + pad_b: 0, + pad_c: 0, + }); + } +} + +/// Adds all decals in the scene to the [`GlobalClusterableObjectMeta`] table. +fn prepare_decals( + decals: Query>, + mut global_clusterable_object_meta: ResMut, + render_decals: Res, +) { + for decal_entity in &decals { + if let Some(index) = render_decals.entity_to_decal_index.get(&decal_entity) { + global_clusterable_object_meta + .entity_to_index + .insert(decal_entity, *index); + } + } +} + +/// Returns the layout for the clustered-decal-related bind group entries for a +/// single view. +pub(crate) fn get_bind_group_layout_entries( + render_device: &RenderDevice, + render_adapter: &RenderAdapter, +) -> Option<[BindGroupLayoutEntryBuilder; 3]> { + // If binding arrays aren't supported on the current platform, we have no + // bind group layout entries. + if !binding_arrays_are_usable(render_device, render_adapter) { + return None; + } + + Some([ + // `decals` + binding_types::storage_buffer_read_only::(false), + // `decal_textures` + binding_types::texture_2d(TextureSampleType::Float { filterable: true }) + .count(NonZero::::new(MAX_VIEW_DECALS as u32).unwrap()), + // `decal_sampler` + binding_types::sampler(SamplerBindingType::Filtering), + ]) +} + +impl<'a> RenderViewClusteredDecalBindGroupEntries<'a> { + /// Creates and returns the bind group entries for clustered decals for a + /// single view. + pub(crate) fn get( + render_decals: &RenderClusteredDecals, + decals_buffer: &'a DecalsBuffer, + images: &'a RenderAssets, + fallback_image: &'a FallbackImage, + render_device: &RenderDevice, + render_adapter: &RenderAdapter, + ) -> Option> { + // Skip the entries if decals are unsupported on the current platform. + if !binding_arrays_are_usable(render_device, render_adapter) { + return None; + } + + // We use the first sampler among all the images. This assumes that all + // images use the same sampler, which is a documented restriction. If + // there's no sampler, we just use the one from the fallback image. + let sampler = match render_decals + .binding_index_to_textures + .iter() + .filter_map(|image_id| images.get(*image_id)) + .next() + { + Some(gpu_image) => &gpu_image.sampler, + None => &fallback_image.d2.sampler, + }; + + // Gather up the decal textures. + let mut texture_views = vec![]; + for image_id in &render_decals.binding_index_to_textures { + match images.get(*image_id) { + None => texture_views.push(&*fallback_image.d2.texture_view), + Some(gpu_image) => texture_views.push(&*gpu_image.texture_view), + } + } + + // Pad out the binding array to its maximum length, which is + // required on some platforms. + while texture_views.len() < MAX_VIEW_DECALS { + texture_views.push(&*fallback_image.d2.texture_view); + } + + Some(RenderViewClusteredDecalBindGroupEntries { + decals: decals_buffer.buffer()?, + texture_views, + sampler, + }) + } +} + +impl RenderClusteredDecals { + /// Returns the index of the given image in the decal texture binding array, + /// adding it to the list if necessary. + fn get_or_insert_image(&mut self, image_id: &AssetId) -> u32 { + *self + .texture_to_binding_index + .entry(*image_id) + .or_insert_with(|| { + let index = self.binding_index_to_textures.len() as u32; + self.binding_index_to_textures.push(*image_id); + index + }) + } +} + +/// Uploads the list of decals from [`RenderDecals::decals`] to the GPU. +fn upload_decals( + render_decals: Res, + mut decals_buffer: ResMut, + render_device: Res, + render_queue: Res, +) { + decals_buffer.clear(); + + for &decal in &render_decals.decals { + decals_buffer.push(decal); + } + + // Make sure the buffer is non-empty. + // Otherwise there won't be a buffer to bind. + if decals_buffer.is_empty() { + decals_buffer.push(ClusteredDecal::default()); + } + + decals_buffer.write_buffer(&render_device, &render_queue); +} diff --git a/crates/bevy_pbr/src/lib.rs b/crates/bevy_pbr/src/lib.rs index ec67b70c8b501..cf5aa85442d97 100644 --- a/crates/bevy_pbr/src/lib.rs +++ b/crates/bevy_pbr/src/lib.rs @@ -31,6 +31,7 @@ pub mod experimental { mod cluster; mod components; +pub mod decal; pub mod deferred; mod extended_material; mod fog; @@ -54,6 +55,7 @@ use bevy_color::{Color, LinearRgba}; pub use cluster::*; pub use components::*; +pub use decal::projector::DecalProjectorPlugin; pub use extended_material::*; pub use fog::*; pub use light::*; @@ -152,8 +154,8 @@ pub const RGB9E5_FUNCTIONS_HANDLE: Handle = Handle::weak_from_u128(26590 const MESHLET_VISIBILITY_BUFFER_RESOLVE_SHADER_HANDLE: Handle = Handle::weak_from_u128(2325134235233421); -pub const TONEMAPPING_LUT_TEXTURE_BINDING_INDEX: u32 = 23; -pub const TONEMAPPING_LUT_SAMPLER_BINDING_INDEX: u32 = 24; +pub const TONEMAPPING_LUT_TEXTURE_BINDING_INDEX: u32 = 26; +pub const TONEMAPPING_LUT_SAMPLER_BINDING_INDEX: u32 = 27; /// Sets up the entire PBR infrastructure of bevy. pub struct PbrPlugin { @@ -336,6 +338,7 @@ impl Plugin for PbrPlugin { }, VolumetricFogPlugin, ScreenSpaceReflectionsPlugin, + DecalProjectorPlugin, )) .add_plugins(( SyncComponentPlugin::::default(), diff --git a/crates/bevy_pbr/src/light_probe/light_probe.wgsl b/crates/bevy_pbr/src/light_probe/light_probe.wgsl index f98759c293b9c..16a211258a379 100644 --- a/crates/bevy_pbr/src/light_probe/light_probe.wgsl +++ b/crates/bevy_pbr/src/light_probe/light_probe.wgsl @@ -52,7 +52,7 @@ fn query_light_probe( var end_offset: u32; if is_irradiance_volume { start_offset = (*clusterable_object_index_ranges).first_irradiance_volume_index_offset; - end_offset = (*clusterable_object_index_ranges).last_clusterable_object_index_offset; + end_offset = (*clusterable_object_index_ranges).first_decal_offset; } else { start_offset = (*clusterable_object_index_ranges).first_reflection_probe_index_offset; end_offset = (*clusterable_object_index_ranges).first_irradiance_volume_index_offset; diff --git a/crates/bevy_pbr/src/prepass/mod.rs b/crates/bevy_pbr/src/prepass/mod.rs index ad3c366c7a62b..0857474a942ac 100644 --- a/crates/bevy_pbr/src/prepass/mod.rs +++ b/crates/bevy_pbr/src/prepass/mod.rs @@ -1,17 +1,28 @@ mod prepass_bindings; -use crate::material_bind_groups::MaterialBindGroupAllocator; +use crate::{ + alpha_mode_pipeline_key, binding_arrays_are_usable, buffer_layout, + material_bind_groups::MaterialBindGroupAllocator, queue_material_meshes, + setup_morph_and_skinning_defs, skin, DrawMesh, Material, MaterialPipeline, MaterialPipelineKey, + MeshLayouts, MeshPipeline, MeshPipelineKey, OpaqueRendererMethod, PreparedMaterial, + RenderLightmaps, RenderMaterialInstances, RenderMeshInstanceFlags, RenderMeshInstances, + SetMaterialBindGroup, SetMeshBindGroup, StandardMaterial, +}; +use bevy_app::{App, Plugin, PreUpdate}; use bevy_render::{ + alpha::AlphaMode, batching::gpu_preprocessing::GpuPreprocessingSupport, mesh::{allocator::MeshAllocator, Mesh3d, MeshVertexBufferLayoutRef, RenderMesh}, + render_asset::prepare_assets, render_resource::binding_types::uniform_buffer, renderer::RenderAdapter, sync_world::RenderEntity, view::{RenderVisibilityRanges, VISIBILITY_RANGES_STORAGE_BUFFER_COUNT}, + ExtractSchedule, Render, RenderApp, RenderSet, }; pub use prepass_bindings::*; -use bevy_asset::{load_internal_asset, AssetServer}; +use bevy_asset::{load_internal_asset, AssetServer, Handle}; use bevy_core_pipeline::{ core_3d::CORE_3D_DEPTH_FORMAT, deferred::*, prelude::Camera3d, prepass::*, }; @@ -41,7 +52,6 @@ use crate::meshlet::{ prepare_material_meshlet_meshes_prepass, queue_material_meshlet_meshes, InstanceManager, MeshletMesh3d, }; -use crate::*; use bevy_render::view::RenderVisibleEntities; use core::{hash::Hash, marker::PhantomData}; diff --git a/crates/bevy_pbr/src/render/clustered_forward.wgsl b/crates/bevy_pbr/src/render/clustered_forward.wgsl index 72eef607db707..aa3fb4f199b1f 100644 --- a/crates/bevy_pbr/src/render/clustered_forward.wgsl +++ b/crates/bevy_pbr/src/render/clustered_forward.wgsl @@ -27,6 +27,7 @@ struct ClusterableObjectIndexRanges { // The offset of the index of the first irradiance volumes, which also // terminates the list of reflection probes. first_irradiance_volume_index_offset: u32, + first_decal_offset: u32, // One past the offset of the index of the final clusterable object for this // cluster. last_clusterable_object_index_offset: u32, @@ -81,12 +82,14 @@ fn unpack_clusterable_object_index_ranges(cluster_index: u32) -> ClusterableObje let spot_light_offset = point_light_offset + offset_and_counts_a.y; let reflection_probe_offset = spot_light_offset + offset_and_counts_a.z; let irradiance_volume_offset = reflection_probe_offset + offset_and_counts_a.w; - let last_clusterable_offset = irradiance_volume_offset + offset_and_counts_b.x; + let decal_offset = irradiance_volume_offset + offset_and_counts_b.x; + let last_clusterable_offset = decal_offset + offset_and_counts_b.y; return ClusterableObjectIndexRanges( point_light_offset, spot_light_offset, reflection_probe_offset, irradiance_volume_offset, + decal_offset, last_clusterable_offset ); @@ -110,7 +113,7 @@ fn unpack_clusterable_object_index_ranges(cluster_index: u32) -> ClusterableObje let offset_b = offset_a + offset_and_counts.y; let offset_c = offset_b + offset_and_counts.z; - return ClusterableObjectIndexRanges(offset_a, offset_b, offset_c, offset_c, offset_c); + return ClusterableObjectIndexRanges(offset_a, offset_b, offset_c, offset_c, offset_c, offset_c); #endif // AVAILABLE_STORAGE_BUFFER_BINDINGS >= 3 } diff --git a/crates/bevy_pbr/src/render/mesh.rs b/crates/bevy_pbr/src/render/mesh.rs index 069763048082b..d3313f85ea3ae 100644 --- a/crates/bevy_pbr/src/render/mesh.rs +++ b/crates/bevy_pbr/src/render/mesh.rs @@ -2281,6 +2281,10 @@ impl SpecializedMeshPipeline for MeshPipeline { shader_defs.push("IRRADIANCE_VOLUMES_ARE_USABLE".into()); } + if self.binding_arrays_are_usable { + shader_defs.push("CLUSTERED_DECALS_ARE_USABLE".into()); + } + let format = if key.contains(MeshPipelineKey::HDR) { ViewTarget::TEXTURE_FORMAT_HDR } else { diff --git a/crates/bevy_pbr/src/render/mesh_view_bindings.rs b/crates/bevy_pbr/src/render/mesh_view_bindings.rs index 385c942c46835..98ae261314d41 100644 --- a/crates/bevy_pbr/src/render/mesh_view_bindings.rs +++ b/crates/bevy_pbr/src/render/mesh_view_bindings.rs @@ -32,6 +32,12 @@ use core::{array, num::NonZero}; use environment_map::EnvironmentMapLight; use crate::{ + decal::{ + self, + projector::{ + DecalsBuffer, RenderClusteredDecals, RenderViewClusteredDecalBindGroupEntries, + }, + }, environment_map::{self, RenderViewEnvironmentMapBindGroupEntries}, irradiance_volume::{ self, IrradianceVolume, RenderViewIrradianceVolumeBindGroupEntries, @@ -329,11 +335,22 @@ fn layout_entries( )); } + // Clustered decals + if let Some(clustered_decal_entries) = + decal::projector::get_bind_group_layout_entries(render_device, render_adapter) + { + entries = entries.extend_with_indices(( + (23, clustered_decal_entries[0]), + (24, clustered_decal_entries[1]), + (25, clustered_decal_entries[2]), + )); + } + // Tonemapping let tonemapping_lut_entries = get_lut_bind_group_layout_entries(); entries = entries.extend_with_indices(( - (23, tonemapping_lut_entries[0]), - (24, tonemapping_lut_entries[1]), + (26, tonemapping_lut_entries[0]), + (27, tonemapping_lut_entries[1]), )); // Prepass @@ -343,7 +360,7 @@ fn layout_entries( { for (entry, binding) in prepass::get_bind_group_layout_entries(layout_key) .iter() - .zip([25, 26, 27, 28]) + .zip([28, 29, 30, 31]) { if let Some(entry) = entry { entries = entries.extend_with_indices(((binding as u32, *entry),)); @@ -354,10 +371,10 @@ fn layout_entries( // View Transmission Texture entries = entries.extend_with_indices(( ( - 29, + 32, texture_2d(TextureSampleType::Float { filterable: true }), ), - (30, sampler(SamplerBindingType::Filtering)), + (33, sampler(SamplerBindingType::Filtering)), )); // OIT @@ -373,12 +390,12 @@ fn layout_entries( { entries = entries.extend_with_indices(( // oit_layers - (31, storage_buffer_sized(false, None)), + (34, storage_buffer_sized(false, None)), // oit_layer_ids, - (32, storage_buffer_sized(false, None)), + (35, storage_buffer_sized(false, None)), // oit_layer_count ( - 33, + 36, uniform_buffer::(true), ), )); @@ -490,8 +507,7 @@ pub struct MeshViewBindGroup { pub fn prepare_mesh_view_bind_groups( mut commands: Commands, - render_device: Res, - render_adapter: Res, + (render_device, render_adapter): (Res, Res), mesh_pipeline: Res, shadow_samplers: Res, (light_meta, global_light_meta): (Res, Res), @@ -522,6 +538,7 @@ pub fn prepare_mesh_view_bind_groups( visibility_ranges: Res, ssr_buffer: Res, oit_buffers: Res, + (decals_buffer, render_decals): (Res, Res), ) { if let ( Some(view_binding), @@ -665,9 +682,40 @@ pub fn prepare_mesh_view_bind_groups( None => {} } + let decal_bind_group_entries = RenderViewClusteredDecalBindGroupEntries::get( + &render_decals, + &decals_buffer, + &images, + &fallback_image, + &render_device, + &render_adapter, + ); + + // Add the decal bind group entries. + if let Some(ref render_view_decal_bind_group_entries) = decal_bind_group_entries { + entries = entries.extend_with_indices(( + // `clustered_decals` + ( + 23, + render_view_decal_bind_group_entries + .decals + .as_entire_binding(), + ), + // `clustered_decal_textures` + ( + 24, + render_view_decal_bind_group_entries + .texture_views + .as_slice(), + ), + // `clustered_decal_sampler` + (25, render_view_decal_bind_group_entries.sampler), + )); + } + let lut_bindings = get_lut_bindings(&images, &tonemapping_luts, tonemapping, &fallback_image); - entries = entries.extend_with_indices(((23, lut_bindings.0), (24, lut_bindings.1))); + entries = entries.extend_with_indices(((26, lut_bindings.0), (27, lut_bindings.1))); // When using WebGL, we can't have a depth texture with multisampling let prepass_bindings; @@ -677,7 +725,7 @@ pub fn prepare_mesh_view_bind_groups( for (binding, index) in prepass_bindings .iter() .map(Option::as_ref) - .zip([25, 26, 27, 28]) + .zip([28, 29, 30, 31]) .flat_map(|(b, i)| b.map(|b| (b, i))) { entries = entries.extend_with_indices(((index, binding),)); @@ -693,7 +741,7 @@ pub fn prepare_mesh_view_bind_groups( .unwrap_or(&fallback_image_zero.sampler); entries = - entries.extend_with_indices(((29, transmission_view), (30, transmission_sampler))); + entries.extend_with_indices(((32, transmission_view), (33, transmission_sampler))); if has_oit { if let ( @@ -706,9 +754,9 @@ pub fn prepare_mesh_view_bind_groups( oit_buffers.settings.binding(), ) { entries = entries.extend_with_indices(( - (31, oit_layers_binding.clone()), - (32, oit_layer_ids_binding.clone()), - (33, oit_settings_binding.clone()), + (34, oit_layers_binding.clone()), + (35, oit_layer_ids_binding.clone()), + (36, oit_settings_binding.clone()), )); } } diff --git a/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl b/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl index a5de8bd873838..2fb34d84669c9 100644 --- a/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl +++ b/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl @@ -70,44 +70,50 @@ const VISIBILITY_RANGE_UNIFORM_BUFFER_SIZE: u32 = 64u; @group(0) @binding(22) var irradiance_volume_sampler: sampler; #endif +#ifdef CLUSTERED_DECALS_ARE_USABLE +@group(0) @binding(23) var clustered_decals: types::ClusteredDecals; +@group(0) @binding(24) var clustered_decal_textures: binding_array, 8u>; +@group(0) @binding(25) var clustered_decal_sampler: sampler; +#endif // CLUSTERED_DECALS_ARE_USABLE + // NB: If you change these, make sure to update `tonemapping_shared.wgsl` too. -@group(0) @binding(23) var dt_lut_texture: texture_3d; -@group(0) @binding(24) var dt_lut_sampler: sampler; +@group(0) @binding(26) var dt_lut_texture: texture_3d; +@group(0) @binding(27) var dt_lut_sampler: sampler; #ifdef MULTISAMPLED #ifdef DEPTH_PREPASS -@group(0) @binding(25) var depth_prepass_texture: texture_depth_multisampled_2d; +@group(0) @binding(28) var depth_prepass_texture: texture_depth_multisampled_2d; #endif // DEPTH_PREPASS #ifdef NORMAL_PREPASS -@group(0) @binding(26) var normal_prepass_texture: texture_multisampled_2d; +@group(0) @binding(29) var normal_prepass_texture: texture_multisampled_2d; #endif // NORMAL_PREPASS #ifdef MOTION_VECTOR_PREPASS -@group(0) @binding(27) var motion_vector_prepass_texture: texture_multisampled_2d; +@group(0) @binding(30) var motion_vector_prepass_texture: texture_multisampled_2d; #endif // MOTION_VECTOR_PREPASS #else // MULTISAMPLED #ifdef DEPTH_PREPASS -@group(0) @binding(25) var depth_prepass_texture: texture_depth_2d; +@group(0) @binding(28) var depth_prepass_texture: texture_depth_2d; #endif // DEPTH_PREPASS #ifdef NORMAL_PREPASS -@group(0) @binding(26) var normal_prepass_texture: texture_2d; +@group(0) @binding(29) var normal_prepass_texture: texture_2d; #endif // NORMAL_PREPASS #ifdef MOTION_VECTOR_PREPASS -@group(0) @binding(27) var motion_vector_prepass_texture: texture_2d; +@group(0) @binding(30) var motion_vector_prepass_texture: texture_2d; #endif // MOTION_VECTOR_PREPASS #endif // MULTISAMPLED #ifdef DEFERRED_PREPASS -@group(0) @binding(28) var deferred_prepass_texture: texture_2d; +@group(0) @binding(31) var deferred_prepass_texture: texture_2d; #endif // DEFERRED_PREPASS -@group(0) @binding(29) var view_transmission_texture: texture_2d; -@group(0) @binding(30) var view_transmission_sampler: sampler; +@group(0) @binding(32) var view_transmission_texture: texture_2d; +@group(0) @binding(33) var view_transmission_sampler: sampler; #ifdef OIT_ENABLED -@group(0) @binding(31) var oit_layers: array>; -@group(0) @binding(32) var oit_layer_ids: array>; -@group(0) @binding(33) var oit_settings: types::OrderIndependentTransparencySettings; +@group(0) @binding(34) var oit_layers: array>; +@group(0) @binding(35) var oit_layer_ids: array>; +@group(0) @binding(36) var oit_settings: types::OrderIndependentTransparencySettings; #endif // OIT_ENABLED diff --git a/crates/bevy_pbr/src/render/mesh_view_types.wgsl b/crates/bevy_pbr/src/render/mesh_view_types.wgsl index ee3b2475e35e9..b1a36639f396b 100644 --- a/crates/bevy_pbr/src/render/mesh_view_types.wgsl +++ b/crates/bevy_pbr/src/render/mesh_view_types.wgsl @@ -13,7 +13,7 @@ struct ClusterableObject { spot_light_tan_angle: f32, soft_shadow_size: f32, shadow_map_near_z: f32, - pad_a: f32, + texture_index: u32, pad_b: f32, }; @@ -172,3 +172,15 @@ struct OrderIndependentTransparencySettings { layers_count: i32, alpha_threshold: f32, }; + +struct ClusteredDecal { + local_from_world: mat4x4, + image_index: i32, + pad_a: u32, + pad_b: u32, + pad_c: u32, +} + +struct ClusteredDecals { + decals: array, +} diff --git a/crates/bevy_pbr/src/render/pbr.wgsl b/crates/bevy_pbr/src/render/pbr.wgsl index 652fa5ac4e41e..78bb7f0e4edd0 100644 --- a/crates/bevy_pbr/src/render/pbr.wgsl +++ b/crates/bevy_pbr/src/render/pbr.wgsl @@ -2,6 +2,7 @@ pbr_types, pbr_functions::alpha_discard, pbr_fragment::pbr_input_from_standard_material, + decal::clustered::apply_decal_base_color, } #ifdef PREPASS_PIPELINE @@ -52,6 +53,12 @@ fn fragment( // alpha discard pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color); + pbr_input.material.base_color = apply_decal_base_color( + in.world_position.xyz, + in.position.xy, + pbr_input.material.base_color + ); + #ifdef PREPASS_PIPELINE // write the gbuffer, lighting pass id, and optionally normal and motion_vector textures let out = deferred_output(in, pbr_input); diff --git a/examples/3d/decal_projectors.rs b/examples/3d/decal_projectors.rs new file mode 100644 index 0000000000000..233634c9ea211 --- /dev/null +++ b/examples/3d/decal_projectors.rs @@ -0,0 +1,506 @@ +//! Demonstrates decal projectors, which affix decals to surfaces. + +use std::f32::consts::{FRAC_PI_3, PI}; +use std::fmt::{self, Formatter}; + +use bevy::window::SystemCursorIcon; +use bevy::winit::cursor::CursorIcon; +use bevy::{ + color::palettes::css::{LIME, ORANGE_RED, SILVER}, + input::mouse::AccumulatedMouseMotion, + pbr::decal::projector::DecalProjector, + prelude::*, +}; +use ops::{acos, cos, sin}; +use widgets::{ + WidgetClickEvent, WidgetClickSender, BUTTON_BORDER, BUTTON_BORDER_COLOR, + BUTTON_BORDER_RADIUS_SIZE, BUTTON_PADDING, +}; + +#[path = "../helpers/widgets.rs"] +mod widgets; + +/// The speed at which the cube rotats, in radians per frame. +const CUBE_ROTATION_SPEED: f32 = 0.02; + +/// The speed at which the selection can be moved, in spherical coordinate +/// radians per mouse unit. +const MOVE_SPEED: f32 = 0.008; +/// The speed at which the selection can be scaled, in reciprocal mouse units. +const SCALE_SPEED: f32 = 0.05; +/// The speed at which the selection can be scaled, in radians per mouse unit. +const ROLL_SPEED: f32 = 0.01; + +/// Various settings for the demo. +#[derive(Resource, Default)] +struct AppStatus { + /// The object that will be moved, scaled, or rotated when the mouse is + /// dragged. + selection: Selection, + /// What happens when the mouse is dragged: one of a move, rotate, or scale + /// operation. + drag_mode: DragMode, +} + +/// The object that will be moved, scaled, or rotated when the mouse is dragged. +#[derive(Clone, Copy, Component, Default, PartialEq)] +enum Selection { + /// The camera. + /// + /// The camera can only be moved, not scaled or rotated. + #[default] + Camera, + /// The first decal, which an orange bounding box surrounds. + DecalA, + /// The second decal, which a lime green bounding box surrounds. + DecalB, +} + +impl fmt::Display for Selection { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match *self { + Selection::Camera => f.write_str("camera"), + Selection::DecalA => f.write_str("decal A"), + Selection::DecalB => f.write_str("decal B"), + } + } +} + +/// What happens when the mouse is dragged: one of a move, rotate, or scale +/// operation. +#[derive(Clone, Copy, Component, Default, PartialEq, Debug)] +enum DragMode { + /// The mouse moves the current selection. + #[default] + Move, + /// The mouse scales the current selection. + /// + /// This only applies to decals, not cameras. + Scale, + /// The mouse rotates the current selection around its local Z axis. + /// + /// This only applies to decals, not cameras. + Roll, +} + +impl fmt::Display for DragMode { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match *self { + DragMode::Move => f.write_str("move"), + DragMode::Scale => f.write_str("scale"), + DragMode::Roll => f.write_str("roll"), + } + } +} + +/// A marker component for the help text in the top left corner of the window. +#[derive(Clone, Copy, Component)] +struct HelpText; + +/// Entry point. +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "Bevy Decal Projectors Example".into(), + ..default() + }), + ..default() + })) + .init_resource::() + .add_event::>() + .add_systems(Startup, setup) + .add_systems(Update, draw_gizmos) + .add_systems(Update, rotate_cube) + .add_systems(Update, widgets::handle_ui_interactions::) + .add_systems( + Update, + (handle_selection_change, update_radio_buttons) + .after(widgets::handle_ui_interactions::), + ) + .add_systems(Update, process_move_input) + .add_systems(Update, process_scale_input) + .add_systems(Update, process_roll_input) + .add_systems(Update, switch_drag_mode) + .add_systems(Update, update_help_text) + .add_systems(Update, update_button_visibility) + .run(); +} + +/// Creates the scene. +fn setup( + mut commands: Commands, + asset_server: Res, + app_status: Res, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + spawn_cube(&mut commands, &mut meshes, &mut materials); + spawn_camera(&mut commands); + spawn_light(&mut commands); + spawn_decal_projectors(&mut commands, &asset_server); + spawn_buttons(&mut commands); + spawn_help_text(&mut commands, &app_status); +} + +/// Spawns the cube onto which the decals are projected. +fn spawn_cube( + commands: &mut Commands, + meshes: &mut Assets, + materials: &mut Assets, +) { + // Rotate the cube a bit just to make it a bit more interesting. + let mut transform = Transform::IDENTITY; + transform.rotate_y(FRAC_PI_3); + + commands + .spawn(Mesh3d(meshes.add(Cuboid::new(3.0, 3.0, 3.0)))) + .insert(MeshMaterial3d(materials.add(Color::from(SILVER)))) + .insert(transform); +} + +/// Spawns the directional light. +fn spawn_light(commands: &mut Commands) { + commands + .spawn(DirectionalLight::default()) + .insert(Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y)); +} + +/// Spawns the camera. +fn spawn_camera(commands: &mut Commands) { + commands + .spawn(Camera3d::default()) + .insert(Transform::from_xyz(0.0, 2.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y)) + // Tag the camera with `Selection::Camera`. + .insert(Selection::Camera); +} + +fn spawn_decal_projectors(commands: &mut Commands, asset_server: &AssetServer) { + let image = asset_server.load("branding/icon.png"); + + commands + .spawn(DecalProjector { + image: image.clone(), + }) + .insert(calculate_initial_projector_transform( + vec3(1.0, 3.0, 5.0), + Vec3::ZERO, + Vec2::splat(1.1), + )) + .insert(Selection::DecalA); + + commands + .spawn(DecalProjector { + image: image.clone(), + }) + .insert(calculate_initial_projector_transform( + vec3(-2.0, -1.0, 4.0), + Vec3::ZERO, + Vec2::splat(2.0), + )) + .insert(Selection::DecalB); +} + +/// Spawns the buttons at the bottom of the screen. +fn spawn_buttons(commands: &mut Commands) { + // Spawn the radio buttons that allow the user to select an object to + // control. + commands + .spawn(widgets::main_ui_node()) + .with_children(|parent| { + widgets::spawn_option_buttons( + parent, + "Drag to Move", + &[ + (Selection::Camera, "Camera"), + (Selection::DecalA, "Decal A"), + (Selection::DecalB, "Decal B"), + ], + ); + }); + + // Spawn the drag buttons that allow the user to control the scale and roll + // of the selected object. + commands + .spawn(Node { + flex_direction: FlexDirection::Row, + position_type: PositionType::Absolute, + right: Val::Px(10.0), + bottom: Val::Px(10.0), + column_gap: Val::Px(6.0), + ..default() + }) + .with_children(|parent| { + spawn_drag_button(parent, "Scale").insert(DragMode::Scale); + spawn_drag_button(parent, "Roll").insert(DragMode::Roll); + }); +} + +/// Spawns a button that the user can drag to change a parameter. +fn spawn_drag_button<'a>(commands: &'a mut ChildBuilder<'_>, label: &str) -> EntityCommands<'a> { + let mut kid = commands.spawn(Node { + border: BUTTON_BORDER, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + padding: BUTTON_PADDING, + ..default() + }); + kid.insert(Button) + .insert(BackgroundColor(Color::BLACK)) + .insert(BorderRadius::all(BUTTON_BORDER_RADIUS_SIZE)) + .insert(BUTTON_BORDER_COLOR) + .with_children(|parent| { + widgets::spawn_ui_text(parent, label, Color::WHITE); + }); + kid +} + +/// Spawns the help text at the top of the screen. +fn spawn_help_text(commands: &mut Commands, app_status: &AppStatus) { + commands + .spawn(Text::new(create_help_string(app_status))) + .insert(Node { + position_type: PositionType::Absolute, + top: Val::Px(12.0), + left: Val::Px(12.0), + ..default() + }) + .insert(HelpText); +} + +/// Draws the outlines that show the bounds of the decal projectors. +fn draw_gizmos( + mut gizmos: Gizmos, + decals: Query<(&GlobalTransform, &Selection), With>, +) { + for (global_transform, selection) in &decals { + let color = match *selection { + Selection::Camera => continue, + Selection::DecalA => ORANGE_RED, + Selection::DecalB => LIME, + }; + + gizmos.primitive_3d( + &Cuboid { + // Since the decal projector is a 1×1×1 cube in model space, its + // half-size is half of the scaling part of its transform. + half_size: global_transform.scale() * 0.5, + }, + Isometry3d { + rotation: global_transform.rotation(), + translation: global_transform.translation_vec3a(), + }, + color, + ); + } +} + +/// Calculates the initial transform of the decal projector. +fn calculate_initial_projector_transform(start: Vec3, looking_at: Vec3, size: Vec2) -> Transform { + let direction = looking_at - start; + let center = start + direction * 0.5; + Transform::from_translation(center) + .with_scale((size * 0.5).extend(direction.length())) + .looking_to(direction, Vec3::Y) +} + +/// Rotates the cube a bit every frame. +fn rotate_cube(mut meshes: Query<&mut Transform, With>) { + for mut transform in &mut meshes { + transform.rotate_y(CUBE_ROTATION_SPEED); + } +} + +/// Updates the state of the radio buttons when the user clicks on one. +fn update_radio_buttons( + mut widgets: Query<( + Entity, + Option<&mut BackgroundColor>, + Has, + &WidgetClickSender, + )>, + app_status: Res, + mut writer: TextUiWriter, +) { + for (entity, maybe_bg_color, has_text, sender) in &mut widgets { + let selected = app_status.selection == **sender; + if let Some(mut bg_color) = maybe_bg_color { + widgets::update_ui_radio_button(&mut bg_color, selected); + } + if has_text { + widgets::update_ui_radio_button_text(entity, &mut writer, selected); + } + } +} + +/// Changes the selection when the user clicks a radio button. +fn handle_selection_change( + mut events: EventReader>, + mut app_status: ResMut, +) { + for event in events.read() { + app_status.selection = **event; + } +} + +/// Process a drag event that moves the selected object. +fn process_move_input( + mut selections: Query<(&mut Transform, &Selection)>, + mouse_buttons: Res>, + mouse_motion: Res, + app_status: Res, +) { + // Only process drags when movement is selected. + if !mouse_buttons.pressed(MouseButton::Left) || app_status.drag_mode != DragMode::Move { + return; + } + + for (mut transform, selection) in &mut selections { + if app_status.selection != *selection { + continue; + } + + let position = transform.translation; + + // Convert to spherical coordinates. + let radius = position.length(); + let mut theta = acos(position.y / radius); + let mut phi = position.z.signum() * acos(position.x * position.xz().length_recip()); + + // Camera movement is the inverse of object movement. + let (phi_factor, theta_factor) = match *selection { + Selection::Camera => (1.0, -1.0), + Selection::DecalA | Selection::DecalB => (-1.0, 1.0), + }; + + // Adjust the spherical coordinates. Clamp the inclination to (0, π). + phi += phi_factor * mouse_motion.delta.x * MOVE_SPEED; + theta = f32::clamp( + theta + theta_factor * mouse_motion.delta.y * MOVE_SPEED, + 0.001, + PI - 0.001, + ); + + // Convert spherical coordinates back to Cartesian coordinates. + transform.translation = + radius * vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi)); + + // Look at the center, but preserve the previous roll angle. + let roll = transform.rotation.to_euler(EulerRot::YXZ).2; + transform.look_at(Vec3::ZERO, Vec3::Y); + let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ); + transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll); + } +} + +/// Processes a drag event that scales the selected target. +fn process_scale_input( + mut selections: Query<(&mut Transform, &Selection)>, + mouse_buttons: Res>, + mouse_motion: Res, + app_status: Res, +) { + // Only process drags when the scaling operation is selected. + if !mouse_buttons.pressed(MouseButton::Left) || app_status.drag_mode != DragMode::Scale { + return; + } + + for (mut transform, selection) in &mut selections { + if app_status.selection == *selection { + transform.scale *= 1.0 + mouse_motion.delta.x * SCALE_SPEED; + } + } +} + +/// Processes a drag event that rotates the selected target along its local Z +/// axis. +fn process_roll_input( + mut selections: Query<(&mut Transform, &Selection)>, + mouse_buttons: Res>, + mouse_motion: Res, + app_status: Res, +) { + // Only process drags when the rolling operation is selected. + if !mouse_buttons.pressed(MouseButton::Left) || app_status.drag_mode != DragMode::Roll { + return; + } + + for (mut transform, selection) in &mut selections { + if app_status.selection != *selection { + continue; + } + + let (yaw, pitch, mut roll) = transform.rotation.to_euler(EulerRot::YXZ); + roll += mouse_motion.delta.x * ROLL_SPEED; + transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll); + } +} + +/// Creates the help string at the top left of the screen. +fn create_help_string(app_status: &AppStatus) -> String { + format!( + "Click and drag to {} {}", + app_status.drag_mode, app_status.selection + ) +} + +/// Changes the drag mode when the user hovers over the "Scale" and "Roll" +/// buttons in the lower right. +/// +/// If the user is hovering over no such button, this system changes the drag +/// mode back to its default value of [`DragMode::Move`]. +fn switch_drag_mode( + mut commands: Commands, + mut interactions: Query<(&Interaction, &DragMode)>, + mut windows: Query>, + mouse_buttons: Res>, + mut app_status: ResMut, +) { + if mouse_buttons.pressed(MouseButton::Left) { + return; + } + + for (interaction, drag_mode) in &mut interactions { + if *interaction != Interaction::Hovered { + continue; + } + + app_status.drag_mode = *drag_mode; + + // Set the cursor to provide the user with a nice visual hint. + for window in &mut windows { + commands + .entity(window) + .insert(CursorIcon::from(SystemCursorIcon::EwResize)); + } + return; + } + + app_status.drag_mode = DragMode::Move; + + for window in &mut windows { + commands.entity(window).remove::(); + } +} + +/// Updates the help text in the top left of the screen to reflect the current +/// selection and drag mode. +fn update_help_text(mut help_text: Query<&mut Text, With>, app_status: Res) { + for mut text in &mut help_text { + text.0 = create_help_string(&app_status); + } +} + +/// Updates the visibility of the drag mode buttons so that they aren't visible +/// if the camera is selected. +fn update_button_visibility( + mut nodes: Query<&mut Visibility, With>, + app_status: Res, +) { + for mut visibility in &mut nodes { + *visibility = match app_status.selection { + Selection::Camera => Visibility::Hidden, + Selection::DecalA | Selection::DecalB => Visibility::Visible, + }; + } +} diff --git a/examples/helpers/widgets.rs b/examples/helpers/widgets.rs index ae8e5626aff8c..271b37a4feec1 100644 --- a/examples/helpers/widgets.rs +++ b/examples/helpers/widgets.rs @@ -22,6 +22,18 @@ pub struct RadioButton; #[derive(Clone, Copy, Component)] pub struct RadioButtonText; +/// The size of the border that surrounds buttons. +pub const BUTTON_BORDER: UiRect = UiRect::all(Val::Px(1.0)); + +/// The color of the border that surrounds buttons. +pub const BUTTON_BORDER_COLOR: BorderColor = BorderColor(Color::WHITE); + +/// The amount of rounding to apply to button corners. +pub const BUTTON_BORDER_RADIUS_SIZE: Val = Val::Px(6.0); + +/// The amount of space between the edge of the button and its label. +pub const BUTTON_PADDING: UiRect = UiRect::axes(Val::Px(12.0), Val::Px(6.0)); + /// Returns a [`Node`] appropriate for the outer main UI node. /// /// This UI is in the bottom left corner and has flex column support @@ -61,20 +73,24 @@ pub fn spawn_option_button( .spawn(( Button, Node { - border: UiRect::all(Val::Px(1.0)).with_left(if is_first { - Val::Px(1.0) - } else { - Val::Px(0.0) - }), + border: BUTTON_BORDER.with_left(if is_first { Val::Px(1.0) } else { Val::Px(0.0) }), justify_content: JustifyContent::Center, align_items: AlignItems::Center, - padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)), + padding: BUTTON_PADDING, ..default() }, - BorderColor(Color::WHITE), + BUTTON_BORDER_COLOR, BorderRadius::ZERO - .with_left(if is_first { Val::Px(6.0) } else { Val::Px(0.0) }) - .with_right(if is_last { Val::Px(6.0) } else { Val::Px(0.0) }), + .with_left(if is_first { + BUTTON_BORDER_RADIUS_SIZE + } else { + Val::Px(0.0) + }) + .with_right(if is_last { + BUTTON_BORDER_RADIUS_SIZE + } else { + Val::Px(0.0) + }), BackgroundColor(bg_color), )) .insert(RadioButton)