diff --git a/Cargo.toml b/Cargo.toml index 43c3a77b0b..be00c9e7c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -281,6 +281,16 @@ description = "Illustrates various lighting options in a simple scene" category = "3D Rendering" wasm = true +[[example]] +name = "spotlight" +path = "examples/3d/spotlight.rs" + +[package.metadata.example.spotlight] +name = "Spotlight" +description = "Illustrates spot lights" +category = "3D Rendering" +wasm = true + [[example]] name = "load_gltf" path = "examples/3d/load_gltf.rs" diff --git a/crates/bevy_gltf/src/loader.rs b/crates/bevy_gltf/src/loader.rs index c6cd534567..f110782f8b 100644 --- a/crates/bevy_gltf/src/loader.rs +++ b/crates/bevy_gltf/src/loader.rs @@ -10,7 +10,7 @@ use bevy_log::warn; use bevy_math::{Mat4, Vec3}; use bevy_pbr::{ AlphaMode, DirectionalLight, DirectionalLightBundle, PbrBundle, PointLight, PointLightBundle, - StandardMaterial, + SpotLight, SpotLightBundle, StandardMaterial, }; use bevy_render::{ camera::{ @@ -862,9 +862,33 @@ fn load_node( } } gltf::khr_lights_punctual::Kind::Spot { - inner_cone_angle: _inner_cone_angle, - outer_cone_angle: _outer_cone_angle, - } => warn!("Spot lights are not yet supported."), + inner_cone_angle, + outer_cone_angle, + } => { + let mut entity = parent.spawn_bundle(SpotLightBundle { + spot_light: SpotLight { + color: Color::from(light.color()), + // NOTE: KHR_punctual_lights defines the intensity units for spot lights in + // candela (lm/sr) which is luminous intensity and we need luminous power. + // For a spot light, we map luminous power = 4 * pi * luminous intensity + intensity: light.intensity() * std::f32::consts::PI * 4.0, + range: light.range().unwrap_or(20.0), + radius: light.range().unwrap_or(0.0), + inner_angle: inner_cone_angle, + outer_angle: outer_cone_angle, + ..Default::default() + }, + ..Default::default() + }); + if let Some(name) = light.name() { + entity.insert(Name::new(name.to_string())); + } + if let Some(extras) = light.extras() { + entity.insert(super::GltfExtras { + value: extras.get().to_string(), + }); + } + } } } diff --git a/crates/bevy_pbr/src/bundle.rs b/crates/bevy_pbr/src/bundle.rs index 7052459dea..2f3a9c63c9 100644 --- a/crates/bevy_pbr/src/bundle.rs +++ b/crates/bevy_pbr/src/bundle.rs @@ -1,4 +1,4 @@ -use crate::{DirectionalLight, Material, PointLight, StandardMaterial}; +use crate::{DirectionalLight, Material, PointLight, SpotLight, StandardMaterial}; use bevy_asset::Handle; use bevy_ecs::{bundle::Bundle, component::Component, reflect::ReflectComponent}; use bevy_reflect::Reflect; @@ -75,6 +75,18 @@ pub struct PointLightBundle { pub visibility: Visibility, } +/// A component bundle for spot light entities +#[derive(Debug, Bundle, Default)] +pub struct SpotLightBundle { + pub spot_light: SpotLight, + pub visible_entities: VisibleEntities, + pub frustum: Frustum, + pub transform: Transform, + pub global_transform: GlobalTransform, + /// Enables or disables the light + pub visibility: Visibility, +} + /// A component bundle for [`DirectionalLight`] entities. #[derive(Debug, Bundle, Default)] pub struct DirectionalLightBundle { diff --git a/crates/bevy_pbr/src/lib.rs b/crates/bevy_pbr/src/lib.rs index 35c2cc9a6a..a73ffcc9a4 100644 --- a/crates/bevy_pbr/src/lib.rs +++ b/crates/bevy_pbr/src/lib.rs @@ -20,8 +20,11 @@ pub mod prelude { #[doc(hidden)] pub use crate::{ alpha::AlphaMode, - bundle::{DirectionalLightBundle, MaterialMeshBundle, PbrBundle, PointLightBundle}, - light::{AmbientLight, DirectionalLight, PointLight}, + bundle::{ + DirectionalLightBundle, MaterialMeshBundle, PbrBundle, PointLightBundle, + SpotLightBundle, + }, + light::{AmbientLight, DirectionalLight, PointLight, SpotLight}, material::{Material, MaterialPlugin}, pbr_material::StandardMaterial, }; @@ -123,6 +126,7 @@ impl Plugin for PbrPlugin { app.register_type::() .register_type::() .register_type::() + .register_type::() .add_plugin(MeshRenderPlugin) .add_plugin(MaterialPlugin::::default()) .register_type::() @@ -152,13 +156,20 @@ impl Plugin for PbrPlugin { .add_system_to_stage( CoreStage::PostUpdate, update_directional_light_frusta - .label(SimulationLightSystems::UpdateDirectionalLightFrusta) + .label(SimulationLightSystems::UpdateLightFrusta) .after(TransformSystem::TransformPropagate), ) .add_system_to_stage( CoreStage::PostUpdate, update_point_light_frusta - .label(SimulationLightSystems::UpdatePointLightFrusta) + .label(SimulationLightSystems::UpdateLightFrusta) + .after(TransformSystem::TransformPropagate) + .after(SimulationLightSystems::AssignLightsToClusters), + ) + .add_system_to_stage( + CoreStage::PostUpdate, + update_spot_light_frusta + .label(SimulationLightSystems::UpdateLightFrusta) .after(TransformSystem::TransformPropagate) .after(SimulationLightSystems::AssignLightsToClusters), ) @@ -168,8 +179,7 @@ impl Plugin for PbrPlugin { .label(SimulationLightSystems::CheckLightVisibility) .after(TransformSystem::TransformPropagate) .after(VisibilitySystems::CalculateBounds) - .after(SimulationLightSystems::UpdateDirectionalLightFrusta) - .after(SimulationLightSystems::UpdatePointLightFrusta) + .after(SimulationLightSystems::UpdateLightFrusta) // NOTE: This MUST be scheduled AFTER the core renderer visibility check // because that resets entity ComputedVisibility for the first view // which would override any results from this otherwise diff --git a/crates/bevy_pbr/src/light.rs b/crates/bevy_pbr/src/light.rs index ae03ebe665..a5d09b1cb3 100644 --- a/crates/bevy_pbr/src/light.rs +++ b/crates/bevy_pbr/src/light.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use bevy_ecs::prelude::*; -use bevy_math::{Mat4, UVec2, UVec3, Vec2, Vec3, Vec3A, Vec3Swizzles, Vec4, Vec4Swizzles}; +use bevy_math::{Mat4, Quat, UVec2, UVec3, Vec2, Vec3, Vec3A, Vec3Swizzles, Vec4, Vec4Swizzles}; use bevy_reflect::prelude::*; use bevy_render::{ camera::{Camera, CameraProjection, OrthographicProjection}, @@ -16,9 +16,9 @@ use bevy_transform::components::GlobalTransform; use bevy_utils::tracing::warn; use crate::{ - calculate_cluster_factors, CubeMapFace, CubemapVisibleEntities, ViewClusterBindings, - CLUSTERED_FORWARD_STORAGE_BUFFER_COUNT, CUBE_MAP_FACES, MAX_UNIFORM_BUFFER_POINT_LIGHTS, - POINT_LIGHT_NEAR_Z, + calculate_cluster_factors, spot_light_projection_matrix, spot_light_view_matrix, CubeMapFace, + CubemapVisibleEntities, ViewClusterBindings, CLUSTERED_FORWARD_STORAGE_BUFFER_COUNT, + CUBE_MAP_FACES, MAX_UNIFORM_BUFFER_POINT_LIGHTS, POINT_LIGHT_NEAR_Z, }; /// A light that emits light in all directions from a central point. @@ -85,6 +85,59 @@ impl Default for PointLightShadowMap { } } +/// A light that emits light in a given direction from a central point. +/// Behaves like a point light in a perfectly absorbant housing that +/// shines light only in a given direction. The direction is taken from +/// the transform, and can be specified with [`Transform::looking_at`](bevy_transform::components::Transform::looking_at). +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component, Default)] +pub struct SpotLight { + pub color: Color, + pub intensity: f32, + pub range: f32, + pub radius: f32, + pub shadows_enabled: bool, + pub shadow_depth_bias: f32, + /// A bias applied along the direction of the fragment's surface normal. It is scaled to the + /// shadow map's texel size so that it can be small close to the camera and gets larger further + /// away. + pub shadow_normal_bias: f32, + /// Angle defining the distance from the spot light direction to the outer limit + /// of the light's cone of effect. + /// `outer_angle` should be < `PI / 2.0`. + /// `PI / 2.0` defines a hemispherical spot light, but shadows become very blocky as the angle + /// approaches this limit. + pub outer_angle: f32, + /// Angle defining the distance from the spot light direction to the inner limit + /// of the light's cone of effect. + /// Light is attenuated from `inner_angle` to `outer_angle` to give a smooth falloff. + /// `inner_angle` should be <= `outer_angle` + pub inner_angle: f32, +} + +impl SpotLight { + pub const DEFAULT_SHADOW_DEPTH_BIAS: f32 = 0.02; + pub const DEFAULT_SHADOW_NORMAL_BIAS: f32 = 0.6; +} + +impl Default for SpotLight { + fn default() -> Self { + // a quarter arc attenuating from the centre + Self { + color: Color::rgb(1.0, 1.0, 1.0), + /// Luminous power in lumens + intensity: 800.0, // Roughly a 60W non-halogen incandescent bulb + range: 20.0, + radius: 0.0, + shadows_enabled: false, + shadow_depth_bias: Self::DEFAULT_SHADOW_DEPTH_BIAS, + shadow_normal_bias: Self::DEFAULT_SHADOW_NORMAL_BIAS, + inner_angle: 0.0, + outer_angle: std::f32::consts::FRAC_PI_4, + } + } +} + /// A Directional light. /// /// Directional lights don't exist in reality but they are a good @@ -198,8 +251,7 @@ pub struct NotShadowReceiver; pub enum SimulationLightSystems { AddClusters, AssignLightsToClusters, - UpdateDirectionalLightFrusta, - UpdatePointLightFrusta, + UpdateLightFrusta, CheckLightVisibility, } @@ -419,6 +471,8 @@ pub fn add_clusters( #[derive(Clone, Component, Debug, Default)] pub struct VisiblePointLights { pub(crate) entities: Vec, + pub point_light_count: usize, + pub spot_light_count: usize, } impl VisiblePointLights { @@ -588,20 +642,113 @@ fn cluster_space_light_aabb( ) } +fn screen_to_view(screen_size: Vec2, inverse_projection: Mat4, screen: Vec2, ndc_z: f32) -> Vec4 { + let tex_coord = screen / screen_size; + let clip = Vec4::new( + tex_coord.x * 2.0 - 1.0, + (1.0 - tex_coord.y) * 2.0 - 1.0, + ndc_z, + 1.0, + ); + clip_to_view(inverse_projection, clip) +} const NDC_MIN: Vec2 = Vec2::NEG_ONE; const NDC_MAX: Vec2 = Vec2::ONE; -// Sort point lights with shadows enabled first, then by a stable key so that the index -// can be used to limit the number of point light shadows to render based on the device and -// we keep a stable set of lights visible +// Calculate the intersection of a ray from the eye through the view space position to a z plane +fn line_intersection_to_z_plane(origin: Vec3, p: Vec3, z: f32) -> Vec3 { + let v = p - origin; + let t = (z - Vec3::Z.dot(origin)) / Vec3::Z.dot(v); + origin + t * v +} + +#[allow(clippy::too_many_arguments)] +fn compute_aabb_for_cluster( + z_near: f32, + z_far: f32, + tile_size: Vec2, + screen_size: Vec2, + inverse_projection: Mat4, + is_orthographic: bool, + cluster_dimensions: UVec3, + ijk: UVec3, +) -> Aabb { + let ijk = ijk.as_vec3(); + + // Calculate the minimum and maximum points in screen space + let p_min = ijk.xy() * tile_size; + let p_max = p_min + tile_size; + + let cluster_min; + let cluster_max; + if is_orthographic { + // Use linear depth slicing for orthographic + + // Convert to view space at the cluster near and far planes + // NOTE: 1.0 is the near plane due to using reverse z projections + let p_min = screen_to_view( + screen_size, + inverse_projection, + p_min, + 1.0 - (ijk.z / cluster_dimensions.z as f32), + ) + .xyz(); + let p_max = screen_to_view( + screen_size, + inverse_projection, + p_max, + 1.0 - ((ijk.z + 1.0) / cluster_dimensions.z as f32), + ) + .xyz(); + + cluster_min = p_min.min(p_max); + cluster_max = p_min.max(p_max); + } else { + // Convert to view space at the near plane + // NOTE: 1.0 is the near plane due to using reverse z projections + let p_min = screen_to_view(screen_size, inverse_projection, p_min, 1.0); + let p_max = screen_to_view(screen_size, inverse_projection, p_max, 1.0); + + let z_far_over_z_near = -z_far / -z_near; + let cluster_near = if ijk.z == 0.0 { + 0.0 + } else { + -z_near * z_far_over_z_near.powf((ijk.z - 1.0) / (cluster_dimensions.z - 1) as f32) + }; + // NOTE: This could be simplified to: + // cluster_far = cluster_near * z_far_over_z_near; + let cluster_far = if cluster_dimensions.z == 1 { + -z_far + } else { + -z_near * z_far_over_z_near.powf(ijk.z / (cluster_dimensions.z - 1) as f32) + }; + + // Calculate the four intersection points of the min and max points with the cluster near and far planes + let p_min_near = line_intersection_to_z_plane(Vec3::ZERO, p_min.xyz(), cluster_near); + let p_min_far = line_intersection_to_z_plane(Vec3::ZERO, p_min.xyz(), cluster_far); + let p_max_near = line_intersection_to_z_plane(Vec3::ZERO, p_max.xyz(), cluster_near); + let p_max_far = line_intersection_to_z_plane(Vec3::ZERO, p_max.xyz(), cluster_far); + + cluster_min = p_min_near.min(p_min_far).min(p_max_near.min(p_max_far)); + cluster_max = p_min_near.max(p_min_far).max(p_max_near.max(p_max_far)); + } + + Aabb::from_min_max(cluster_min, cluster_max) +} + +// Sort lights by +// - point-light vs spot-light, so that we can iterate point lights and spot lights in contiguous blocks in the fragment shader, +// - then those with shadows enabled first, so that the index can be used to render at most `point_light_shadow_maps_count` +// point light shadows and `spot_light_shadow_maps_count` spot light shadow maps, +// - then by entity as a stable key to ensure that a consistent set of lights are chosen if the light count limit is exceeded. pub(crate) fn point_light_order( - (entity_1, shadows_enabled_1): (&Entity, &bool), - (entity_2, shadows_enabled_2): (&Entity, &bool), + (entity_1, shadows_enabled_1, is_spot_light_1): (&Entity, &bool, &bool), + (entity_2, shadows_enabled_2, is_spot_light_2): (&Entity, &bool, &bool), ) -> std::cmp::Ordering { - shadows_enabled_1 - .cmp(shadows_enabled_2) - .reverse() - .then_with(|| entity_1.cmp(entity_2)) + is_spot_light_1 + .cmp(is_spot_light_2) // pointlights before spot lights + .then_with(|| shadows_enabled_2.cmp(shadows_enabled_1)) // shadow casters before non-casters + .then_with(|| entity_1.cmp(entity_2)) // stable } #[derive(Clone, Copy)] @@ -609,8 +756,10 @@ pub(crate) fn point_light_order( pub(crate) struct PointLightAssignmentData { entity: Entity, translation: Vec3, + rotation: Quat, range: f32, shadows_enabled: bool, + spot_light_angle: Option, } #[derive(Default)] @@ -644,8 +793,10 @@ pub(crate) fn assign_lights_to_clusters( &mut Clusters, Option<&mut VisiblePointLights>, )>, - lights_query: Query<(Entity, &GlobalTransform, &PointLight, &Visibility)>, + point_lights_query: Query<(Entity, &GlobalTransform, &PointLight, &Visibility)>, + spot_lights_query: Query<(Entity, &GlobalTransform, &SpotLight, &Visibility)>, mut lights: Local>, + mut cluster_aabb_spheres: Local>>, mut max_point_lights_warning_emitted: Local, render_device: Option>, ) { @@ -658,15 +809,32 @@ pub(crate) fn assign_lights_to_clusters( lights.clear(); // collect just the relevant light query data into a persisted vec to avoid reallocating each frame lights.extend( - lights_query + point_lights_query .iter() .filter(|(.., visibility)| visibility.is_visible) .map( - |(entity, transform, light, _visibility)| PointLightAssignmentData { + |(entity, transform, point_light, _visibility)| PointLightAssignmentData { entity, translation: transform.translation, - shadows_enabled: light.shadows_enabled, - range: light.range, + rotation: Quat::default(), + shadows_enabled: point_light.shadows_enabled, + range: point_light.range, + spot_light_angle: None, + }, + ), + ); + lights.extend( + spot_lights_query + .iter() + .filter(|(.., visibility)| visibility.is_visible) + .map( + |(entity, transform, spot_light, _visibility)| PointLightAssignmentData { + entity, + translation: transform.translation, + rotation: transform.rotation, + shadows_enabled: spot_light.shadows_enabled, + range: spot_light.range, + spot_light_angle: Some(spot_light.outer_angle), }, ), ); @@ -680,8 +848,16 @@ pub(crate) fn assign_lights_to_clusters( if lights.len() > MAX_UNIFORM_BUFFER_POINT_LIGHTS && !supports_storage_buffers { lights.sort_by(|light_1, light_2| { point_light_order( - (&light_1.entity, &light_1.shadows_enabled), - (&light_2.entity, &light_2.shadows_enabled), + ( + &light_1.entity, + &light_1.shadows_enabled, + &light_1.spot_light_angle.is_some(), + ), + ( + &light_2.entity, + &light_2.shadows_enabled, + &light_2.spot_light_angle.is_some(), + ), ) }); @@ -876,11 +1052,18 @@ pub(crate) fn assign_lights_to_clusters( for lights in &mut clusters.lights { lights.entities.clear(); + lights.point_light_count = 0; + lights.spot_light_count = 0; } - clusters.lights.resize_with( - (clusters.dimensions.x * clusters.dimensions.y * clusters.dimensions.z) as usize, - VisiblePointLights::default, - ); + let cluster_count = + (clusters.dimensions.x * clusters.dimensions.y * clusters.dimensions.z) as usize; + clusters + .lights + .resize_with(cluster_count, VisiblePointLights::default); + + // initialize empty cluster bounding spheres + cluster_aabb_spheres.clear(); + cluster_aabb_spheres.extend(std::iter::repeat(None).take(cluster_count)); // Calculate the x/y/z cluster frustum planes in view space let mut x_planes = Vec::with_capacity(clusters.dimensions.x as usize + 1); @@ -991,6 +1174,15 @@ pub(crate) fn assign_lights_to_clusters( center: Vec3A::from(inverse_view_transform * light_sphere.center.extend(1.0)), radius: light_sphere.radius, }; + let spot_light_dir_sin_cos = light.spot_light_angle.map(|angle| { + let (angle_sin, angle_cos) = angle.sin_cos(); + ( + (inverse_view_transform * (light.rotation * Vec3::Z).extend(0.0)) + .truncate(), + angle_sin, + angle_cos, + ) + }); let light_center_clip = camera.projection_matrix() * view_light_sphere.center.extend(1.0); let light_center_ndc = light_center_clip.xyz() / light_center_clip.w; @@ -1084,10 +1276,66 @@ pub(crate) fn assign_lights_to_clusters( let mut cluster_index = ((y * clusters.dimensions.x + min_x) * clusters.dimensions.z + z) as usize; - // Mark the clusters in the range as affected - for _ in min_x..=max_x { - clusters.lights[cluster_index].entities.push(light.entity); - cluster_index += clusters.dimensions.z as usize; + + if let Some((view_light_direction, angle_sin, angle_cos)) = + spot_light_dir_sin_cos + { + for x in min_x..=max_x { + // further culling for spot lights + // get or initialize cluster bounding sphere + let cluster_aabb_sphere = &mut cluster_aabb_spheres[cluster_index]; + let cluster_aabb_sphere = if let Some(sphere) = cluster_aabb_sphere + { + &*sphere + } else { + let aabb = compute_aabb_for_cluster( + first_slice_depth, + far_z, + clusters.tile_size.as_vec2(), + screen_size.as_vec2(), + inverse_projection, + is_orthographic, + clusters.dimensions, + UVec3::new(x, y, z), + ); + let sphere = Sphere { + center: aabb.center, + radius: aabb.half_extents.length(), + }; + *cluster_aabb_sphere = Some(sphere); + cluster_aabb_sphere.as_ref().unwrap() + }; + + // test -- based on https://bartwronski.com/2017/04/13/cull-that-cone/ + let spot_light_offset = Vec3::from( + view_light_sphere.center - cluster_aabb_sphere.center, + ); + let spot_light_dist_sq = spot_light_offset.length_squared(); + let v1_len = spot_light_offset.dot(view_light_direction); + + let distance_closest_point = (angle_cos + * (spot_light_dist_sq - v1_len * v1_len).sqrt()) + - v1_len * angle_sin; + let angle_cull = + distance_closest_point > cluster_aabb_sphere.radius; + + let front_cull = v1_len > cluster_aabb_sphere.radius + light.range; + let back_cull = v1_len < -cluster_aabb_sphere.radius; + + if !angle_cull && !front_cull && !back_cull { + // this cluster is affected by the spot light + clusters.lights[cluster_index].entities.push(light.entity); + clusters.lights[cluster_index].spot_light_count += 1; + } + cluster_index += clusters.dimensions.z as usize; + } + } else { + for _ in min_x..=max_x { + // all clusters within range are affected by point lights + clusters.lights[cluster_index].entities.push(light.entity); + clusters.lights[cluster_index].point_light_count += 1; + cluster_index += clusters.dimensions.z as usize; + } } } } @@ -1101,9 +1349,10 @@ pub(crate) fn assign_lights_to_clusters( } else { let mut entities = Vec::new(); update_from_light_intersections(&mut entities); - commands - .entity(view_entity) - .insert(VisiblePointLights { entities }); + commands.entity(view_entity).insert(VisiblePointLights { + entities, + ..Default::default() + }); } } } @@ -1235,6 +1484,41 @@ pub fn update_point_light_frusta( } } +pub fn update_spot_light_frusta( + global_lights: Res, + mut views: Query< + (Entity, &GlobalTransform, &SpotLight, &mut Frustum), + Or<(Changed, Changed)>, + >, +) { + for (entity, transform, spot_light, mut frustum) in views.iter_mut() { + // The frusta are used for culling meshes to the light for shadow mapping + // so if shadow mapping is disabled for this light, then the frusta are + // not needed. + // Also, if the light is not relevant for any cluster, it will not be in the + // global lights set and so there is no need to update its frusta. + if !spot_light.shadows_enabled || !global_lights.entities.contains(&entity) { + continue; + } + + // ignore scale because we don't want to effectively scale light radius and range + // by applying those as a view transform to shadow map rendering of objects + let view_translation = GlobalTransform::from_translation(transform.translation); + let view_backward = transform.back(); + + let spot_view = spot_light_view_matrix(transform); + let spot_projection = spot_light_projection_matrix(spot_light.outer_angle); + let view_projection = spot_projection * spot_view.inverse(); + + *frustum = Frustum::from_view_projection( + &view_projection, + &view_translation.translation, + &view_backward, + spot_light.range, + ); + } +} + pub fn check_light_mesh_visibility( visible_point_lights: Query<&VisiblePointLights>, mut point_lights: Query<( @@ -1244,13 +1528,23 @@ pub fn check_light_mesh_visibility( &mut CubemapVisibleEntities, Option<&RenderLayers>, )>, - mut directional_lights: Query<( - &DirectionalLight, + mut spot_lights: Query<( + &SpotLight, + &GlobalTransform, &Frustum, &mut VisibleEntities, Option<&RenderLayers>, - &Visibility, )>, + mut directional_lights: Query< + ( + &DirectionalLight, + &Frustum, + &mut VisibleEntities, + Option<&RenderLayers>, + &Visibility, + ), + Without, + >, mut visible_entity_query: Query< ( Entity, @@ -1309,9 +1603,9 @@ pub fn check_light_mesh_visibility( // to prevent holding unneeded memory } - // Point lights for visible_lights in visible_point_lights.iter() { for light_entity in visible_lights.entities.iter().copied() { + // Point lights if let Ok(( point_light, transform, @@ -1360,6 +1654,7 @@ pub fn check_light_mesh_visibility( if !light_sphere.intersects_obb(aabb, &model_to_world) { continue; } + for (frustum, visible_entities) in cubemap_frusta .iter() .zip(cubemap_visible_entities.iter_mut()) @@ -1380,6 +1675,63 @@ pub fn check_light_mesh_visibility( // TODO: check for big changes in visible entities len() vs capacity() (ex: 2x) and resize // to prevent holding unneeded memory } + + // spot lights + if let Ok((point_light, transform, frustum, mut visible_entities, maybe_view_mask)) = + spot_lights.get_mut(light_entity) + { + visible_entities.entities.clear(); + + // NOTE: If shadow mapping is disabled for the light then it must have no visible entities + if !point_light.shadows_enabled { + continue; + } + + let view_mask = maybe_view_mask.copied().unwrap_or_default(); + let light_sphere = Sphere { + center: Vec3A::from(transform.translation), + radius: point_light.range, + }; + + for ( + entity, + visibility, + mut computed_visibility, + maybe_entity_mask, + maybe_aabb, + maybe_transform, + ) in visible_entity_query.iter_mut() + { + if !visibility.is_visible { + continue; + } + + let entity_mask = maybe_entity_mask.copied().unwrap_or_default(); + if !view_mask.intersects(&entity_mask) { + continue; + } + + // If we have an aabb and transform, do frustum culling + if let (Some(aabb), Some(transform)) = (maybe_aabb, maybe_transform) { + let model_to_world = transform.compute_matrix(); + // Do a cheap sphere vs obb test to prune out most meshes outside the sphere of the light + if !light_sphere.intersects_obb(aabb, &model_to_world) { + continue; + } + + if frustum.intersects_obb(aabb, &model_to_world, true) { + computed_visibility.is_visible = true; + visible_entities.entities.push(entity); + } + } else { + computed_visibility.is_visible = true; + visible_entities.entities.push(entity); + } + } + + // TODO: check for big changes in visible entities len() vs capacity() (ex: 2x) and resize + // to prevent holding unneeded memory + } } } } diff --git a/crates/bevy_pbr/src/render/clustered_forward.wgsl b/crates/bevy_pbr/src/render/clustered_forward.wgsl index a27e4b33b6..46c54ad6f6 100644 --- a/crates/bevy_pbr/src/render/clustered_forward.wgsl +++ b/crates/bevy_pbr/src/render/clustered_forward.wgsl @@ -27,18 +27,19 @@ fn fragment_cluster_index(frag_coord: vec2, view_z: f32, is_orthographic: b } // this must match CLUSTER_COUNT_SIZE in light.rs -let CLUSTER_COUNT_SIZE = 13u; -fn unpack_offset_and_count(cluster_index: u32) -> vec2 { +let CLUSTER_COUNT_SIZE = 9u; +fn unpack_offset_and_counts(cluster_index: u32) -> vec3 { #ifdef NO_STORAGE_BUFFERS_SUPPORT - let offset_and_count = cluster_offsets_and_counts.data[cluster_index >> 2u][cluster_index & ((1u << 2u) - 1u)]; - return vec2( - // The offset is stored in the upper 32 - CLUSTER_COUNT_SIZE = 19 bits - (offset_and_count >> CLUSTER_COUNT_SIZE) & ((1u << 32u - CLUSTER_COUNT_SIZE) - 1u), - // The count is stored in the lower CLUSTER_COUNT_SIZE = 13 bits - offset_and_count & ((1u << CLUSTER_COUNT_SIZE) - 1u) + let offset_and_counts = cluster_offsets_and_counts.data[cluster_index >> 2u][cluster_index & ((1u << 2u) - 1u)]; + // [ 31 .. 18 | 17 .. 9 | 8 .. 0 ] + // [ offset | point light count | spot light count ] + return vec3( + (offset_and_counts >> (CLUSTER_COUNT_SIZE * 2u)) & ((1u << (32u - (CLUSTER_COUNT_SIZE * 2u))) - 1u), + (offset_and_counts >> CLUSTER_COUNT_SIZE) & ((1u << CLUSTER_COUNT_SIZE) - 1u), + offset_and_counts & ((1u << CLUSTER_COUNT_SIZE) - 1u), ); #else - return cluster_offsets_and_counts.data[cluster_index]; + return cluster_offsets_and_counts.data[cluster_index].xyz; #endif } @@ -58,7 +59,7 @@ fn cluster_debug_visualization( output_color: vec4, view_z: f32, is_orthographic: bool, - offset_and_count: vec2, + offset_and_counts: vec3, cluster_index: u32, ) -> vec4 { // Cluster allocation debug (using 'over' alpha blending) @@ -82,9 +83,9 @@ fn cluster_debug_visualization( let cluster_overlay_alpha = 0.1; let max_light_complexity_per_cluster = 64.0; output_color.r = (1.0 - cluster_overlay_alpha) * output_color.r - + cluster_overlay_alpha * smoothStep(0.0, max_light_complexity_per_cluster, f32(offset_and_count[1])); + + cluster_overlay_alpha * smoothStep(0.0, max_light_complexity_per_cluster, f32(offset_and_counts[1] + offset_and_counts[2])); output_color.g = (1.0 - cluster_overlay_alpha) * output_color.g - + cluster_overlay_alpha * (1.0 - smoothStep(0.0, max_light_complexity_per_cluster, f32(offset_and_count[1]))); + + cluster_overlay_alpha * (1.0 - smoothStep(0.0, max_light_complexity_per_cluster, f32(offset_and_counts[1] + offset_and_counts[2]))); #endif // CLUSTERED_FORWARD_DEBUG_CLUSTER_LIGHT_COMPLEXITY #ifdef CLUSTERED_FORWARD_DEBUG_CLUSTER_COHERENCY // NOTE: Visualizes the cluster to which the fragment belongs diff --git a/crates/bevy_pbr/src/render/light.rs b/crates/bevy_pbr/src/render/light.rs index ab21a62758..5b7f16de96 100644 --- a/crates/bevy_pbr/src/render/light.rs +++ b/crates/bevy_pbr/src/render/light.rs @@ -1,7 +1,8 @@ use crate::{ point_light_order, AmbientLight, Clusters, CubemapVisibleEntities, DirectionalLight, DirectionalLightShadowMap, DrawMesh, GlobalVisiblePointLights, MeshPipeline, NotShadowCaster, - PointLight, PointLightShadowMap, SetMeshBindGroup, VisiblePointLights, SHADOW_SHADER_HANDLE, + PointLight, PointLightShadowMap, SetMeshBindGroup, SpotLight, VisiblePointLights, + SHADOW_SHADER_HANDLE, }; use bevy_asset::Handle; use bevy_core_pipeline::core_3d::Transparent3d; @@ -9,7 +10,7 @@ use bevy_ecs::{ prelude::*, system::{lifetimeless::*, SystemParamItem}, }; -use bevy_math::{Mat4, UVec2, UVec3, UVec4, Vec2, Vec3, Vec4, Vec4Swizzles}; +use bevy_math::{Mat4, UVec3, UVec4, Vec2, Vec3, Vec3Swizzles, Vec4, Vec4Swizzles}; use bevy_render::{ camera::{Camera, CameraProjection}, color::Color, @@ -56,6 +57,7 @@ pub struct ExtractedPointLight { shadows_enabled: bool, shadow_depth_bias: f32, shadow_normal_bias: f32, + spot_light_angles: Option<(f32, f32)>, } #[derive(Component)] @@ -71,13 +73,15 @@ pub struct ExtractedDirectionalLight { #[derive(Copy, Clone, ShaderType, Default, Debug)] pub struct GpuPointLight { - // The lower-right 2x2 values of the projection matrix 22 23 32 33 - projection_lr: Vec4, + // For point lights: the lower-right 2x2 values of the projection matrix [2][2] [2][3] [3][2] [3][3] + // For spot lights: 2 components of the direction (x,z), spot_scale and spot_offset + light_custom_data: Vec4, color_inverse_square_range: Vec4, position_radius: Vec4, flags: u32, shadow_depth_bias: f32, shadow_normal_bias: f32, + spot_light_tan_angle: f32, } #[derive(ShaderType)] @@ -162,6 +166,7 @@ bitflags::bitflags! { #[repr(transparent)] struct PointLightFlags: u32 { const SHADOWS_ENABLED = (1 << 0); + const SPOT_LIGHT_Y_NEGATIVE = (1 << 1); const NONE = 0; const UNINITIALIZED = 0xFFFF; } @@ -198,12 +203,13 @@ pub struct GpuLights { // w is cluster_dimensions.z * log(near) / log(far / near) cluster_factors: Vec4, n_directional_lights: u32, + // offset from spot light's light index to spot light's shadow map index + spot_light_shadowmap_offset: i32, } // NOTE: this must be kept in sync with the same constants in pbr.frag pub const MAX_UNIFORM_BUFFER_POINT_LIGHTS: usize = 256; pub const MAX_DIRECTIONAL_LIGHTS: usize = 1; -pub const DIRECTIONAL_SHADOW_LAYERS: u32 = MAX_DIRECTIONAL_LIGHTS as u32; pub const SHADOW_FORMAT: TextureFormat = TextureFormat::Depth32Float; pub struct ShadowPipeline { @@ -402,14 +408,19 @@ pub fn extract_lights( directional_light_shadow_map: Res, global_point_lights: Res, mut point_lights: Query<(&PointLight, &mut CubemapVisibleEntities, &GlobalTransform)>, - mut directional_lights: Query<( - Entity, - &DirectionalLight, - &mut VisibleEntities, - &GlobalTransform, - &Visibility, - )>, + mut spot_lights: Query<(&SpotLight, &mut VisibleEntities, &GlobalTransform)>, + mut directional_lights: Query< + ( + Entity, + &DirectionalLight, + &mut VisibleEntities, + &GlobalTransform, + &Visibility, + ), + Without, + >, mut previous_point_lights_len: Local, + mut previous_spot_lights_len: Local, ) { // NOTE: These shadow map resources are extracted here as they are used here too so this avoids // races between scheduling of ExtractResourceSystems and this system. @@ -452,6 +463,7 @@ pub fn extract_lights( shadow_normal_bias: point_light.shadow_normal_bias * point_light_texel_size * std::f32::consts::SQRT_2, + spot_light_angles: None, }, render_cubemap_visible_entities, ), @@ -461,6 +473,44 @@ pub fn extract_lights( *previous_point_lights_len = point_lights_values.len(); commands.insert_or_spawn_batch(point_lights_values); + let mut spot_lights_values = Vec::with_capacity(*previous_spot_lights_len); + for entity in global_point_lights.iter().copied() { + if let Ok((spot_light, visible_entities, transform)) = spot_lights.get_mut(entity) { + let render_visible_entities = std::mem::take(visible_entities.into_inner()); + let texel_size = + 2.0 * spot_light.outer_angle.tan() / directional_light_shadow_map.size as f32; + + spot_lights_values.push(( + entity, + ( + ExtractedPointLight { + color: spot_light.color, + // NOTE: Map from luminous power in lumens to luminous intensity in lumens per steradian + // for a point light. See https://google.github.io/filament/Filament.html#mjx-eqn-pointLightLuminousPower + // for details. + // Note: Filament uses a divisor of PI for spot lights. We choose to use the same 4*PI divisor + // in both cases so that toggling between point light and spot light keeps lit areas lit equally, + // which seems least surprising for users + intensity: spot_light.intensity / (4.0 * std::f32::consts::PI), + range: spot_light.range, + radius: spot_light.radius, + transform: *transform, + shadows_enabled: spot_light.shadows_enabled, + shadow_depth_bias: spot_light.shadow_depth_bias, + // The factor of SQRT_2 is for the worst-case diagonal offset + shadow_normal_bias: spot_light.shadow_normal_bias + * texel_size + * std::f32::consts::SQRT_2, + spot_light_angles: Some((spot_light.inner_angle, spot_light.outer_angle)), + }, + render_visible_entities, + ), + )); + } + } + *previous_spot_lights_len = spot_lights_values.len(); + commands.insert_or_spawn_batch(spot_lights_values); + for (entity, directional_light, visible_entities, transform, visibility) in directional_lights.iter_mut() { @@ -619,6 +669,9 @@ pub enum LightEntity { light_entity: Entity, face_index: usize, }, + Spot { + light_entity: Entity, + }, } pub fn calculate_cluster_factors( near: f32, @@ -637,6 +690,38 @@ pub fn calculate_cluster_factors( } } +// this method of constructing a basis from a vec3 is used by glam::Vec3::any_orthonormal_pair +// we will also construct it in the fragment shader and need our implementations to match, +// so we reproduce it here to avoid a mismatch if glam changes. we also switch the handedness +// could move this onto transform but it's pretty niche +pub(crate) fn spot_light_view_matrix(transform: &GlobalTransform) -> Mat4 { + // the matrix z_local (opposite of transform.forward()) + let fwd_dir = transform.local_z().extend(0.0); + + let sign = 1f32.copysign(fwd_dir.z); + let a = -1.0 / (fwd_dir.z + sign); + let b = fwd_dir.x * fwd_dir.y * a; + let up_dir = Vec4::new( + 1.0 + sign * fwd_dir.x * fwd_dir.x * a, + sign * b, + -sign * fwd_dir.x, + 0.0, + ); + let right_dir = Vec4::new(-b, -sign - fwd_dir.y * fwd_dir.y * a, fwd_dir.y, 0.0); + + Mat4::from_cols( + right_dir, + up_dir, + fwd_dir, + transform.translation.extend(1.0), + ) +} + +pub(crate) fn spot_light_projection_matrix(angle: f32) -> Mat4 { + // spot light projection FOV is 2x the angle from spot light centre to outer edge + Mat4::perspective_infinite_reverse_rh(angle * 2.0, 1.0, POINT_LIGHT_NEAR_Z) +} + #[allow(clippy::too_many_arguments)] pub fn prepare_lights( mut commands: Commands, @@ -670,20 +755,50 @@ pub fn prepare_lights( let mut point_lights: Vec<_> = point_lights.iter().collect::>(); #[cfg(not(feature = "webgl"))] - let max_point_light_shadow_maps = point_lights - .iter() - .filter(|light| light.1.shadows_enabled) - .count() - .min((render_device.limits().max_texture_array_layers / 6) as usize); + let max_texture_array_layers = render_device.limits().max_texture_array_layers as usize; + #[cfg(not(feature = "webgl"))] + let max_texture_cubes = max_texture_array_layers / 6; #[cfg(feature = "webgl")] - let max_point_light_shadow_maps = 1; + let max_texture_array_layers = 1; + #[cfg(feature = "webgl")] + let max_texture_cubes = 1; - // Sort point lights with shadows enabled first, then by a stable key so that the index can be used - // to render at most `max_point_light_shadow_maps` point light shadows. + let point_light_count = point_lights + .iter() + .filter(|light| light.1.shadows_enabled && light.1.spot_light_angles.is_none()) + .count(); + + let point_light_shadow_maps_count = point_light_count.min(max_texture_cubes); + + let directional_shadow_maps_count = directional_lights + .iter() + .filter(|(_, light)| light.shadows_enabled) + .count() + .min(max_texture_array_layers); + + let spot_light_shadow_maps_count = point_lights + .iter() + .filter(|(_, light)| light.shadows_enabled && light.spot_light_angles.is_some()) + .count() + .min(max_texture_array_layers - directional_shadow_maps_count); + + // Sort lights by + // - point-light vs spot-light, so that we can iterate point lights and spot lights in contiguous blocks in the fragment shader, + // - then those with shadows enabled first, so that the index can be used to render at most `point_light_shadow_maps_count` + // point light shadows and `spot_light_shadow_maps_count` spot light shadow maps, + // - then by entity as a stable key to ensure that a consistent set of lights are chosen if the light count limit is exceeded. point_lights.sort_by(|(entity_1, light_1), (entity_2, light_2)| { point_light_order( - (entity_1, &light_1.shadows_enabled), - (entity_2, &light_2.shadows_enabled), + ( + entity_1, + &light_1.shadows_enabled, + &light_1.spot_light_angles.is_some(), + ), + ( + entity_2, + &light_2.shadows_enabled, + &light_2.spot_light_angles.is_some(), + ), ) }); @@ -696,17 +811,50 @@ pub fn prepare_lights( let mut gpu_point_lights = Vec::new(); for (index, &(entity, light)) in point_lights.iter().enumerate() { let mut flags = PointLightFlags::NONE; + // Lights are sorted, shadow enabled lights are first - if light.shadows_enabled && index < max_point_light_shadow_maps { + if light.shadows_enabled + && (index < point_light_shadow_maps_count + || (light.spot_light_angles.is_some() + && index - point_light_count < spot_light_shadow_maps_count)) + { flags |= PointLightFlags::SHADOWS_ENABLED; } + + let (light_custom_data, spot_light_tan_angle) = match light.spot_light_angles { + Some((inner, outer)) => { + let light_direction = light.transform.forward(); + if light_direction.y.is_sign_negative() { + flags |= PointLightFlags::SPOT_LIGHT_Y_NEGATIVE; + } + + let cos_outer = outer.cos(); + let spot_scale = 1.0 / f32::max(inner.cos() - cos_outer, 1e-4); + let spot_offset = -cos_outer * spot_scale; + + ( + // For spot lights: the direction (x,z), spot_scale and spot_offset + light_direction.xz().extend(spot_scale).extend(spot_offset), + outer.tan(), + ) + } + None => { + ( + // For point lights: the lower-right 2x2 values of the projection matrix [2][2] [2][3] [3][2] [3][3] + Vec4::new( + cube_face_projection.z_axis.z, + cube_face_projection.z_axis.w, + cube_face_projection.w_axis.z, + cube_face_projection.w_axis.w, + ), + // unused + 0.0, + ) + } + }; + gpu_point_lights.push(GpuPointLight { - projection_lr: Vec4::new( - cube_face_projection.z_axis.z, - cube_face_projection.z_axis.w, - cube_face_projection.w_axis.z, - cube_face_projection.w_axis.w, - ), + light_custom_data, // premultiply color by intensity // we don't use the alpha at all, so no reason to multiply only [0..3] color_inverse_square_range: (Vec4::from_slice(&light.color.as_linear_rgba_f32()) @@ -717,6 +865,7 @@ pub fn prepare_lights( flags: flags.bits, shadow_depth_bias: light.shadow_depth_bias, shadow_normal_bias: light.shadow_normal_bias, + spot_light_tan_angle, }); global_light_meta.entity_to_index.insert(entity, index); } @@ -734,7 +883,7 @@ pub fn prepare_lights( size: Extent3d { width: point_light_shadow_map.size as u32, height: point_light_shadow_map.size as u32, - depth_or_array_layers: max_point_light_shadow_maps.max(1) as u32 * 6, + depth_or_array_layers: point_light_shadow_maps_count.max(1) as u32 * 6, }, mip_level_count: 1, sample_count: 1, @@ -752,7 +901,9 @@ pub fn prepare_lights( .min(render_device.limits().max_texture_dimension_2d), height: (directional_light_shadow_map.size as u32) .min(render_device.limits().max_texture_dimension_2d), - depth_or_array_layers: DIRECTIONAL_SHADOW_LAYERS, + depth_or_array_layers: (directional_shadow_maps_count + + spot_light_shadow_maps_count) + .max(1) as u32, }, mip_level_count: 1, sample_count: 1, @@ -785,13 +936,18 @@ pub fn prepare_lights( ), cluster_dimensions: clusters.dimensions.extend(n_clusters), n_directional_lights: directional_lights.iter().len() as u32, + // spotlight shadow maps are stored in the directional light array, starting at directional_shadow_maps_count. + // the spot lights themselves start in the light array at point_light_count. so to go from light + // index to shadow map index, we need to subtract point light shadowmap count and add directional shadowmap count. + spot_light_shadowmap_offset: directional_shadow_maps_count as i32 + - point_light_count as i32, }; // TODO: this should select lights based on relevance to the view instead of the first ones that show up in a query for &(light_entity, light) in point_lights .iter() // Lights are sorted, shadow enabled lights are first - .take(max_point_light_shadow_maps) + .take(point_light_shadow_maps_count) .filter(|(_, light)| light.shadows_enabled) { let light_index = *global_light_meta @@ -846,6 +1002,55 @@ pub fn prepare_lights( } } + // spot lights + for (light_index, &(light_entity, light)) in point_lights + .iter() + .skip(point_light_count) + .take(spot_light_shadow_maps_count) + .enumerate() + { + let spot_view_matrix = spot_light_view_matrix(&light.transform); + let spot_view_transform = GlobalTransform::from_matrix(spot_view_matrix); + + let angle = light.spot_light_angles.expect("lights should be sorted so that \ + [point_light_shadow_maps_count..point_light_shadow_maps_count + spot_light_shadow_maps_count] are spot lights").1; + let spot_projection = spot_light_projection_matrix(angle); + + let depth_texture_view = + directional_light_depth_texture + .texture + .create_view(&TextureViewDescriptor { + label: Some("spot_light_shadow_map_texture_view"), + format: None, + dimension: Some(TextureViewDimension::D2), + aspect: TextureAspect::All, + base_mip_level: 0, + mip_level_count: None, + base_array_layer: (directional_shadow_maps_count + light_index) as u32, + array_layer_count: NonZeroU32::new(1), + }); + + let view_light_entity = commands + .spawn() + .insert_bundle(( + ShadowView { + depth_texture_view, + pass_name: format!("shadow pass spot light {}", light_index,), + }, + ExtractedView { + width: directional_light_shadow_map.size as u32, + height: directional_light_shadow_map.size as u32, + transform: spot_view_transform, + projection: spot_projection, + }, + RenderPhase::::default(), + LightEntity::Spot { light_entity }, + )) + .id(); + + view_lights.push(view_light_entity); + } + for (i, (light_entity, light)) in directional_lights .iter() .enumerate() @@ -980,28 +1185,29 @@ pub fn prepare_lights( // this must match CLUSTER_COUNT_SIZE in pbr.wgsl // and must be large enough to contain MAX_UNIFORM_BUFFER_POINT_LIGHTS -const CLUSTER_COUNT_SIZE: u32 = 13; +const CLUSTER_COUNT_SIZE: u32 = 9; -const CLUSTER_OFFSET_MASK: u32 = (1 << (32 - CLUSTER_COUNT_SIZE)) - 1; +const CLUSTER_OFFSET_MASK: u32 = (1 << (32 - (CLUSTER_COUNT_SIZE * 2))) - 1; const CLUSTER_COUNT_MASK: u32 = (1 << CLUSTER_COUNT_SIZE) - 1; -const POINT_LIGHT_INDEX_MASK: u32 = (1 << 8) - 1; // NOTE: With uniform buffer max binding size as 16384 bytes -// that means we can fit say 256 point lights in one uniform +// that means we can fit 256 point lights in one uniform // buffer, which means the count can be at most 256 so it // needs 9 bits. // The array of indices can also use u8 and that means the // offset in to the array of indices needs to be able to address // 16384 values. log2(16384) = 14 bits. -// We use 32 bits to store the pair, so we choose to divide the -// remaining 9 bits proportionally to give some future room. -// This means we can pack the offset into the upper 19 bits of a u32 -// and the count into the lower 13 bits. +// We use 32 bits to store the offset and counts so +// we pack the offset into the upper 14 bits of a u32, +// the point light count into bits 9-17, and the spot light count into bits 0-8. +// [ 31 .. 18 | 17 .. 9 | 8 .. 0 ] +// [ offset | point light count | spot light count ] // NOTE: This assumes CPU and GPU endianness are the same which is true // for all common and tested x86/ARM CPUs and AMD/NVIDIA/Intel/Apple/etc GPUs -fn pack_offset_and_count(offset: usize, count: usize) -> u32 { - ((offset as u32 & CLUSTER_OFFSET_MASK) << CLUSTER_COUNT_SIZE) - | (count as u32 & CLUSTER_COUNT_MASK) +fn pack_offset_and_counts(offset: usize, point_count: usize, spot_count: usize) -> u32 { + ((offset as u32 & CLUSTER_OFFSET_MASK) << (CLUSTER_COUNT_SIZE * 2)) + | (point_count as u32 & CLUSTER_COUNT_MASK) << CLUSTER_COUNT_SIZE + | (spot_count as u32 & CLUSTER_COUNT_MASK) } #[derive(ShaderType)] @@ -1043,7 +1249,7 @@ struct GpuClusterLightIndexListsStorage { #[derive(ShaderType, Default)] struct GpuClusterOffsetsAndCountsStorage { #[size(runtime)] - data: Vec, + data: Vec, } enum ViewClusterBuffers { @@ -1122,7 +1328,7 @@ impl ViewClusterBindings { } } - pub fn push_offset_and_count(&mut self, offset: usize, count: usize) { + pub fn push_offset_and_counts(&mut self, offset: usize, point_count: usize, spot_count: usize) { match &mut self.buffers { ViewClusterBuffers::Uniform { cluster_offsets_and_counts, @@ -1134,7 +1340,7 @@ impl ViewClusterBindings { return; } let component = self.n_offsets & ((1 << 2) - 1); - let packed = pack_offset_and_count(offset, count); + let packed = pack_offset_and_counts(offset, point_count, spot_count); cluster_offsets_and_counts.get_mut().data[array_index][component] = packed; } @@ -1142,10 +1348,12 @@ impl ViewClusterBindings { cluster_offsets_and_counts, .. } => { - cluster_offsets_and_counts - .get_mut() - .data - .push(UVec2::new(offset as u32, count as u32)); + cluster_offsets_and_counts.get_mut().data.push(UVec4::new( + offset as u32, + point_count as u32, + spot_count as u32, + 0, + )); } } @@ -1165,7 +1373,7 @@ impl ViewClusterBindings { let array_index = self.n_indices >> 4; // >> 4 is equivalent to / 16 let component = (self.n_indices >> 2) & ((1 << 2) - 1); let sub_index = self.n_indices & ((1 << 2) - 1); - let index = index as u32 & POINT_LIGHT_INDEX_MASK; + let index = index as u32; cluster_light_index_lists.get_mut().data[array_index][component] |= index << (8 * sub_index); @@ -1278,8 +1486,11 @@ pub fn prepare_clusters( for _z in 0..cluster_config.dimensions.z { let offset = view_clusters_bindings.n_indices(); let cluster_lights = &extracted_clusters.data[cluster_index]; - let count = cluster_lights.len(); - view_clusters_bindings.push_offset_and_count(offset, count); + view_clusters_bindings.push_offset_and_counts( + offset, + cluster_lights.point_light_count, + cluster_lights.spot_light_count, + ); if !indices_full { for entity in cluster_lights.iter() { @@ -1340,6 +1551,7 @@ pub fn queue_shadows( mut view_light_shadow_phases: Query<(&LightEntity, &mut RenderPhase)>, point_light_entities: Query<&CubemapVisibleEntities, With>, directional_light_entities: Query<&VisibleEntities, With>, + spot_light_entities: Query<&VisibleEntities, With>, ) { for view_lights in view_lights.iter() { let draw_shadow_mesh = shadow_draw_functions @@ -1360,6 +1572,9 @@ pub fn queue_shadows( .get(*light_entity) .expect("Failed to get point light visible entities") .get(*face_index), + LightEntity::Spot { light_entity } => spot_light_entities + .get(*light_entity) + .expect("Failed to get spot light visible entities"), }; // NOTE: Lights with shadow mapping disabled will have no visible entities // so no meshes will be queued diff --git a/crates/bevy_pbr/src/render/mesh_view_types.wgsl b/crates/bevy_pbr/src/render/mesh_view_types.wgsl index 31d3cdebb2..98b447209c 100644 --- a/crates/bevy_pbr/src/render/mesh_view_types.wgsl +++ b/crates/bevy_pbr/src/render/mesh_view_types.wgsl @@ -13,17 +13,20 @@ struct View { }; struct PointLight { - // NOTE: [2][2] [2][3] [3][2] [3][3] - projection_lr: vec4; + // For point lights: the lower-right 2x2 values of the projection matrix [2][2] [2][3] [3][2] [3][3] + // For spot lights: the direction (x,z), spot_scale and spot_offset + light_custom_data: vec4; color_inverse_square_range: vec4; position_radius: vec4; // 'flags' is a bit field indicating various options. u32 is 32 bits so we have up to 32 options. flags: u32; shadow_depth_bias: f32; shadow_normal_bias: f32; + spot_light_tan_angle: f32; }; -let POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT: u32 = 1u; +let POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT: u32 = 1u; +let POINT_LIGHT_FLAGS_SPOT_LIGHT_Y_NEGATIVE: u32 = 2u; struct DirectionalLight { view_projection: mat4x4; @@ -55,6 +58,7 @@ struct Lights { // w is cluster_dimensions.z / (-far - -near) cluster_factors: vec4; n_directional_lights: u32; + spot_light_shadowmap_offset: i32; }; #ifdef NO_STORAGE_BUFFERS_SUPPORT @@ -78,6 +82,6 @@ struct ClusterLightIndexLists { data: array; }; struct ClusterOffsetsAndCounts { - data: array>; + data: array>; }; #endif diff --git a/crates/bevy_pbr/src/render/pbr_functions.wgsl b/crates/bevy_pbr/src/render/pbr_functions.wgsl index f35d2a3d6b..a5b52a2586 100644 --- a/crates/bevy_pbr/src/render/pbr_functions.wgsl +++ b/crates/bevy_pbr/src/render/pbr_functions.wgsl @@ -169,8 +169,10 @@ fn pbr( view.inverse_view[3].z ), in.world_position); let cluster_index = fragment_cluster_index(in.frag_coord.xy, view_z, in.is_orthographic); - let offset_and_count = unpack_offset_and_count(cluster_index); - for (var i: u32 = offset_and_count[0]; i < offset_and_count[0] + offset_and_count[1]; i = i + 1u) { + let offset_and_counts = unpack_offset_and_counts(cluster_index); + + // point lights + for (var i: u32 = offset_and_counts[0]; i < offset_and_counts[0] + offset_and_counts[1]; i = i + 1u) { let light_id = get_light_id(i); let light = point_lights.data[light_id]; var shadow: f32 = 1.0; @@ -182,6 +184,19 @@ fn pbr( light_accum = light_accum + light_contrib * shadow; } + // spot lights + for (var i: u32 = offset_and_counts[0] + offset_and_counts[1]; i < offset_and_counts[0] + offset_and_counts[1] + offset_and_counts[2]; i = i + 1u) { + let light_id = get_light_id(i); + let light = point_lights.data[light_id]; + var shadow: f32 = 1.0; + if ((mesh.flags & MESH_FLAGS_SHADOW_RECEIVER_BIT) != 0u + && (light.flags & POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT) != 0u) { + shadow = fetch_spot_shadow(light_id, in.world_position, in.world_normal); + } + let light_contrib = spot_light(in.world_position.xyz, light, roughness, NdotV, in.N, in.V, R, F0, diffuse_color); + light_accum = light_accum + light_contrib * shadow; + } + let n_directional_lights = lights.n_directional_lights; for (var i: u32 = 0u; i < n_directional_lights; i = i + 1u) { let light = lights.directional_lights[i]; @@ -207,7 +222,7 @@ fn pbr( output_color, view_z, in.is_orthographic, - offset_and_count, + offset_and_counts, cluster_index, ); diff --git a/crates/bevy_pbr/src/render/pbr_lighting.wgsl b/crates/bevy_pbr/src/render/pbr_lighting.wgsl index 79818bc837..c373177c41 100644 --- a/crates/bevy_pbr/src/render/pbr_lighting.wgsl +++ b/crates/bevy_pbr/src/render/pbr_lighting.wgsl @@ -239,6 +239,31 @@ fn point_light( return ((diffuse + specular_light) * light.color_inverse_square_range.rgb) * (rangeAttenuation * NoL); } +fn spot_light( + world_position: vec3, light: PointLight, roughness: f32, NdotV: f32, N: vec3, V: vec3, + R: vec3, F0: vec3, diffuseColor: vec3 +) -> vec3 { + // reuse the point light calculations + let point = point_light(world_position, light, roughness, NdotV, N, V, R, F0, diffuseColor); + + // reconstruct spot dir from x/z and y-direction flag + var spot_dir = vec3(light.light_custom_data.x, 0.0, light.light_custom_data.y); + spot_dir.y = sqrt(1.0 - spot_dir.x * spot_dir.x - spot_dir.z * spot_dir.z); + if ((light.flags & POINT_LIGHT_FLAGS_SPOT_LIGHT_Y_NEGATIVE) != 0u) { + spot_dir.y = -spot_dir.y; + } + let light_to_frag = light.position_radius.xyz - world_position.xyz; + + // calculate attenuation based on filament formula https://google.github.io/filament/Filament.html#listing_glslpunctuallight + // spot_scale and spot_offset have been precomputed + // note we normalize here to get "l" from the filament listing. spot_dir is already normalized + let cd = dot(-spot_dir, normalize(light_to_frag)); + let attenuation = saturate(cd * light.light_custom_data.z + light.light_custom_data.w); + let spot_attenuation = attenuation * attenuation; + + return point * spot_attenuation; +} + fn directional_light(light: DirectionalLight, roughness: f32, NdotV: f32, normal: vec3, view: vec3, R: vec3, F0: vec3, diffuseColor: vec3) -> vec3 { let incident_light = light.direction_to_light.xyz; diff --git a/crates/bevy_pbr/src/render/shadows.wgsl b/crates/bevy_pbr/src/render/shadows.wgsl index 9cc2c7e84d..3953edc041 100644 --- a/crates/bevy_pbr/src/render/shadows.wgsl +++ b/crates/bevy_pbr/src/render/shadows.wgsl @@ -25,7 +25,7 @@ fn fetch_point_shadow(light_id: u32, frag_position: vec4, surface_normal: v // projection * vec4(0, 0, -major_axis_magnitude, 1.0) // and keeping only the terms that have any impact on the depth. // Projection-agnostic approach: - let zw = -major_axis_magnitude * light.projection_lr.xy + light.projection_lr.zw; + let zw = -major_axis_magnitude * light.light_custom_data.xy + light.light_custom_data.zw; let depth = zw.x / zw.y; // do the lookup, using HW PCF and comparison @@ -41,6 +41,63 @@ fn fetch_point_shadow(light_id: u32, frag_position: vec4, surface_normal: v #endif } +fn fetch_spot_shadow(light_id: u32, frag_position: vec4, surface_normal: vec3) -> f32 { + let light = point_lights.data[light_id]; + + let surface_to_light = light.position_radius.xyz - frag_position.xyz; + + // construct the light view matrix + var spot_dir = vec3(light.light_custom_data.x, 0.0, light.light_custom_data.y); + // reconstruct spot dir from x/z and y-direction flag + spot_dir.y = sqrt(1.0 - spot_dir.x * spot_dir.x - spot_dir.z * spot_dir.z); + if ((light.flags & POINT_LIGHT_FLAGS_SPOT_LIGHT_Y_NEGATIVE) != 0u) { + spot_dir.y = -spot_dir.y; + } + + // view matrix z_axis is the reverse of transform.forward() + let fwd = -spot_dir; + let distance_to_light = dot(fwd, surface_to_light); + let offset_position = + -surface_to_light + + (light.shadow_depth_bias * normalize(surface_to_light)) + + (surface_normal.xyz * light.shadow_normal_bias) * distance_to_light; + + // the construction of the up and right vectors needs to precisely mirror the code + // in render/light.rs:spot_light_view_matrix + var sign = -1.0; + if (fwd.z >= 0.0) { + sign = 1.0; + } + let a = -1.0 / (fwd.z + sign); + let b = fwd.x * fwd.y * a; + let up_dir = vec3(1.0 + sign * fwd.x * fwd.x * a, sign * b, -sign * fwd.x); + let right_dir = vec3(-b, -sign - fwd.y * fwd.y * a, fwd.y); + let light_inv_rot = mat3x3(right_dir, up_dir, fwd); + + // because the matrix is a pure rotation matrix, the inverse is just the transpose, and to calculate + // the product of the transpose with a vector we can just post-multiply instead of pre-multplying. + // this allows us to keep the matrix construction code identical between CPU and GPU. + let projected_position = offset_position * light_inv_rot; + + // divide xy by perspective matrix "f" and by -projected.z (projected.z is -projection matrix's w) + // to get ndc coordinates + let f_div_minus_z = 1.0 / (light.spot_light_tan_angle * -projected_position.z); + let shadow_xy_ndc = projected_position.xy * f_div_minus_z; + // convert to uv coordinates + let shadow_uv = shadow_xy_ndc * vec2(0.5, -0.5) + vec2(0.5, 0.5); + + // 0.1 must match POINT_LIGHT_NEAR_Z + let depth = 0.1 / -projected_position.z; + + #ifdef NO_ARRAY_TEXTURES_SUPPORT + return textureSampleCompare(directional_shadow_textures, directional_shadow_textures_sampler, + shadow_uv, depth); + #else + return textureSampleCompareLevel(directional_shadow_textures, directional_shadow_textures_sampler, + shadow_uv, i32(light_id) + lights.spot_light_shadowmap_offset, depth); + #endif +} + fn fetch_directional_shadow(light_id: u32, frag_position: vec4, surface_normal: vec3) -> f32 { let light = lights.directional_lights[light_id]; diff --git a/crates/bevy_render/src/primitives/mod.rs b/crates/bevy_render/src/primitives/mod.rs index 4f1b67555b..7bb3be093d 100644 --- a/crates/bevy_render/src/primitives/mod.rs +++ b/crates/bevy_render/src/primitives/mod.rs @@ -123,6 +123,9 @@ impl Plane { } } +/// A frustum defined by the 6 containing planes +/// Planes are ordered left, right, top, bottom, near, far +/// Normals point into the contained volume #[derive(Component, Clone, Copy, Debug, Default, Reflect)] #[reflect(Component)] pub struct Frustum { diff --git a/examples/3d/lighting.rs b/examples/3d/lighting.rs index e8a1a067b7..89101f0a51 100644 --- a/examples/3d/lighting.rs +++ b/examples/3d/lighting.rs @@ -121,22 +121,28 @@ fn setup( }); }); - // green point light + // green spot light commands - .spawn_bundle(PointLightBundle { - // transform: Transform::from_xyz(5.0, 8.0, 2.0), - transform: Transform::from_xyz(-1.0, 2.0, 0.0), - point_light: PointLight { + .spawn_bundle(SpotLightBundle { + transform: Transform::from_xyz(-1.0, 2.0, 0.0) + .looking_at(Vec3::new(-1.0, 0.0, 0.0), Vec3::Z), + spot_light: SpotLight { intensity: 1600.0, // lumens - roughly a 100W non-halogen incandescent bulb color: Color::GREEN, shadows_enabled: true, + inner_angle: 0.6, + outer_angle: 0.8, ..default() }, ..default() }) .with_children(|builder| { builder.spawn_bundle(PbrBundle { - mesh: meshes.add(Mesh::from(shape::UVSphere { + transform: Transform::from_rotation(Quat::from_rotation_x( + std::f32::consts::PI / 2.0, + )), + mesh: meshes.add(Mesh::from(shape::Capsule { + depth: 0.125, radius: 0.1, ..default() })), diff --git a/examples/3d/spotlight.rs b/examples/3d/spotlight.rs new file mode 100644 index 0000000000..d241d3d236 --- /dev/null +++ b/examples/3d/spotlight.rs @@ -0,0 +1,166 @@ +use bevy::{ + diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin}, + pbr::NotShadowCaster, + prelude::*, +}; +use rand::{thread_rng, Rng}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugin(FrameTimeDiagnosticsPlugin::default()) + .add_plugin(LogDiagnosticsPlugin::default()) + .add_startup_system(setup) + .add_system(light_sway) + .add_system(movement) + .run(); +} + +#[derive(Component)] +struct Movable; + +/// set up a simple 3D scene +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + // ground plane + commands.spawn_bundle(PbrBundle { + mesh: meshes.add(Mesh::from(shape::Plane { size: 100.0 })), + material: materials.add(StandardMaterial { + base_color: Color::GREEN, + perceptual_roughness: 1.0, + ..default() + }), + ..default() + }); + + // cubes + let mut rng = thread_rng(); + for _ in 0..100 { + let x = rng.gen_range(-5.0..5.0); + let y = rng.gen_range(-5.0..5.0); + let z = rng.gen_range(-5.0..5.0); + commands + .spawn_bundle(PbrBundle { + mesh: meshes.add(Mesh::from(shape::Cube { size: 0.5 })), + material: materials.add(StandardMaterial { + base_color: Color::BLUE, + ..default() + }), + transform: Transform::from_xyz(x, y, z), + ..default() + }) + .insert(Movable); + } + + // ambient light + commands.insert_resource(AmbientLight { + color: Color::rgb(0.0, 1.0, 1.0), + brightness: 0.14, + }); + + for x in 0..4 { + for z in 0..4 { + let x = x as f32 - 2.0; + let z = z as f32 - 2.0; + // red spot_light + commands + .spawn_bundle(SpotLightBundle { + transform: Transform::from_xyz(1.0 + x, 2.0, z) + .looking_at(Vec3::new(1.0 + x, 0.0, z), Vec3::X), + spot_light: SpotLight { + intensity: 200.0, // lumens + color: Color::WHITE, + shadows_enabled: true, + inner_angle: std::f32::consts::PI / 4.0 * 0.85, + outer_angle: std::f32::consts::PI / 4.0, + ..default() + }, + ..default() + }) + .with_children(|builder| { + builder.spawn_bundle(PbrBundle { + mesh: meshes.add(Mesh::from(shape::UVSphere { + radius: 0.05, + ..default() + })), + material: materials.add(StandardMaterial { + base_color: Color::RED, + emissive: Color::rgba_linear(1.0, 0.0, 0.0, 0.0), + ..default() + }), + ..default() + }); + builder + .spawn_bundle(PbrBundle { + transform: Transform::from_translation(Vec3::Z * -0.1), + mesh: meshes.add(Mesh::from(shape::UVSphere { + radius: 0.1, + ..default() + })), + material: materials.add(StandardMaterial { + base_color: Color::MAROON, + emissive: Color::rgba_linear(0.125, 0.0, 0.0, 0.0), + ..default() + }), + ..default() + }) + .insert(NotShadowCaster); + }); + } + } + + // camera + commands.spawn_bundle(Camera3dBundle { + transform: Transform::from_xyz(-4.0, 5.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y), + ..default() + }); +} + +fn light_sway(time: Res