Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions crates/bevy_camera/src/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,47 @@ impl Default for CameraOutputMode {
}
}

/// The camera that Bevy uses to resolve visibility ranges when no specific
/// camera is applicable.
///
/// For efficiency, Bevy currently renders point and spot light shadow maps only
/// once per frame, regardless of the number of cameras in use, rather than
/// rendering the shadow maps anew for each camera each frame. Most of the time,
/// this optimization doesn't change the result relative to a rendering that
/// rendered such shadow maps separately for each camera. However, there's one
/// exception: visibility ranges. Visibility ranges cause meshes to be visible
/// or invisible depending on the distance from the mesh to the camera. When
/// rendering a shadow map for a point or spot light, Bevy must therefore select
/// a camera as the reference camera for the purposes of visibility ranges. This
/// camera is known as the *primary camera*.
///
/// Placing this component on a [`Camera`] makes that camera the primary one for
/// the purposes of shadow mapping of point and spot lights.
///
/// The exact algorithm that Bevy uses to determine a primary camera is as

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I really like this fallback strategy: good defaults with manual control.

/// follows. Once a camera is chosen as the primary camera, all further steps
/// are skipped.
///
/// 1. If a camera has this [`PrimaryCamera`] component, then it's the primary
/// camera. If there's more than one camera with the [`PrimaryCamera`]
/// component, one is chosen arbitrarily.
///
/// 2. If a camera renders to a window (that is, the camera's [`RenderTarget`]
/// is [`RenderTarget::Window`]), then that camera is the primary one. If
/// there's more than one camera that renders to a window, then one is chosen
/// arbitrarily.
///
/// 3. A primary camera is chosen arbitrarily from all cameras in the scene.
///
/// This algorithm means that, in most cases, you don't need to add this
/// [`PrimaryCamera`] component explicitly to the scene; usually, Bevy chooses
/// the right camera automatically. You only need to use this component
/// explicitly if you have multiple cameras rendering to the window: e.g. a in a
/// split-screen game.
#[derive(Clone, Copy, Component, Default, Debug, Reflect)]
#[reflect(Clone, Component, Default)]
pub struct PrimaryCamera;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm nervous about the introduction of this. My understanding is that per-camera shadow maps is likely to always be too expensive.

This design feels fundamentally wrong for split-screen games, even though it works for many others.

Could we change the semantics here to allow for tagging multiple cameras, and then taking the union of shadows that should be seen by any of these cameras? We'd need to rename this component obviously. We can even bound the max number of cameras used for this comfortably: 4 or 8 is plenty.

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.

That won't work for GPU driven because you would have a sequential loop over an array for every mesh in the shader. The right solution is has_own_point_and_spot_light_shadow_maps, which is something I'll implement after 0.19.

@pcwalton pcwalton May 14, 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.

In other words, per-camera shadow maps aren't necessarily too expensive. It really depends on the scene. There's a general feeling that they're too expensive by default, but we should support them at least on an opt-in basis. cart and Griffin disagree as to whether they should be enabled by default; I'm neutral on that question and simply think we should support both.


/// The "target" that a [`Camera`] will render to. For example, this could be a `Window`
/// swapchain or an [`Image`].
#[derive(Component, Debug, Clone, Reflect, From)]
Expand Down
2 changes: 1 addition & 1 deletion crates/bevy_pbr/src/render/mesh_functions.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ fn get_visibility_range_dither_level(instance_index: u32, world_position: vec4<f
}

let lod_range = visibility_ranges[visibility_buffer_index];
let camera_distance = length(view.world_position.xyz - world_position.xyz);
let camera_distance = length(view.lod_view_world_position.xyz - world_position.xyz);

