From 8ea52c896e1e9e7da6a61b50b888182d12d73c7a Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Thu, 7 May 2026 20:03:21 -0400 Subject: [PATCH 01/11] Fix: Send CurrentView world position via immediates for gpu vis range culling --- crates/bevy_pbr/src/render/gpu_preprocess.rs | 92 ++++++++++++++----- .../bevy_pbr/src/render/mesh_preprocess.wgsl | 12 ++- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/crates/bevy_pbr/src/render/gpu_preprocess.rs b/crates/bevy_pbr/src/render/gpu_preprocess.rs index 33617abac86dc..9a5b89a0ff4c2 100644 --- a/crates/bevy_pbr/src/render/gpu_preprocess.rs +++ b/crates/bevy_pbr/src/render/gpu_preprocess.rs @@ -18,7 +18,7 @@ use bevy_core_pipeline::{ DeferredPrepass, DepthPrepass, MotionVectorPrepass, NormalPrepass, PreviousViewData, PreviousViewUniformOffset, PreviousViewUniforms, }, - schedule::{Core3d, Core3dSystems}, + schedule::{Core3d, Core3dSystems, RootNonCameraView}, }; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{ @@ -45,6 +45,7 @@ use bevy_render::{ PreprocessWorkItemBuffers, UntypedPhaseBatchedInstanceBuffers, UntypedPhaseIndirectParametersBuffers, }, + camera::ExtractedCamera, diagnostic::RecordDiagnostics as _, occlusion_culling::OcclusionCulling, render_phase::GpuRenderBinnedMeshInstance, @@ -69,6 +70,7 @@ use bevy_render::{ use bevy_shader::Shader; use bevy_utils::{default, TypeIdMap}; use bitflags::bitflags; +use bytemuck::{Pod, Zeroable}; use smallvec::{smallvec, SmallVec}; use tracing::warn; @@ -291,7 +293,7 @@ pub enum PhasePreprocessBindGroups { }, /// The bind groups used for the compute shader when indirect drawing is - /// being used, but occlusion culling isn't being used. + /// being used and occlusion culling is being used. /// /// Because indirect drawing requires splitting the meshes into indexed and /// non-indexed meshes, and because occlusion culling requires splitting @@ -313,6 +315,13 @@ pub enum PhasePreprocessBindGroups { }, } +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +struct PreprocessImmediates { + cur_view_world_position: [f32; 3], + late_preprocess_work_item_indirect_offset: u32, +} + /// The bind groups for the compute shaders that reset indirect draw counts and /// build indirect parameters. /// @@ -600,7 +609,15 @@ pub fn unpack_bins( } pub fn early_gpu_preprocess( - current_view: ViewQuery, Without>, + current_view: ViewQuery< + ( + Option<&ViewLightEntities>, + &ExtractedView, + Has, + ), + Without, + >, + camera_views: Query<&ExtractedView, (With, Without)>, view_query: Query< ( &ExtractedView, @@ -630,7 +647,7 @@ pub fn early_gpu_preprocess( let pass_span = diagnostics.pass_span(&mut compute_pass, "early_mesh_preprocessing"); let view_entity = current_view.entity(); - let shadow_cascade_views = current_view.into_inner(); + let (shadow_cascade_views, extracted_view, has_non_root_view) = current_view.into_inner(); let all_views = gather_shadow_cascades_for_view(view_entity, shadow_cascade_views, &light_query); @@ -641,6 +658,23 @@ pub fn early_gpu_preprocess( else { continue; }; + // Set the camera position that will be used for visibility range culling. + let cur_view_world_position = if !has_non_root_view { + extracted_view.world_from_view.translation().to_array() + } else { + // TODO: We need to better handle this case. + // As written, point and spot lights shadows will just use the first user camera + // that is returned by the query. + // If there is only one user camera, this is fine, but for multiple user cameras, + // only one of them is randomly used for visibility range culling. + let camera_view: Option<&ExtractedView> = camera_views.iter().next(); + if let Some(camera_view) = camera_view { + camera_view.world_from_view.translation().to_array() + } else { + // No camera views to render to. + continue; + } + }; let Some(bind_groups) = bind_groups else { continue; @@ -746,10 +780,15 @@ pub fn early_gpu_preprocess( .. } = *work_item_buffers { - compute_pass.set_immediates( - 0, - bytemuck::bytes_of(&late_indirect_parameters_indexed_offset), - ); + let immediates = PreprocessImmediates { + cur_view_world_position, + late_preprocess_work_item_indirect_offset: + late_indirect_parameters_indexed_offset, + }; + compute_pass.set_immediates(0, bytemuck::bytes_of(&immediates)); + } else { + compute_pass + .set_immediates(0, bytemuck::bytes_of(&cur_view_world_position)); } compute_pass.set_bind_group(0, indexed_bind_group, &dynamic_offsets); @@ -770,10 +809,15 @@ pub fn early_gpu_preprocess( .. } = *work_item_buffers { - compute_pass.set_immediates( - 0, - bytemuck::bytes_of(&late_indirect_parameters_non_indexed_offset), - ); + let immediates = PreprocessImmediates { + cur_view_world_position, + late_preprocess_work_item_indirect_offset: + late_indirect_parameters_non_indexed_offset, + }; + compute_pass.set_immediates(0, bytemuck::bytes_of(&immediates)); + } else { + compute_pass + .set_immediates(0, bytemuck::bytes_of(&cur_view_world_position)); } compute_pass.set_bind_group(0, non_indexed_bind_group, &dynamic_offsets); @@ -832,6 +876,7 @@ pub fn late_gpu_preprocess( ) { let (view, bind_groups, view_uniform_offset) = current_view.into_inner(); + let cur_view_world_position = view.world_from_view.translation().to_array(); // Fetch the pipeline BEFORE starting diagnostic spans to avoid panic on early return let maybe_pipeline_id = preprocess_pipelines .late_gpu_occlusion_culling_preprocess @@ -917,10 +962,11 @@ pub fn late_gpu_preprocess( // Transform and cull indexed meshes if there are any. if let Some(late_indexed_bind_group) = maybe_late_indexed_bind_group { - compute_pass.set_immediates( - 0, - bytemuck::bytes_of(late_indirect_parameters_indexed_offset), - ); + let immediates = PreprocessImmediates { + cur_view_world_position, + late_preprocess_work_item_indirect_offset: *late_indirect_parameters_indexed_offset, + }; + compute_pass.set_immediates(0, bytemuck::bytes_of(&immediates)); compute_pass.set_bind_group(0, late_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( @@ -932,10 +978,12 @@ pub fn late_gpu_preprocess( // Transform and cull non-indexed meshes if there are any. if let Some(late_non_indexed_bind_group) = maybe_late_non_indexed_bind_group { - compute_pass.set_immediates( - 0, - bytemuck::bytes_of(late_indirect_parameters_non_indexed_offset), - ); + let immediates = PreprocessImmediates { + cur_view_world_position, + late_preprocess_work_item_indirect_offset: + *late_indirect_parameters_non_indexed_offset, + }; + compute_pass.set_immediates(0, bytemuck::bytes_of(&immediates)); compute_pass.set_bind_group(0, late_non_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( @@ -1247,7 +1295,9 @@ impl SpecializedComputePipeline for PreprocessPipeline { ), layout: vec![self.bind_group_layout.clone()], immediate_size: if key.contains(PreprocessPipelineKey::OCCLUSION_CULLING) { - 4 + 16 + } else if key.contains(PreprocessPipelineKey::FRUSTUM_CULLING) { + 12 } else { 0 }, diff --git a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl index 496cd6b6f84ab..15128ef9fb12b 100644 --- a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl +++ b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl @@ -64,12 +64,18 @@ struct LatePreprocessWorkItemIndirectParameters { pad: vec4, } +#ifdef FRUSTUM_CULLING // These have to be in a structure because of Naga limitations on DX12. struct Immediates { + // The world position of the `CurrentView` + cur_view_world_position: vec3, +#ifdef OCCLUSION_CULLING // The offset into the `late_preprocess_work_item_indirect_parameters` // buffer. late_preprocess_work_item_indirect_offset: u32, +#endif // OCCLUSION_CULLING } +#endif // FRUSTUM_CULLING // The current frame's `MeshInput`. @group(0) @binding(3) var current_input: array; @@ -98,6 +104,8 @@ struct Immediates { @group(0) @binding(9) var mesh_culling_data: array; @group(0) @binding(10) var visibility_ranges: array>; + +var immediates: Immediates; #endif // FRUSTUM_CULLING #ifdef OCCLUSION_CULLING @@ -115,8 +123,6 @@ struct Immediates { @group(0) @binding(13) var late_preprocess_work_item_indirect_parameters: array; #endif // LATE_PHASE - -var immediates: Immediates; #endif // OCCLUSION_CULLING #ifdef FRUSTUM_CULLING @@ -224,7 +230,7 @@ fn main(@builtin(global_invocation_id) global_invocation_id: vec3) { world_pos = world_from_local[3].xyz; } - let camera_distance = length(position_world_to_view(world_pos)); + let camera_distance = length(immediates.cur_view_world_position - world_pos); // `x` is the minimum range; `w` is the largest range. if (camera_distance < lod_range.x || camera_distance >= lod_range.w) { return; From 37fac2eb6b31177b98b7966196c419f60647d7e5 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 10:33:12 -0400 Subject: [PATCH 02/11] remove changes to late gpu preprocess - it only does occlusion culling? --- crates/bevy_pbr/src/render/gpu_preprocess.rs | 20 ++++++++----------- .../bevy_pbr/src/render/mesh_preprocess.wgsl | 6 ++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/bevy_pbr/src/render/gpu_preprocess.rs b/crates/bevy_pbr/src/render/gpu_preprocess.rs index 9a5b89a0ff4c2..46099ad4a653b 100644 --- a/crates/bevy_pbr/src/render/gpu_preprocess.rs +++ b/crates/bevy_pbr/src/render/gpu_preprocess.rs @@ -876,7 +876,6 @@ pub fn late_gpu_preprocess( ) { let (view, bind_groups, view_uniform_offset) = current_view.into_inner(); - let cur_view_world_position = view.world_from_view.translation().to_array(); // Fetch the pipeline BEFORE starting diagnostic spans to avoid panic on early return let maybe_pipeline_id = preprocess_pipelines .late_gpu_occlusion_culling_preprocess @@ -962,11 +961,10 @@ pub fn late_gpu_preprocess( // Transform and cull indexed meshes if there are any. if let Some(late_indexed_bind_group) = maybe_late_indexed_bind_group { - let immediates = PreprocessImmediates { - cur_view_world_position, - late_preprocess_work_item_indirect_offset: *late_indirect_parameters_indexed_offset, - }; - compute_pass.set_immediates(0, bytemuck::bytes_of(&immediates)); + compute_pass.set_immediates( + 0, + bytemuck::bytes_of(late_indirect_parameters_indexed_offset), + ); compute_pass.set_bind_group(0, late_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( @@ -978,12 +976,10 @@ pub fn late_gpu_preprocess( // Transform and cull non-indexed meshes if there are any. if let Some(late_non_indexed_bind_group) = maybe_late_non_indexed_bind_group { - let immediates = PreprocessImmediates { - cur_view_world_position, - late_preprocess_work_item_indirect_offset: - *late_indirect_parameters_non_indexed_offset, - }; - compute_pass.set_immediates(0, bytemuck::bytes_of(&immediates)); + compute_pass.set_immediates( + 0, + bytemuck::bytes_of(late_indirect_parameters_non_indexed_offset), + ); compute_pass.set_bind_group(0, late_non_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( diff --git a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl index 15128ef9fb12b..41cbb3315e7be 100644 --- a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl +++ b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl @@ -75,6 +75,12 @@ struct Immediates { late_preprocess_work_item_indirect_offset: u32, #endif // OCCLUSION_CULLING } +#else // FRUSTUM_CULLING +struct Immediates { + // The offset into the `late_preprocess_work_item_indirect_parameters` + // buffer. + late_preprocess_work_item_indirect_offset: u32, +} #endif // FRUSTUM_CULLING // The current frame's `MeshInput`. From f3b3681cdc54a59c82b47c1a80120e1dd66cd1f4 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 10:35:07 -0400 Subject: [PATCH 03/11] move frustum culling out of PreprocessOnly --- crates/bevy_pbr/src/render/gpu_preprocess.rs | 13 ++----------- .../bevy_render/src/batching/gpu_preprocessing.rs | 1 + 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/crates/bevy_pbr/src/render/gpu_preprocess.rs b/crates/bevy_pbr/src/render/gpu_preprocess.rs index 46099ad4a653b..be3ce1c0dacf5 100644 --- a/crates/bevy_pbr/src/render/gpu_preprocess.rs +++ b/crates/bevy_pbr/src/render/gpu_preprocess.rs @@ -961,10 +961,7 @@ pub fn late_gpu_preprocess( // Transform and cull indexed meshes if there are any. if let Some(late_indexed_bind_group) = maybe_late_indexed_bind_group { - compute_pass.set_immediates( - 0, - bytemuck::bytes_of(late_indirect_parameters_indexed_offset), - ); + compute_pass.set_immediates(0, bytemuck::bytes_of(late_indirect_parameters_indexed_offset)); compute_pass.set_bind_group(0, late_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( @@ -976,10 +973,7 @@ pub fn late_gpu_preprocess( // Transform and cull non-indexed meshes if there are any. if let Some(late_non_indexed_bind_group) = maybe_late_non_indexed_bind_group { - compute_pass.set_immediates( - 0, - bytemuck::bytes_of(late_indirect_parameters_non_indexed_offset), - ); + compute_pass.set_immediates(0, bytemuck::bytes_of(late_indirect_parameters_non_indexed_offset)); compute_pass.set_bind_group(0, late_non_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( @@ -1188,9 +1182,6 @@ impl PreprocessPipelines { GpuPreprocessingMode::None => false, GpuPreprocessingMode::PreprocessingOnly => { self.direct_preprocess.is_loaded(pipeline_cache) - && self - .gpu_frustum_culling_preprocess - .is_loaded(pipeline_cache) } GpuPreprocessingMode::Culling => { self.direct_preprocess.is_loaded(pipeline_cache) diff --git a/crates/bevy_render/src/batching/gpu_preprocessing.rs b/crates/bevy_render/src/batching/gpu_preprocessing.rs index 95e0b3d804efc..def810196c1f8 100644 --- a/crates/bevy_render/src/batching/gpu_preprocessing.rs +++ b/crates/bevy_render/src/batching/gpu_preprocessing.rs @@ -1337,6 +1337,7 @@ impl FromWorld for GpuPreprocessingSupport { crate::get_pixel10_driver_version(adapter_info).is_some() } + // Includes occlusion culling and frustum culling let culling_feature_support = device .features() .contains(Features::INDIRECT_FIRST_INSTANCE | Features::IMMEDIATES); From 147ba07be47844658d36b15df05bb1bf3f20cb31 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 10:46:40 -0400 Subject: [PATCH 04/11] fmt --- crates/bevy_pbr/src/render/gpu_preprocess.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/bevy_pbr/src/render/gpu_preprocess.rs b/crates/bevy_pbr/src/render/gpu_preprocess.rs index be3ce1c0dacf5..f6c05c8782443 100644 --- a/crates/bevy_pbr/src/render/gpu_preprocess.rs +++ b/crates/bevy_pbr/src/render/gpu_preprocess.rs @@ -961,7 +961,10 @@ pub fn late_gpu_preprocess( // Transform and cull indexed meshes if there are any. if let Some(late_indexed_bind_group) = maybe_late_indexed_bind_group { - compute_pass.set_immediates(0, bytemuck::bytes_of(late_indirect_parameters_indexed_offset)); + compute_pass.set_immediates( + 0, + bytemuck::bytes_of(late_indirect_parameters_indexed_offset), + ); compute_pass.set_bind_group(0, late_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( @@ -973,7 +976,10 @@ pub fn late_gpu_preprocess( // Transform and cull non-indexed meshes if there are any. if let Some(late_non_indexed_bind_group) = maybe_late_non_indexed_bind_group { - compute_pass.set_immediates(0, bytemuck::bytes_of(late_indirect_parameters_non_indexed_offset)); + compute_pass.set_immediates( + 0, + bytemuck::bytes_of(late_indirect_parameters_non_indexed_offset), + ); compute_pass.set_bind_group(0, late_non_indexed_bind_group, &dynamic_offsets); compute_pass.dispatch_workgroups_indirect( From 7f7d03178ab7f9aaf984bec63bbca3612d1a4c50 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 19:39:07 -0400 Subject: [PATCH 05/11] docs: state reason behind logic for directional shadow cascades --- crates/bevy_pbr/src/render/gpu_preprocess.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/bevy_pbr/src/render/gpu_preprocess.rs b/crates/bevy_pbr/src/render/gpu_preprocess.rs index f6c05c8782443..1eab47e650ed4 100644 --- a/crates/bevy_pbr/src/render/gpu_preprocess.rs +++ b/crates/bevy_pbr/src/render/gpu_preprocess.rs @@ -660,10 +660,16 @@ pub fn early_gpu_preprocess( }; // Set the camera position that will be used for visibility range culling. let cur_view_world_position = if !has_non_root_view { + // Directional light shadows are made via cascaded shadow maps. + // These cascades are unique to each camera view (see RetainedViewEntity). + // For directional light shadows, the world position of the associated camera + // should be used for visibility range culling, not the world position of the shadow camera. + // The `CurrentView`'s `ExtractedView` contains the associated camera's world position. extracted_view.world_from_view.translation().to_array() } else { + // PointLight and SpotLight shadow views are handled in this else block. // TODO: We need to better handle this case. - // As written, point and spot lights shadows will just use the first user camera + // As written, point and spot lights shadow views will just use the first user camera // that is returned by the query. // If there is only one user camera, this is fine, but for multiple user cameras, // only one of them is randomly used for visibility range culling. From 2f8e21a0bb4ea836576a822adb18c010312a7971 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 19:42:50 -0400 Subject: [PATCH 06/11] Add back code that I thought I had to remove heh --- crates/bevy_pbr/src/render/gpu_preprocess.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/bevy_pbr/src/render/gpu_preprocess.rs b/crates/bevy_pbr/src/render/gpu_preprocess.rs index 1eab47e650ed4..483691b517687 100644 --- a/crates/bevy_pbr/src/render/gpu_preprocess.rs +++ b/crates/bevy_pbr/src/render/gpu_preprocess.rs @@ -1194,6 +1194,9 @@ impl PreprocessPipelines { GpuPreprocessingMode::None => false, GpuPreprocessingMode::PreprocessingOnly => { self.direct_preprocess.is_loaded(pipeline_cache) + && self + .gpu_frustum_culling_preprocess + .is_loaded(pipeline_cache) } GpuPreprocessingMode::Culling => { self.direct_preprocess.is_loaded(pipeline_cache) From 31dc4561308dcb42d4f0468ce8abafe86a25062d Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 20:02:54 -0400 Subject: [PATCH 07/11] PreprocessingOnly requires Immediates --- crates/bevy_render/src/batching/gpu_preprocessing.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/bevy_render/src/batching/gpu_preprocessing.rs b/crates/bevy_render/src/batching/gpu_preprocessing.rs index def810196c1f8..cfe1b398963e7 100644 --- a/crates/bevy_render/src/batching/gpu_preprocessing.rs +++ b/crates/bevy_render/src/batching/gpu_preprocessing.rs @@ -1337,7 +1337,7 @@ impl FromWorld for GpuPreprocessingSupport { crate::get_pixel10_driver_version(adapter_info).is_some() } - // Includes occlusion culling and frustum culling + // Includes occlusion culling let culling_feature_support = device .features() .contains(Features::INDIRECT_FIRST_INSTANCE | Features::IMMEDIATES); @@ -1361,6 +1361,7 @@ impl FromWorld for GpuPreprocessingSupport { let max_supported_mode = if device.limits().max_compute_workgroup_size_x == 0 || is_non_supported_android_device(&adapter_info) || adapter_info.backend == wgpu::Backend::Gl + || !device.features().contains(Features::IMMEDIATES) { info_once!( "GPU preprocessing is not supported on this device. \ From edc1df0e773ebe3a1108614983e703b0e56cf889 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 20:08:25 -0400 Subject: [PATCH 08/11] add ifndef just in case... --- crates/bevy_pbr/src/render/mesh_preprocess.wgsl | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl index 41cbb3315e7be..900908ffdba3a 100644 --- a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl +++ b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl @@ -73,15 +73,15 @@ struct Immediates { // The offset into the `late_preprocess_work_item_indirect_parameters` // buffer. late_preprocess_work_item_indirect_offset: u32, -#endif // OCCLUSION_CULLING +#endif // OCCLUSION_CULLING } -#else // FRUSTUM_CULLING +#else // FRUSTUM_CULLING struct Immediates { // The offset into the `late_preprocess_work_item_indirect_parameters` // buffer. late_preprocess_work_item_indirect_offset: u32, } -#endif // FRUSTUM_CULLING +#endif // FRUSTUM_CULLING // The current frame's `MeshInput`. @group(0) @binding(3) var current_input: array; @@ -129,6 +129,10 @@ var immediates: Immediates; @group(0) @binding(13) var late_preprocess_work_item_indirect_parameters: array; #endif // LATE_PHASE + +#ifndef FRUSTUM_CULLING +var immediates: Immediates; +#endif // FRUSTUM_CULLING #endif // OCCLUSION_CULLING #ifdef FRUSTUM_CULLING From c6f162bdd4b0f31a7d06839cc928d80d3b02f050 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 21:30:54 -0400 Subject: [PATCH 09/11] remove unnecessary comment --- crates/bevy_render/src/batching/gpu_preprocessing.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/bevy_render/src/batching/gpu_preprocessing.rs b/crates/bevy_render/src/batching/gpu_preprocessing.rs index cfe1b398963e7..c5eb0ecf98b30 100644 --- a/crates/bevy_render/src/batching/gpu_preprocessing.rs +++ b/crates/bevy_render/src/batching/gpu_preprocessing.rs @@ -1337,7 +1337,6 @@ impl FromWorld for GpuPreprocessingSupport { crate::get_pixel10_driver_version(adapter_info).is_some() } - // Includes occlusion culling let culling_feature_support = device .features() .contains(Features::INDIRECT_FIRST_INSTANCE | Features::IMMEDIATES); From f1fc8513fe6fd016f0987b71dfedeea38fc7a9be Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Fri, 8 May 2026 21:35:09 -0400 Subject: [PATCH 10/11] more comments in wgsl --- crates/bevy_pbr/src/render/mesh_preprocess.wgsl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl index 900908ffdba3a..f03f89095e85c 100644 --- a/crates/bevy_pbr/src/render/mesh_preprocess.wgsl +++ b/crates/bevy_pbr/src/render/mesh_preprocess.wgsl @@ -67,7 +67,10 @@ struct LatePreprocessWorkItemIndirectParameters { #ifdef FRUSTUM_CULLING // These have to be in a structure because of Naga limitations on DX12. struct Immediates { - // The world position of the `CurrentView` + // The world position of a camera view. + // This is important to distinguish when preprocessing shadow views, + // which is bound to `view`. Visibility range culling must use a camera view's + // position in order to function properly. cur_view_world_position: vec3, #ifdef OCCLUSION_CULLING // The offset into the `late_preprocess_work_item_indirect_parameters` From ac87f8e0123a879198f8d5cf8f473724ec866465 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Sat, 9 May 2026 16:59:32 -0400 Subject: [PATCH 11/11] Add marker component for point and spot light shadow primary camera --- crates/bevy_pbr/src/render/gpu_preprocess.rs | 27 +++++++++++-------- crates/bevy_render/src/camera.rs | 14 ++++++++-- .../bevy_render/src/view/visibility/range.rs | 18 +++++++++++++ examples/testbed/3d.rs | 7 ++++- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/crates/bevy_pbr/src/render/gpu_preprocess.rs b/crates/bevy_pbr/src/render/gpu_preprocess.rs index 483691b517687..3eff624d1454f 100644 --- a/crates/bevy_pbr/src/render/gpu_preprocess.rs +++ b/crates/bevy_pbr/src/render/gpu_preprocess.rs @@ -62,8 +62,8 @@ use bevy_render::{ renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery}, settings::WgpuFeatures, view::{ - ExtractedView, NoIndirectDrawing, RenderVisibilityRanges, RetainedViewEntity, ViewUniform, - ViewUniformOffset, ViewUniforms, + ExtractedView, NoIndirectDrawing, PointAndSpotLightShadowPrimaryCamera, + RenderVisibilityRanges, RetainedViewEntity, ViewUniform, ViewUniformOffset, ViewUniforms, }, GpuResourceAppExt, Render, RenderApp, RenderSystems, }; @@ -617,7 +617,13 @@ pub fn early_gpu_preprocess( ), Without, >, - camera_views: Query<&ExtractedView, (With, Without)>, + point_and_spot_light_shadow_primary_camera: Query< + &ExtractedView, + ( + With, + With, + ), + >, view_query: Query< ( &ExtractedView, @@ -650,6 +656,8 @@ pub fn early_gpu_preprocess( let (shadow_cascade_views, extracted_view, has_non_root_view) = current_view.into_inner(); let all_views = gather_shadow_cascades_for_view(view_entity, shadow_cascade_views, &light_query); + let point_and_spot_light_shadow_camera_view = + point_and_spot_light_shadow_primary_camera.single(); // Run the compute passes. for view_entity in all_views { @@ -668,16 +676,13 @@ pub fn early_gpu_preprocess( extracted_view.world_from_view.translation().to_array() } else { // PointLight and SpotLight shadow views are handled in this else block. - // TODO: We need to better handle this case. - // As written, point and spot lights shadow views will just use the first user camera - // that is returned by the query. - // If there is only one user camera, this is fine, but for multiple user cameras, - // only one of them is randomly used for visibility range culling. - let camera_view: Option<&ExtractedView> = camera_views.iter().next(); - if let Some(camera_view) = camera_view { + // We could handle this case better if we can specify certain point and spot light shadow + // views to different cameras. Right now, it is all centralized to one user specified camera. + if let Ok(camera_view) = point_and_spot_light_shadow_camera_view { camera_view.world_from_view.translation().to_array() } else { - // No camera views to render to. + // There is no single designated primary camera to use for vis range culling of the shadows. + // Do not render them. continue; } }; diff --git a/crates/bevy_render/src/camera.rs b/crates/bevy_render/src/camera.rs index 047187e2a9c2a..53203efc8ffd4 100644 --- a/crates/bevy_render/src/camera.rs +++ b/crates/bevy_render/src/camera.rs @@ -11,8 +11,9 @@ use crate::{ texture::{GpuImage, ManualTextureViews}, view::{ ColorGrading, ExtractedView, ExtractedWindows, Msaa, NoIndirectDrawing, - RenderExtractedVisibleEntities, RenderVisibleEntities, RenderVisibleEntitiesClass, - RetainedViewEntity, ViewUniformOffset, VisibilityExtractionSystemParam, + PointAndSpotLightShadowPrimaryCamera, RenderExtractedVisibleEntities, + RenderVisibleEntities, RenderVisibleEntitiesClass, RetainedViewEntity, ViewUniformOffset, + VisibilityExtractionSystemParam, }, Extract, ExtractSchedule, Render, RenderApp, RenderSystems, }; @@ -492,6 +493,7 @@ pub fn extract_cameras( Option<&MipBias>, Option<&RenderLayers>, Option<&Projection>, + Has, Has, ), )>, @@ -517,6 +519,7 @@ pub fn extract_cameras( MipBias, RenderLayers, Projection, + PointAndSpotLightShadowPrimaryCamera, NoIndirectDrawing, ViewUniformOffset, ); @@ -538,6 +541,7 @@ pub fn extract_cameras( mip_bias, render_layers, projection, + has_pasl_shadow_primary_camera, no_indirect_drawing, ), ) in query.iter() @@ -682,6 +686,12 @@ pub fn extract_cameras( commands.remove::(); } + if has_pasl_shadow_primary_camera { + commands.insert(PointAndSpotLightShadowPrimaryCamera); + } else { + commands.remove::(); + } + if no_indirect_drawing || !matches!( gpu_preprocessing_support.max_supported_mode, diff --git a/crates/bevy_render/src/view/visibility/range.rs b/crates/bevy_render/src/view/visibility/range.rs index f657ecd344741..7e3a5d11cf99c 100644 --- a/crates/bevy_render/src/view/visibility/range.rs +++ b/crates/bevy_render/src/view/visibility/range.rs @@ -4,6 +4,7 @@ use super::VisibilityRange; use bevy_app::{App, Plugin}; use bevy_ecs::{ + component::Component, entity::Entity, lifecycle::RemovedComponents, query::Changed, @@ -57,6 +58,23 @@ impl Plugin for RenderVisibilityRangePlugin { } } +/// A marker component for a `Camera` to denote that this entity should be the primary camera +/// used for GPU Visibility Range Culling on shadows produced by all `PointLight`s and +/// `SpotLight`s. There should only be one `Camera` with this marker component +/// at a given moment in the application. This culling will occur if `GpuPreprocessingMode` +/// is at least `GpuPreprocessingMode::PreprocessingOnly`. +/// +/// Unlike `DirectionalLight` shadows, `PointLight` shadows and `SpotLight` shadows are +/// not associated with any camera. They are only rendered once, regardless of the +/// number of cameras. In order for these shadows to participate in visibility range culling, +/// the user camera that distances are calculated relative to must be specified. Use this component +/// to specify that camera. +/// +/// If this marker component is not placed on a camera, or if there are multiple cameras +/// with this component, shadows made by `PointLight`s and `SpotLight`s may not render. +#[derive(Component, Default)] +pub struct PointAndSpotLightShadowPrimaryCamera; + /// Stores information related to [`VisibilityRange`]s in the render world. #[derive(Resource)] pub struct RenderVisibilityRanges { diff --git a/examples/testbed/3d.rs b/examples/testbed/3d.rs index c4553dbb09c89..bc0ae99808601 100644 --- a/examples/testbed/3d.rs +++ b/examples/testbed/3d.rs @@ -122,6 +122,7 @@ mod light { use bevy::{ color::palettes::css::{DEEP_PINK, LIME, RED}, prelude::*, + render::view::PointAndSpotLightShadowPrimaryCamera, }; const CURRENT_SCENE: super::Scene = super::Scene::Light; @@ -203,6 +204,7 @@ mod light { commands.spawn(( Camera3d::default(), + PointAndSpotLightShadowPrimaryCamera, Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y), DespawnOnExit(CURRENT_SCENE), )); @@ -732,6 +734,7 @@ mod render_layers { use bevy::{ camera::{visibility::RenderLayers, Viewport}, prelude::*, + render::view::PointAndSpotLightShadowPrimaryCamera, window::PrimaryWindow, }; @@ -805,7 +808,9 @@ mod render_layers { DespawnOnExit(CURRENT_SCENE), )); match index { - 0 => {} + 0 => { + entity_cmds.insert(PointAndSpotLightShadowPrimaryCamera); + } 1 => { entity_cmds.insert(RenderLayers::layer(1)); }