Skip to content
Closed
87 changes: 72 additions & 15 deletions crates/bevy_pbr/src/render/gpu_preprocess.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -45,6 +45,7 @@ use bevy_render::{
PreprocessWorkItemBuffers, UntypedPhaseBatchedInstanceBuffers,
UntypedPhaseIndirectParametersBuffers,
},
camera::ExtractedCamera,
diagnostic::RecordDiagnostics as _,
occlusion_culling::OcclusionCulling,
render_phase::GpuRenderBinnedMeshInstance,
Expand All @@ -61,14 +62,15 @@ 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,
};
use bevy_shader::Shader;
use bevy_utils::{default, TypeIdMap};
use bitflags::bitflags;
use bytemuck::{Pod, Zeroable};
use smallvec::{smallvec, SmallVec};
use tracing::warn;

Expand Down Expand Up @@ -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
Expand All @@ -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.
///
Expand Down Expand Up @@ -600,7 +609,21 @@ pub fn unpack_bins(
}

pub fn early_gpu_preprocess(
current_view: ViewQuery<Option<&ViewLightEntities>, Without<SkipGpuPreprocess>>,
current_view: ViewQuery<
(
Option<&ViewLightEntities>,
&ExtractedView,
Has<RootNonCameraView>,
),
Without<SkipGpuPreprocess>,
>,
point_and_spot_light_shadow_primary_camera: Query<
&ExtractedView,
(
With<ExtractedCamera>,
With<PointAndSpotLightShadowPrimaryCamera>,
),
>,
view_query: Query<
(
&ExtractedView,
Expand Down Expand Up @@ -630,9 +653,11 @@ 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);
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 {
Expand All @@ -641,6 +666,26 @@ 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 {
// 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()
Comment thread
kfc35 marked this conversation as resolved.
} else {
// PointLight and SpotLight shadow views are handled in this else block.
// 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 {
// There is no single designated primary camera to use for vis range culling of the shadows.
// Do not render them.
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this continue skip the entire preprocess dispatch for the view?

@kfc35 kfc35 May 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the PointLight/SpotLight shadow view, yes

But I would consider that a user configuration error at this point, so the shadows just wouldn’t be rendered.

(However we do not need to continue this conversation because I will close this PR! It seems the consensus is converging on reverting the offending PR)

}
};

let Some(bind_groups) = bind_groups else {
continue;
Expand Down Expand Up @@ -746,10 +791,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);
Expand All @@ -770,10 +820,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);
Expand Down Expand Up @@ -1247,7 +1302,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
},
Expand Down
21 changes: 20 additions & 1 deletion crates/bevy_pbr/src/render/mesh_preprocess.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,27 @@ struct LatePreprocessWorkItemIndirectParameters {
pad: vec4<u32>,
}

#ifdef FRUSTUM_CULLING
// These have to be in a structure because of Naga limitations on DX12.
struct Immediates {
// 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<f32>,
#ifdef OCCLUSION_CULLING
// The offset into the `late_preprocess_work_item_indirect_parameters`
// buffer.
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`.
@group(0) @binding(3) var<storage> current_input: array<MeshInput>;
Expand Down Expand Up @@ -98,6 +113,8 @@ struct Immediates {
@group(0) @binding(9) var<storage> mesh_culling_data: array<MeshCullingData>;

@group(0) @binding(10) var<storage> visibility_ranges: array<vec4<f32>>;

var<immediate> immediates: Immediates;
#endif // FRUSTUM_CULLING

#ifdef OCCLUSION_CULLING
Expand All @@ -116,7 +133,9 @@ struct Immediates {
array<LatePreprocessWorkItemIndirectParameters>;
#endif // LATE_PHASE

#ifndef FRUSTUM_CULLING
var<immediate> immediates: Immediates;
#endif // FRUSTUM_CULLING
#endif // OCCLUSION_CULLING

#ifdef FRUSTUM_CULLING
Expand Down Expand Up @@ -224,7 +243,7 @@ fn main(@builtin(global_invocation_id) global_invocation_id: vec3<u32>) {
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;
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_render/src/batching/gpu_preprocessing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,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. \
Expand Down
14 changes: 12 additions & 2 deletions crates/bevy_render/src/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -492,6 +493,7 @@ pub fn extract_cameras(
Option<&MipBias>,
Option<&RenderLayers>,
Option<&Projection>,
Has<PointAndSpotLightShadowPrimaryCamera>,
Has<NoIndirectDrawing>,
),
)>,
Expand All @@ -517,6 +519,7 @@ pub fn extract_cameras(
MipBias,
RenderLayers,
Projection,
PointAndSpotLightShadowPrimaryCamera,
NoIndirectDrawing,
ViewUniformOffset,
);
Expand All @@ -538,6 +541,7 @@ pub fn extract_cameras(
mip_bias,
render_layers,
projection,
has_pasl_shadow_primary_camera,
no_indirect_drawing,
),
) in query.iter()
Expand Down Expand Up @@ -682,6 +686,12 @@ pub fn extract_cameras(
commands.remove::<Projection>();
}

if has_pasl_shadow_primary_camera {
commands.insert(PointAndSpotLightShadowPrimaryCamera);
} else {
commands.remove::<PointAndSpotLightShadowPrimaryCamera>();
}

if no_indirect_drawing
|| !matches!(
gpu_preprocessing_support.max_supported_mode,
Expand Down
18 changes: 18 additions & 0 deletions crates/bevy_render/src/view/visibility/range.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use super::VisibilityRange;
use bevy_app::{App, Plugin};
use bevy_ecs::{
component::Component,
entity::Entity,
lifecycle::RemovedComponents,
query::Changed,
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion examples/testbed/3d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
));
Expand Down Expand Up @@ -732,6 +734,7 @@ mod render_layers {
use bevy::{
camera::{visibility::RenderLayers, Viewport},
prelude::*,
render::view::PointAndSpotLightShadowPrimaryCamera,
window::PrimaryWindow,
};

Expand Down Expand Up @@ -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));
}
Expand Down
Loading