// This encodes the following mapping:
//
Expand Down
45 changes: 41 additions & 4 deletions crates/bevy_render/src/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ use crate::{
sync_world::{MainEntity, MainEntityHashSet, RenderEntity, SyncToRenderWorld},
texture::{GpuImage, ManualTextureViews},
view::{
ColorGrading, ExtractedView, ExtractedWindows, Msaa, NoIndirectDrawing,
RenderExtractedVisibleEntities, RenderVisibleEntities, RenderVisibleEntitiesClass,
RetainedViewEntity, ViewUniformOffset, VisibilityExtractionSystemParam,
ColorGrading, ExtractedPrimaryCamera, ExtractedView, ExtractedWindows, Msaa,
NoIndirectDrawing, RenderExtractedVisibleEntities, RenderVisibleEntities,
RenderVisibleEntitiesClass, RetainedViewEntity, ViewUniformOffset,
VisibilityExtractionSystemParam,
},
Extract, ExtractSchedule, Render, RenderApp, RenderSystems,
};
Expand All @@ -24,7 +25,8 @@ use bevy_camera::{
visibility::{self, RenderLayers, VisibleEntities},
Camera, Camera2d, Camera3d, CameraMainTextureUsages, CameraOutputMode, CameraUpdateSystems,
ClearColor, ClearColorConfig, CompositingSpace, Exposure, Hdr, ManualTextureViewHandle,
MsaaWriteback, NormalizedRenderTarget, Projection, RenderTarget, RenderTargetInfo, Viewport,
MsaaWriteback, NormalizedRenderTarget, PrimaryCamera, Projection, RenderTarget,
RenderTargetInfo, Viewport,
};
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{
Expand Down Expand Up @@ -497,6 +499,7 @@ pub fn extract_cameras(
)>,
>,
primary_window: Extract<Query<Entity, With<PrimaryWindow>>>,
primary_camera: Extract<Query<Entity, With<PrimaryCamera>>>,
extracted_windows: Res<ExtractedWindows>,
manual_texture_views: Res<ManualTextureViews>,
images: Res<RenderAssets<GpuImage>>,
Expand All @@ -520,6 +523,34 @@ pub fn extract_cameras(
NoIndirectDrawing,
ViewUniformOffset,
);

// Determine the primary camera. The selection priority is, from highest to
// lowest:
//
// 1. A camera explicitly marked with the `PrimaryCamera` component.
//
// 2. A camera that renders to a window.
//
// 3. Any camera.
let mut primary_camera = primary_camera.iter().next();
if primary_camera.is_none() {
primary_camera =
query
.iter()
.find_map(
|(main_entity, _, _, render_target, _, _, _, _, _)| match *render_target {
RenderTarget::Window(_) => Some(main_entity),
_ => None,
},
);
}
if primary_camera.is_none() {
primary_camera = query
.iter()
.map(|(main_entity, _, _, _, _, _, _, _, _)| main_entity)
.next();
}

for (
main_entity,
render_entity,
Expand Down Expand Up @@ -682,6 +713,12 @@ pub fn extract_cameras(
commands.remove::<Projection>();
}

if primary_camera == Some(main_entity) {
commands.insert(ExtractedPrimaryCamera);
} else {
commands.remove::<ExtractedPrimaryCamera>();
}

if no_indirect_drawing
|| !matches!(
gpu_preprocessing_support.max_supported_mode,
Expand Down
53 changes: 53 additions & 0 deletions crates/bevy_render/src/view/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,16 @@ impl ExtractedView {
}
}

/// The render-world component that marks the primary camera.
///
/// See [`bevy_camera::camera::PrimaryCamera`] for more information on primary
/// cameras.
///
/// If any cameras are present in the render world, exactly one will have this
/// component.
#[derive(Clone, Copy, Component, Debug)]
pub struct ExtractedPrimaryCamera;

/// Configures filmic color grading parameters to adjust the image appearance.
///
/// Color grading is applied just before tonemapping for a given
Expand Down Expand Up @@ -651,6 +661,13 @@ pub struct ViewUniform {
/// The normal vectors point towards the interior of the frustum.
/// A half space contains `p` if `normal.dot(p) + distance > 0.`
pub frustum: [Vec4; 6],
/// The world-space position of the camera used to resolve visibility
/// ranges.
///
/// This is the position of the camera itself, unless this view isn't
/// associated with a camera, in which case it's the position of the primary
/// camera.
pub lod_view_world_position: Vec3,
pub color_grading: ColorGradingUniform,
pub mip_bias: f32,
pub frame_count: u32,
Expand Down Expand Up @@ -997,6 +1014,7 @@ pub fn prepare_view_uniforms(
Option<&MipBias>,
Option<&MainPassResolutionOverride>,
)>,
primary_view: Query<&ExtractedView, With<ExtractedPrimaryCamera>>,
frame_count: Res<FrameCount>,
) {
let view_iter = views.iter();
Expand All @@ -1008,6 +1026,9 @@ pub fn prepare_view_uniforms(
else {
return;
};
let Some(primary_view) = primary_view.iter().next() else {
return;
};
for (
entity,
extracted_camera,
Expand Down Expand Up @@ -1049,6 +1070,37 @@ pub fn prepare_view_uniforms(
.map(|frustum| frustum.half_spaces.map(|h| h.normal_d()))
.unwrap_or([Vec4::ZERO; 6]);

// Determine the position of the camera used for resolving visibility
// ranges (LODs).
let lod_view_world_position = if extracted_camera.is_some() {
// If we're rendering a camera directly (i.e. we're not rendering a
// shadow map), we use this camera's position as the LOD view
// position.
extracted_view.world_from_view.translation()
} else if extracted_view.retained_view_entity.auxiliary_entity
== MainEntity::from(Entity::PLACEHOLDER)
{
// If we're rendering a shadow map that isn't associated with a
// camera, we use the position of the primary camera as the LOD view
// position.
primary_view.world_from_view.translation()
} else {
// Otherwise, if we're rendering a shadow map that is associated
// with a camera (i.e. a directional light shadow map, at present),
// we use the position of that camera as the LOD view position. This
// ensures that each rendered object has a shadow and that no
// invisible objects have shadows.
match views.get(
extracted_view
.retained_view_entity
.auxiliary_entity
.entity(),
) {
Ok((_, _, camera_view, _, _, _, _)) => camera_view.world_from_view.translation(),
Err(_) => primary_view.world_from_view.translation(),
}
};

let view_uniforms = ViewUniformOffset {
offset: writer.write(&ViewUniform {
clip_from_world,
Expand All @@ -1065,6 +1117,7 @@ pub fn prepare_view_uniforms(
viewport,
main_pass_viewport,
frustum,
lod_view_world_position,
color_grading: extracted_view.color_grading.clone().into(),
mip_bias: mip_bias.unwrap_or(&MipBias(0.0)).0,
frame_count: frame_count.0,
Expand Down
6 changes: 6 additions & 0 deletions crates/bevy_render/src/view/view.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ struct View {
// The normal vectors point towards the interior of the frustum.
// A half space contains `p` if `normal.dot(p) + distance > 0.`
frustum: array<vec4<f32>, 6>,
// The world-space position of the camera used to resolve visibility ranges.
//
// This is the position of the camera itself, unless this view isn't
// associated with a camera, in which case it's the position of the primary
// camera.
lod_view_world_position: vec3<f32>,
color_grading: ColorGrading,
mip_bias: f32,
frame_count: u32,
Expand Down
Loading