diff --git a/Cargo.toml b/Cargo.toml index 54f99d37b5a77..0b84c0145da57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1189,6 +1189,17 @@ category = "3D Rendering" # TAA not supported by WebGL wasm = false +[[example]] +name = "only_shadow_caster" +path = "examples/3d/only_shadow_caster.rs" +doc-scrape-examples = true + +[package.metadata.example.only_shadow_caster] +name = "Only Shadow Caster" +description = "Makes an entity invisible to the main camera while still casting shadows" +category = "3D Rendering" +wasm = true + [[example]] name = "atmospheric_fog" path = "examples/3d/atmospheric_fog.rs" diff --git a/crates/bevy_pbr/src/material.rs b/crates/bevy_pbr/src/material.rs index 42717b57c40fe..1fce9c38632a8 100644 --- a/crates/bevy_pbr/src/material.rs +++ b/crates/bevy_pbr/src/material.rs @@ -1153,6 +1153,7 @@ pub fn queue_material_meshes( render_materials: Res>, render_mesh_instances: Res, render_material_instances: Res, + render_passes: Query<&RenderPasses>, mesh_allocator: Res, gpu_preprocessing_support: Res, mut opaque_render_phases: ResMut>, @@ -1186,6 +1187,10 @@ pub fn queue_material_meshes( let rangefinder = view.rangefinder3d(); for (render_entity, visible_entity) in visible_entities.iter::() { + let pass_mask = render_passes + .get(*render_entity) + .map_or(RenderPassMask::ALL, |passes| passes.0); + let Some((current_change_tick, pipeline_id)) = view_specialized_material_pipeline_cache .get(visible_entity) .map(|(current_change_tick, pipeline_id)| (*current_change_tick, *pipeline_id)) @@ -1217,6 +1222,9 @@ pub fn queue_material_meshes( match material.properties.render_phase_type { RenderPhaseType::Transmissive => { + if !pass_mask.contains(RenderPassMask::TRANSMISSIVE_MAIN) { + continue; + } let distance = rangefinder.distance(&mesh_instance.center) + material.properties.depth_bias; let Some(draw_function) = material @@ -1236,6 +1244,9 @@ pub fn queue_material_meshes( }); } RenderPhaseType::Opaque => { + if !pass_mask.contains(RenderPassMask::OPAQUE_MAIN) { + continue; + } if material.properties.render_method == OpaqueRendererMethod::Deferred { // Even though we aren't going to insert the entity into // a bin, we still want to update its cache entry. That @@ -1275,6 +1286,9 @@ pub fn queue_material_meshes( } // Alpha mask RenderPhaseType::AlphaMask => { + if !pass_mask.contains(RenderPassMask::ALPHA_MASK_MAIN) { + continue; + } let Some(draw_function) = material .properties .get_draw_function(MainPassAlphaMaskDrawFunction) @@ -1304,6 +1318,9 @@ pub fn queue_material_meshes( ); } RenderPhaseType::Transparent => { + if !pass_mask.contains(RenderPassMask::TRANSPARENT_MAIN) { + continue; + } let distance = rangefinder.distance(&mesh_instance.center) + material.properties.depth_bias; let Some(draw_function) = material diff --git a/crates/bevy_pbr/src/prepass/mod.rs b/crates/bevy_pbr/src/prepass/mod.rs index d6e6ce69b49b0..b115b669659cc 100644 --- a/crates/bevy_pbr/src/prepass/mod.rs +++ b/crates/bevy_pbr/src/prepass/mod.rs @@ -38,7 +38,8 @@ use bevy_render::{ ExtractedView, Msaa, RenderVisibilityRanges, RetainedViewEntity, ViewUniform, ViewUniformOffset, ViewUniforms, VISIBILITY_RANGES_STORAGE_BUFFER_COUNT, }, - Extract, ExtractSchedule, Render, RenderApp, RenderDebugFlags, RenderStartup, RenderSystems, + Extract, ExtractSchedule, Render, RenderApp, RenderDebugFlags, RenderPassMask, RenderPasses, + RenderStartup, RenderSystems, }; use bevy_shader::{load_shader_library, Shader, ShaderDefVal}; use bevy_transform::prelude::GlobalTransform; @@ -1009,6 +1010,7 @@ pub fn queue_prepass_material_meshes( render_mesh_instances: Res, render_materials: Res>, render_material_instances: Res, + render_passes: Query<&RenderPasses>, mesh_allocator: Res, gpu_preprocessing_support: Res, mut opaque_prepass_render_phases: ResMut>, @@ -1047,6 +1049,10 @@ pub fn queue_prepass_material_meshes( } for (render_entity, visible_entity) in visible_entities.iter::() { + let pass_mask = render_passes + .get(*render_entity) + .map_or(RenderPassMask::ALL, |passes| passes.0); + let Some((current_change_tick, pipeline_id)) = view_specialized_material_pipeline_cache.get(visible_entity) else { @@ -1088,6 +1094,9 @@ pub fn queue_prepass_material_meshes( match material.properties.render_phase_type { RenderPhaseType::Opaque => { if deferred { + if !pass_mask.contains(RenderPassMask::OPAQUE_MAIN) { + continue; + } let Some(draw_function) = material .properties .get_draw_function(DeferredOpaqueDrawFunction) @@ -1114,6 +1123,9 @@ pub fn queue_prepass_material_meshes( *current_change_tick, ); } else if let Some(opaque_phase) = opaque_phase.as_mut() { + if !pass_mask.intersects(RenderPassMask::PREPASS) { + continue; + } let (vertex_slab, index_slab) = mesh_allocator.mesh_slabs(&mesh_instance.mesh_asset_id); let Some(draw_function) = material @@ -1145,6 +1157,9 @@ pub fn queue_prepass_material_meshes( } RenderPhaseType::AlphaMask => { if deferred { + if !pass_mask.contains(RenderPassMask::ALPHA_MASK_MAIN) { + continue; + } let (vertex_slab, index_slab) = mesh_allocator.mesh_slabs(&mesh_instance.mesh_asset_id); let Some(draw_function) = material @@ -1175,6 +1190,9 @@ pub fn queue_prepass_material_meshes( *current_change_tick, ); } else if let Some(alpha_mask_phase) = alpha_mask_phase.as_mut() { + if !pass_mask.intersects(RenderPassMask::PREPASS) { + continue; + } let (vertex_slab, index_slab) = mesh_allocator.mesh_slabs(&mesh_instance.mesh_asset_id); let Some(draw_function) = material diff --git a/crates/bevy_pbr/src/render/light.rs b/crates/bevy_pbr/src/render/light.rs index 9c95f0711493d..bec5877d6b400 100644 --- a/crates/bevy_pbr/src/render/light.rs +++ b/crates/bevy_pbr/src/render/light.rs @@ -41,6 +41,7 @@ use bevy_render::{ camera::SortedCameras, mesh::allocator::MeshAllocator, view::{NoIndirectDrawing, RetainedViewEntity}, + RenderPassMask, RenderPasses, }; use bevy_render::{ diagnostic::RecordDiagnostics, @@ -1946,6 +1947,7 @@ pub fn queue_shadows( render_mesh_instances: Res, render_materials: Res>, render_material_instances: Res, + render_passes: Query<&RenderPasses>, mut shadow_render_phases: ResMut>, gpu_preprocessing_support: Res, mesh_allocator: Res, @@ -2003,6 +2005,12 @@ pub fn queue_shadows( }; for (entity, main_entity) in visible_entities.iter().copied() { + if let Ok(render_passes) = render_passes.get(entity) + && !render_passes.0.contains(RenderPassMask::SHADOW) + { + continue; + } + let Some((current_change_tick, pipeline_id)) = view_specialized_material_pipeline_cache.get(&main_entity) else { diff --git a/crates/bevy_pbr/src/wireframe.rs b/crates/bevy_pbr/src/wireframe.rs index c4b19df63f09f..3f4d76fd96752 100644 --- a/crates/bevy_pbr/src/wireframe.rs +++ b/crates/bevy_pbr/src/wireframe.rs @@ -863,6 +863,7 @@ pub fn specialize_wireframes( fn queue_wireframes( custom_draw_functions: Res>, render_mesh_instances: Res, + render_passes: Query<&RenderPasses>, gpu_preprocessing_support: Res, mesh_allocator: Res, specialized_wireframe_pipeline_cache: Res, @@ -883,6 +884,12 @@ fn queue_wireframes( }; for (render_entity, visible_entity) in visible_entities.iter::() { + if let Ok(render_passes) = render_passes.get(*render_entity) + && !render_passes.0.contains(RenderPassMask::MAIN) + { + continue; + } + let Some(wireframe_instance) = render_wireframe_instances.get(visible_entity) else { continue; }; diff --git a/crates/bevy_render/src/lib.rs b/crates/bevy_render/src/lib.rs index 2325139a0af98..6eea0f369734f 100644 --- a/crates/bevy_render/src/lib.rs +++ b/crates/bevy_render/src/lib.rs @@ -69,8 +69,14 @@ pub mod view; pub mod prelude { #[doc(hidden)] pub use crate::{ - alpha::AlphaMode, camera::NormalizedRenderTargetExt as _, texture::ManualTextureViews, - view::Msaa, ExtractSchedule, + alpha::AlphaMode, + camera::NormalizedRenderTargetExt as _, + texture::ManualTextureViews, + view::Msaa, + ExtractSchedule, + // Render pass participation mask component and flags + RenderPassMask, + RenderPasses, }; } @@ -92,6 +98,7 @@ use alloc::sync::Arc; use batching::gpu_preprocessing::BatchingPlugin; use bevy_app::{App, AppLabel, Plugin, SubApp}; use bevy_asset::{AssetApp, AssetServer}; +use bevy_ecs::query::QueryItem; use bevy_ecs::{ prelude::*, schedule::{ScheduleBuildSettings, ScheduleLabel}, @@ -143,6 +150,75 @@ bitflags! { } } +bitflags! { + /// Which render passes an entity participates in. + /// + /// This mask can be used to exclude an entity from specific render + /// passes such as the main camera pass, prepasses, or shadow passes. + #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] + pub struct RenderPassMask: u8 { + /// No participation in any pass. + const NONE = 0; + /// Participation in the main opaque pass. + const OPAQUE_MAIN = 1 << 0; + /// Participation in the main alpha-mask pass. + const ALPHA_MASK_MAIN = 1 << 1; + /// Participation in the main transparent pass. + const TRANSPARENT_MAIN = 1 << 2; + /// Participation in the main transmissive pass. + const TRANSMISSIVE_MAIN = 1 << 3; + + /// Participation in the depth prepass. + const DEPTH_PREPASS = 1 << 4; + /// Participation in the normal prepass. + const NORMAL_PREPASS = 1 << 5; + /// Participation in the motion vector prepass. + const MOTION_VECTOR_PREPASS = 1 << 6; + + /// Participation in shadow-view passes. + const SHADOW = 1 << 7; + + /// Participation in main camera passes. + const MAIN = Self::OPAQUE_MAIN.bits() + | Self::ALPHA_MASK_MAIN.bits() + | Self::TRANSPARENT_MAIN.bits() + | Self::TRANSMISSIVE_MAIN.bits(); + + /// Participation in prepasses (depth / normal / motion vectors). + /// + /// Note: today, Bevy's "prepass" is commonly executed as a single pass that can write + /// multiple outputs depending on view configuration. These bits exist to allow more + /// fine-grained control and future expansion. + const PREPASS = Self::DEPTH_PREPASS.bits() + | Self::NORMAL_PREPASS.bits() + | Self::MOTION_VECTOR_PREPASS.bits(); + /// Participation in all known passes. + const ALL = Self::MAIN.bits() | Self::PREPASS.bits() | Self::SHADOW.bits(); + } +} + +/// Component to control which render passes this entity should be queued into. +/// +/// Defaults to `RenderPassMask::ALL` (participates in all passes). +#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] +pub struct RenderPasses(pub RenderPassMask); + +impl Default for RenderPasses { + fn default() -> Self { + Self(RenderPassMask::ALL) + } +} + +impl extract_component::ExtractComponent for RenderPasses { + type QueryData = &'static Self; + type QueryFilter = (); + type Out = Self; + + fn extract_component(item: QueryItem<'_, '_, Self::QueryData>) -> Option { + Some(*item) + } +} + /// The systems sets of the default [`App`] rendering schedule. /// /// These can be useful for ordering, but you almost never want to add your systems to these sets. @@ -363,6 +439,7 @@ impl Plugin for RenderPlugin { WindowRenderPlugin, CameraPlugin, ViewPlugin, + extract_component::ExtractComponentPlugin::::default(), MeshRenderAssetPlugin, GlobalsPlugin, #[cfg(feature = "morph")] @@ -443,6 +520,30 @@ impl Plugin for RenderPlugin { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_passes_default_is_all() { + assert_eq!(RenderPasses::default().0, RenderPassMask::ALL); + } + + #[test] + fn render_pass_mask_all_contains_expected_bits() { + assert!(RenderPassMask::ALL.contains(RenderPassMask::OPAQUE_MAIN)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::ALPHA_MASK_MAIN)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::TRANSPARENT_MAIN)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::TRANSMISSIVE_MAIN)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::MAIN)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::DEPTH_PREPASS)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::NORMAL_PREPASS)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::MOTION_VECTOR_PREPASS)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::PREPASS)); + assert!(RenderPassMask::ALL.contains(RenderPassMask::SHADOW)); + } +} + /// A "scratch" world used to avoid allocating new worlds every frame when /// swapping out the [`MainWorld`] for [`ExtractSchedule`]. #[derive(Resource, Default)] diff --git a/examples/3d/only_shadow_caster.rs b/examples/3d/only_shadow_caster.rs new file mode 100644 index 0000000000000..ca9c2dde60885 --- /dev/null +++ b/examples/3d/only_shadow_caster.rs @@ -0,0 +1,121 @@ +//! Demonstrates how to make an entity invisible to the main camera while still allowing it to cast +//! shadows. +//! +//! This uses `RenderPasses(RenderPassMask::SHADOW)` to exclude entities from the main camera passes +//! while keeping them in the shadow pass. +//! +//! This example also shows a simple “fake shadow caster” setup: the visible red cube is marked +//! `NotShadowCaster`, while a shadow-only sphere is co-located with it so the cube appears to cast a +//! sphere-shaped shadow. + +use std::f32::consts::PI; + +use bevy::{ + color::palettes::basic::{BLUE, GREEN, RED}, + light::NotShadowCaster, + prelude::*, + render::{RenderPassMask, RenderPasses}, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, animate_light_direction) + .run(); +} + +/// Set up a 3D scene to test shadow casters +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let sphere_radius = 0.5; + let cube_edge = sphere_radius * 2.0; + let default_material = materials.add(StandardMaterial::default()); + let sphere_handle = meshes.add(Sphere::new(sphere_radius)); + + // Floor/ground plane - our shadow receiver + commands.spawn(( + Mesh3d(meshes.add(Plane3d::default().mesh().size(10.0, 10.0))), + MeshMaterial3d(default_material.clone()), + Name::new("Ground Plane"), + )); + + // Visible red cube that appears to cast a sphere-shaped shadow + let cuboid_handle = meshes.add(Cuboid::new(cube_edge, cube_edge, cube_edge)); + // Make the visible red cube not cast shadows; the invisible sphere will cast + // a shadow in the same place so that it appears to belong to this cube. + commands.spawn(( + Mesh3d(cuboid_handle), + MeshMaterial3d(materials.add(Color::from(RED))), + Transform::from_xyz(0.0, cube_edge * 0.5, 0.0), + NotShadowCaster, + Name::new("Red Cube"), + )); + + // Visible green cube that casts and receives shadows normally + commands.spawn(( + Mesh3d(meshes.add(Cuboid::new(cube_edge, cube_edge, cube_edge))), + MeshMaterial3d(materials.add(Color::from(GREEN))), + Transform::from_xyz(1.25, cube_edge * 0.5, 0.0), + Name::new("Green cube"), + )); + + // Visible blue rectilinear cuboid that casts and receives shadows normally + let pillar_width = 0.50; + let pillar_height = pillar_width * 2.75; + let pillar_depth = pillar_width; + commands.spawn(( + Mesh3d(meshes.add(Cuboid::new(pillar_width, pillar_height, pillar_depth))), + MeshMaterial3d(materials.add(Color::from(BLUE))), + Transform::from_xyz(0.25, pillar_height * 0.5, 1.25) + .with_rotation(Quat::from_rotation_y(PI / 3.0)), + Name::new("Blue cuboid"), + )); + + // A sphere that only casts a shadow so that the visible red cube appears to cast a + // sphere-shaped shadow. + commands.spawn(( + Mesh3d(sphere_handle), + MeshMaterial3d(default_material.clone()), + Transform::from_xyz(0.0, sphere_radius, 0.0), + RenderPasses(RenderPassMask::SHADOW), + Name::new("Invisible sphere"), + )); + + // An invisible rectilinear cuboid that casts a shadow but is excluded from the main passes. + commands.spawn(( + Mesh3d(meshes.add(Cuboid::new(pillar_width, pillar_height, pillar_depth))), + MeshMaterial3d(default_material.clone()), + Transform::from_xyz(-2.0, pillar_height * 0.5, 0.0), + RenderPasses(RenderPassMask::SHADOW), + Name::new("Invisible cuboid"), + )); + + commands.spawn(( + DirectionalLight { + illuminance: light_consts::lux::OVERCAST_DAY, + shadows_enabled: true, + ..default() + }, + Transform::from_rotation(Quat::from_euler(EulerRot::ZYX, 0.0, PI / 2., -PI / 4.)), + Name::new("Light"), + )); + + commands.spawn(( + Camera3d::default(), + Transform::from_xyz(-3.0, 5.0, 3.0).looking_at(Vec3::new(0.0, 1.0, 1.0), Vec3::Y), + Name::new("Camera"), + )); +} + +fn animate_light_direction( + time: Res