diff --git a/_release-content/migration-guides/resolved_compositing_space.md b/_release-content/migration-guides/resolved_compositing_space.md deleted file mode 100644 index fe0a8fcf1922f..0000000000000 --- a/_release-content/migration-guides/resolved_compositing_space.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: "`ViewTarget::compositing_space` is replaced by `ResolvedCompositingSpace`" -pull_requests: [25481] ---- - -Cameras stacked on one render target now share one compositing space, the -single space any member requests through `CompositingSpace`. A stack whose -cameras request conflicting spaces falls back to linear compositing and logs a -warning. - -`ViewTarget::compositing_space` and `ExtractedCamera::compositing_space` have -been removed. Render-world code should query `Option<&ResolvedCompositingSpace>` -on the view entity instead. diff --git a/crates/bevy_camera/src/components.rs b/crates/bevy_camera/src/components.rs index a649c8e6643bd..b346cc945b8d8 100644 --- a/crates/bevy_camera/src/components.rs +++ b/crates/bevy_camera/src/components.rs @@ -100,11 +100,3 @@ pub enum CompositingSpace { /// Perceptually uniform blending. Often smoother gradients. Requires [`Hdr`] because its value can be outside [0, 1]. Oklab, } - -impl CompositingSpace { - /// Whether this is the linear space, which needs no encode step. - #[inline] - pub fn is_linear(self) -> bool { - matches!(self, CompositingSpace::Linear) - } -} diff --git a/crates/bevy_core_pipeline/src/upscaling/mod.rs b/crates/bevy_core_pipeline/src/upscaling/mod.rs index 5000f48ed8e35..ba6a95ee79891 100644 --- a/crates/bevy_core_pipeline/src/upscaling/mod.rs +++ b/crates/bevy_core_pipeline/src/upscaling/mod.rs @@ -3,10 +3,8 @@ use bevy_app::prelude::*; use bevy_camera::CameraOutputMode; use bevy_ecs::prelude::*; use bevy_render::{ - camera::ExtractedCamera, - render_resource::*, - view::{ResolvedCompositingSpace, ViewTarget}, - Render, RenderApp, RenderStartup, RenderSystems, + camera::ExtractedCamera, render_resource::*, view::ViewTarget, Render, RenderApp, + RenderStartup, RenderSystems, }; mod node; @@ -57,10 +55,9 @@ fn prepare_view_upscaling_pipelines( &ViewTarget, Option<&ExtractedCamera>, Option<&ViewUpscalingPipeline>, - Option<&ResolvedCompositingSpace>, )>, ) { - for (entity, view_target, camera, maybe_pipeline, resolved_space) in view_targets.iter() { + for (entity, view_target, camera, maybe_pipeline) in view_targets.iter() { let blend_state = if let Some(extracted_camera) = camera { match extracted_camera.output_mode { CameraOutputMode::Skip => None, @@ -93,7 +90,7 @@ fn prepare_view_upscaling_pipelines( target_format, blend_state, samples: 1, - source_space: ResolvedCompositingSpace::space(resolved_space), + source_space: view_target.compositing_space, }; if maybe_pipeline.is_none_or(|ViewUpscalingPipeline(_, cached_key)| *cached_key != key) { diff --git a/crates/bevy_render/src/camera.rs b/crates/bevy_render/src/camera.rs index 2df2182e31c68..93639f3dd8407 100644 --- a/crates/bevy_render/src/camera.rs +++ b/crates/bevy_render/src/camera.rs @@ -12,8 +12,7 @@ use crate::{ view::{ ColorGrading, ExtractedView, ExtractedWindow, Msaa, NoIndirectDrawing, RenderExtractedVisibleEntities, RenderVisibleEntities, RenderVisibleEntitiesClass, - ResolvedCompositingSpace, RetainedViewEntity, ViewUniformOffset, - VisibilityExtractionSystemParam, + RetainedViewEntity, ViewUniformOffset, VisibilityExtractionSystemParam, }, Extract, ExtractSchedule, Render, RenderApp, RenderSystems, }; @@ -468,6 +467,9 @@ pub struct ExtractedCamera { pub sorted_camera_index_for_target: usize, pub exposure: f32, pub hdr: bool, + /// When [`CompositingSpace::Srgb`], the main texture uses linear storage (`Rgba8Unorm`) + /// and shaders output sRGB-encoded values for gamma-encoded blending. + pub compositing_space: Option, } pub fn extract_cameras( @@ -513,7 +515,6 @@ pub fn extract_cameras( ExtractedCamera, ExtractedView, RenderVisibleEntities, - ResolvedCompositingSpace, TemporalJitter, MipBias, RenderLayers, @@ -643,8 +644,8 @@ pub fn extract_cameras( .map(Exposure::exposure) .unwrap_or_else(|| Exposure::default().exposure()), hdr, + compositing_space: compositing_space.copied(), }, - ResolvedCompositingSpace(compositing_space.copied()), ExtractedView { retained_view_entity: RetainedViewEntity::new(main_entity.into(), None, 0), clip_from_view: camera.clip_from_view(), @@ -1195,6 +1196,7 @@ mod tests { sorted_camera_index_for_target: 0, exposure: 1.0, hdr, + compositing_space: None, } } diff --git a/crates/bevy_render/src/view/composition.rs b/crates/bevy_render/src/view/composition.rs deleted file mode 100644 index 493ef717ba805..0000000000000 --- a/crates/bevy_render/src/view/composition.rs +++ /dev/null @@ -1,585 +0,0 @@ -//! Resolution of per-camera [`CompositingSpace`] requests. -//! -//! Cameras that render to the same target share main textures when their -//! settings match, and composite over each other in that texture. Later -//! passes need to know how the texture is encoded, so the whole stack has to -//! agree on one compositing space. This module picks that space each frame -//! and stores it in each view's [`ResolvedCompositingSpace`]. - -use bevy_camera::{Camera2d, CameraMainTextureUsages, ClearColorConfig, CompositingSpace}; -use bevy_ecs::{ - component::Component, - entity::{Entity, EntityHashMap}, - query::Has, - schedule::SystemSet, - system::Query, -}; -use bevy_log::warn_once; -use bevy_platform::collections::HashMap; -use wgpu::TextureFormat; - -use super::{main_texture_key, ExtractedView, MainTextureKey, Msaa}; -use crate::camera::ExtractedCamera; - -/// The compositing space a camera view actually uses this frame, written by -/// [`resolve_composition_spaces`]. -/// -/// `None` means linear. An explicit [`CompositingSpace::Linear`] request also -/// resolves to `None`, so both forms of linear produce the same pipeline -/// keys. -/// -/// The value depends on every camera in the stack. Adding or removing a -/// camera on a render target can change it for the cameras already there, -/// which respecializes their 2d pipelines for a frame. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] -pub struct ResolvedCompositingSpace(pub Option); - -impl ResolvedCompositingSpace { - /// Reads a view's resolved space. An absent component means linear. - pub fn space(this: Option<&Self>) -> Option { - this.and_then(|resolved| resolved.0) - } -} - -/// The system set that writes [`ResolvedCompositingSpace`]. It runs in -/// [`RenderSystems::CreateViews`](crate::RenderSystems::CreateViews) after -/// `sort_cameras`. Readers in `CreateViews` should order after this set. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SystemSet)] -pub struct ResolveCompositingSpaces; - -/// Whether a camera composites over the previous camera's output and covers -/// the whole render target. Decides membership in a compositing stack. -pub fn composites_fullscreen(camera: &ExtractedCamera) -> bool { - matches!(camera.clear_color, ClearColorConfig::None) && camera.viewport.is_none() -} - -/// Writes each camera view's [`ResolvedCompositingSpace`]. Runs in -/// [`ResolveCompositingSpaces`]. -pub fn resolve_composition_spaces( - mut views: Query<( - Entity, - &ExtractedCamera, - &ExtractedView, - &CameraMainTextureUsages, - &Msaa, - Has, - &mut ResolvedCompositingSpace, - )>, -) { - // When every camera requests linear or nothing, all views resolve to - // linear and no diagnostic can fire, so skip the grouping. - let any_request = views - .iter() - .any(|(.., resolved)| resolved.0.is_some_and(|space| !space.is_linear())); - if !any_request { - for (.., mut resolved) in views.iter_mut() { - *resolved = ResolvedCompositingSpace(None); - } - return; - } - - let inputs: Vec<(MainTextureKey, SpaceInput)> = views - .iter() - .map( - |(entity, camera, view, texture_usage, msaa, is_camera_2d, resolved)| { - ( - main_texture_key(camera, view, texture_usage, *msaa), - SpaceInput { - entity, - sorted_index: camera.sorted_camera_index_for_target, - // Extraction seeded each component with the camera's - // own request. - request: resolved.0, - composites_fullscreen: composites_fullscreen(camera), - is_camera_2d, - signed_float_storage: matches!( - view.target_format, - TextureFormat::Rgba16Float | TextureFormat::Rgba32Float - ), - }, - ) - }, - ) - .collect(); - - let (spaces, diagnostics) = resolve_spaces(inputs); - // `resolve_spaces` returns an entry for every view it was given. A - // missing entry reads as linear. - for (entity, .., mut resolved) in views.iter_mut() { - *resolved = ResolvedCompositingSpace(spaces.get(&entity).copied().flatten()); - } - - for diagnostic in diagnostics { - match diagnostic { - CompositingSpaceResolutionError::ConflictingStackRequests { requests } => warn_once!( - "Cameras stacked on one shared main texture request conflicting compositing \ - spaces: {requests:?}. The stack composites in linear instead. Give every \ - camera in the stack the same CompositingSpace." - ), - CompositingSpaceResolutionError::MixedSharedTextureRequests { requests } => warn_once!( - "Cameras sharing a render target request different compositing spaces: \ - {requests:?}. \ - Blending between their pixels will be wrong where their regions meet. Use one \ - CompositingSpace for every camera on a shared target." - ), - CompositingSpaceResolutionError::NonCamera2dRequest { non_camera_2d } => warn_once!( - "A CompositingSpace request resolves to linear because the views \ - {non_camera_2d:?} are not Camera2d views and their render paths do not encode \ - into compositing spaces. Remove the CompositingSpace component or use a Camera2d." - ), - CompositingSpaceResolutionError::OklabWithoutSignedFloatStorage { entities } => { - warn_once!( - "CompositingSpace::Oklab on views {entities:?} resolves to linear because \ - the main texture format cannot store the signed Oklab channels. Add the Hdr \ - component to the camera to get a signed-float main texture." - ); - } - } - } -} - -/// Per-view input to [`resolve_spaces`]. -struct SpaceInput { - entity: Entity, - /// The camera's position in its render target's sorted camera order. - sorted_index: usize, - request: Option, - composites_fullscreen: bool, - is_camera_2d: bool, - /// Whether the main texture format stores signed floats. The format is - /// part of the texture key, so this is the same for every view in a - /// group. - signed_float_storage: bool, -} - -/// A misconfiguration found while resolving compositing spaces. -/// `resolve_composition_spaces` reports each one as a warning. -/// `resolve_spaces` returns them so tests can check when each fires. -#[derive(Debug, PartialEq, Eq)] -enum CompositingSpaceResolutionError { - /// A compositing stack requests both `Srgb` and `Oklab`. - ConflictingStackRequests { - requests: Vec<(Entity, CompositingSpace)>, - }, - /// Views that share a main texture without forming a stack disagree on a - /// compositing space. - MixedSharedTextureRequests { - requests: Vec<(Entity, Option)>, - }, - /// A view that isn't a `Camera2d`, or a stack that holds one, requests - /// `Srgb` or `Oklab`. - NonCamera2dRequest { non_camera_2d: Vec }, - /// A resolved `Oklab` lands on a main texture without signed-float storage. - OklabWithoutSignedFloatStorage { entities: Vec }, -} - -/// Resolves one compositing space per view. A stack must agree on its shared -/// texture's encoding, so it resolves as one unit. Views that share a texture -/// without forming a stack resolve on their own. -fn resolve_spaces( - views: impl IntoIterator, -) -> ( - EntityHashMap>, - Vec, -) { - let mut groups: HashMap> = HashMap::default(); - for (texture, mut view) in views { - view.request = view.request.filter(|space| !space.is_linear()); - groups.entry(texture).or_default().push(view); - } - - let mut resolved = EntityHashMap::default(); - let mut diagnostics = Vec::new(); - for group in groups.values_mut() { - group.sort_unstable_by_key(|view| view.sorted_index); - let is_stack = group.len() >= 2 && group[1..].iter().all(|view| view.composites_fullscreen); - if is_stack { - resolve_members(group, &mut resolved, &mut diagnostics); - } else { - warn_on_mixed_requests(group, &mut diagnostics); - // Non-stack members resolve like solo views, each with its own - // request and its own overrides. - for member in 0..group.len() { - resolve_members(&group[member..=member], &mut resolved, &mut diagnostics); - } - } - } - (resolved, diagnostics) -} - -/// Warns when views that share a texture without forming a stack disagree on -/// a compositing space. Blending between their pixels is wrong where their -/// regions meet. -fn warn_on_mixed_requests( - members: &[SpaceInput], - diagnostics: &mut Vec, -) { - if members.len() < 2 { - return; - } - let first = members[0].request; - let mixed = members[1..].iter().any(|member| member.request != first); - let any_space = members.iter().any(|member| member.request.is_some()); - if mixed && any_space { - diagnostics.push( - CompositingSpaceResolutionError::MixedSharedTextureRequests { - requests: members - .iter() - .map(|member| (member.entity, member.request)) - .collect(), - }, - ); - } -} - -/// Resolves the compositing space for a list of views. -/// -/// It reports a diagnostic and falls back to linear in the following cases: -/// * The views request both [`Srgb`](CompositingSpace::Srgb) and -/// [`Oklab`](CompositingSpace::Oklab). -/// * A view that is not a [`Camera2d`] is in the list and any view requests a -/// non-linear compositing space. 3d render paths write linear values. -/// * The views request [`Oklab`](CompositingSpace::Oklab) on a texture format -/// without signed-float storage, as Oklab requires negative numbers. -/// -/// Otherwise the requested compositing space is chosen. With no request the -/// views resolve to linear. -fn resolve_members( - members: &[SpaceInput], - resolved: &mut EntityHashMap>, - diagnostics: &mut Vec, -) { - let mut has_srgb = false; - let mut has_oklab = false; - let mut has_non_camera_2d = false; - for member in members { - has_srgb |= member.request == Some(CompositingSpace::Srgb); - has_oklab |= member.request == Some(CompositingSpace::Oklab); - has_non_camera_2d |= !member.is_camera_2d; - } - - let mut space = match (has_srgb, has_oklab) { - (false, false) => None, - (true, false) => Some(CompositingSpace::Srgb), - (false, true) => Some(CompositingSpace::Oklab), - (true, true) => { - diagnostics.push(CompositingSpaceResolutionError::ConflictingStackRequests { - requests: members - .iter() - .filter_map(|member| member.request.map(|space| (member.entity, space))) - .collect(), - }); - None - } - }; - - if has_non_camera_2d { - // Warn only when a request exists to be overridden. - if has_srgb || has_oklab { - diagnostics.push(CompositingSpaceResolutionError::NonCamera2dRequest { - non_camera_2d: members - .iter() - .filter(|member| !member.is_camera_2d) - .map(|member| member.entity) - .collect(), - }); - } - space = None; - } - - if space == Some(CompositingSpace::Oklab) && !members[0].signed_float_storage { - diagnostics.push( - CompositingSpaceResolutionError::OklabWithoutSignedFloatStorage { - entities: members.iter().map(|member| member.entity).collect(), - }, - ); - space = None; - } - - for member in members { - resolved.insert(member.entity, space); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use wgpu::TextureUsages; - - const SRGB: Option = Some(CompositingSpace::Srgb); - const OKLAB: Option = Some(CompositingSpace::Oklab); - const LINEAR: Option = Some(CompositingSpace::Linear); - - fn entity(raw: u32) -> Entity { - Entity::from_raw_u32(raw).unwrap() - } - - /// A fullscreen `Camera2d` view whose main texture stores signed floats. - /// Tests override the fields they care about. `texture` picks which of - /// two texture keys the view groups under. - fn view( - raw: u32, - texture: usize, - index: usize, - request: Option, - ) -> (MainTextureKey, SpaceInput) { - let msaa = [Msaa::Off, Msaa::Sample4][texture]; - ( - ( - None, - TextureUsages::RENDER_ATTACHMENT, - TextureFormat::Rgba16Float, - msaa, - ), - SpaceInput { - entity: entity(raw), - sorted_index: index, - request, - composites_fullscreen: true, - is_camera_2d: true, - signed_float_storage: true, - }, - ) - } - - fn resolved_for( - output: &EntityHashMap>, - raw: u32, - ) -> Option { - *output.get(&entity(raw)).expect("view must be resolved") - } - - fn has_conflict(diagnostics: &[CompositingSpaceResolutionError]) -> bool { - diagnostics.iter().any(|d| { - matches!( - d, - CompositingSpaceResolutionError::ConflictingStackRequests { .. } - ) - }) - } - - fn has_mixed(diagnostics: &[CompositingSpaceResolutionError]) -> bool { - diagnostics.iter().any(|d| { - matches!( - d, - CompositingSpaceResolutionError::MixedSharedTextureRequests { .. } - ) - }) - } - - fn has_non_camera_2d(diagnostics: &[CompositingSpaceResolutionError]) -> bool { - diagnostics.iter().any(|d| { - matches!( - d, - CompositingSpaceResolutionError::NonCamera2dRequest { .. } - ) - }) - } - - fn has_oklab_storage(diagnostics: &[CompositingSpaceResolutionError]) -> bool { - diagnostics.iter().any(|d| { - matches!( - d, - CompositingSpaceResolutionError::OklabWithoutSignedFloatStorage { .. } - ) - }) - } - - #[test] - fn solo_default_camera_keeps_no_request() { - let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, None)]); - assert_eq!(resolved_for(&resolved, 1), None); - assert!(diagnostics.is_empty()); - } - - #[test] - fn solo_linear_request_normalizes_to_none() { - let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, LINEAR)]); - assert_eq!(resolved_for(&resolved, 1), None); - assert!(diagnostics.is_empty()); - } - - #[test] - fn stack_of_linear_requests_normalizes_to_none() { - let (resolved, diagnostics) = - resolve_spaces([view(1, 0, 0, LINEAR), view(2, 0, 1, LINEAR)]); - assert_eq!(resolved_for(&resolved, 1), None); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(diagnostics.is_empty()); - } - - #[test] - fn stack_with_one_distinct_space_resolves_every_member_to_it() { - let (resolved, diagnostics) = resolve_spaces([ - view(1, 0, 0, None), - view(2, 0, 1, SRGB), - view(3, 0, 2, SRGB), - ]); - assert_eq!(resolved_for(&resolved, 1), SRGB); - assert_eq!(resolved_for(&resolved, 2), SRGB); - assert_eq!(resolved_for(&resolved, 3), SRGB); - assert!(diagnostics.is_empty()); - } - - #[test] - fn stack_with_conflicting_spaces_resolves_to_none_and_warns() { - let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), view(2, 0, 1, OKLAB)]); - assert_eq!(resolved_for(&resolved, 1), None); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(has_conflict(&diagnostics)); - assert!(!has_mixed(&diagnostics)); - } - - #[test] - fn viewport_splitscreen_keeps_per_view_requests() { - let mut base = view(1, 0, 0, SRGB); - base.1.composites_fullscreen = false; - let mut pip = view(2, 0, 1, OKLAB); - pip.1.composites_fullscreen = false; - let (resolved, diagnostics) = resolve_spaces([base, pip]); - assert_eq!(resolved_for(&resolved, 1), SRGB); - assert_eq!(resolved_for(&resolved, 2), OKLAB); - assert!(has_mixed(&diagnostics)); - assert!(!has_conflict(&diagnostics)); - } - - #[test] - fn mixed_request_and_no_request_non_stack_warns() { - let mut upper = view(2, 0, 1, None); - upper.1.composites_fullscreen = false; - let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), upper]); - assert_eq!(resolved_for(&resolved, 1), SRGB); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(has_mixed(&diagnostics)); - } - - #[test] - fn same_request_non_stack_does_not_warn() { - let mut upper = view(2, 0, 1, SRGB); - upper.1.composites_fullscreen = false; - let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), upper]); - assert_eq!(resolved_for(&resolved, 1), SRGB); - assert_eq!(resolved_for(&resolved, 2), SRGB); - assert!(diagnostics.is_empty()); - } - - #[test] - fn linear_vs_no_request_non_stack_does_not_warn() { - let mut upper = view(2, 0, 1, None); - upper.1.composites_fullscreen = false; - let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, LINEAR), upper]); - assert_eq!(resolved_for(&resolved, 1), None); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(diagnostics.is_empty()); - } - - #[test] - fn solo_non_camera_2d_srgb_request_resolves_to_none() { - let mut camera_3d = view(1, 0, 0, SRGB); - camera_3d.1.is_camera_2d = false; - let (resolved, diagnostics) = resolve_spaces([camera_3d]); - assert_eq!(resolved_for(&resolved, 1), None); - assert!(has_non_camera_2d(&diagnostics)); - } - - #[test] - fn solo_non_camera_2d_linear_request_resolves_without_warning() { - let mut camera_3d = view(1, 0, 0, LINEAR); - camera_3d.1.is_camera_2d = false; - let (resolved, diagnostics) = resolve_spaces([camera_3d]); - assert_eq!(resolved_for(&resolved, 1), None); - assert!(diagnostics.is_empty()); - } - - #[test] - fn stack_with_non_camera_2d_member_resolves_to_none() { - let mut base = view(1, 0, 0, None); - base.1.is_camera_2d = false; - base.1.composites_fullscreen = false; - let (resolved, diagnostics) = resolve_spaces([base, view(2, 0, 1, SRGB)]); - assert_eq!(resolved_for(&resolved, 1), None); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(has_non_camera_2d(&diagnostics)); - } - - #[test] - fn non_camera_2d_stack_without_requests_does_not_warn() { - let mut base = view(1, 0, 0, None); - base.1.is_camera_2d = false; - base.1.composites_fullscreen = false; - let (resolved, diagnostics) = resolve_spaces([base, view(2, 0, 1, None)]); - assert_eq!(resolved_for(&resolved, 1), None); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(diagnostics.is_empty()); - } - - // The non-`Camera2d` member has no request, so there is no non-2d warning. - #[test] - fn camera_2d_member_of_mixed_non_stack_group_keeps_request() { - let mut camera_2d = view(1, 0, 0, SRGB); - camera_2d.1.composites_fullscreen = false; - let mut camera_3d = view(2, 0, 1, None); - camera_3d.1.composites_fullscreen = false; - camera_3d.1.is_camera_2d = false; - let (resolved, diagnostics) = resolve_spaces([camera_2d, camera_3d]); - assert_eq!(resolved_for(&resolved, 1), SRGB); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(has_mixed(&diagnostics)); - assert!(!has_non_camera_2d(&diagnostics)); - } - - #[test] - fn oklab_without_signed_float_storage_degrades_to_linear() { - let mut camera = view(1, 0, 0, OKLAB); - camera.1.signed_float_storage = false; - let (resolved, diagnostics) = resolve_spaces([camera]); - assert_eq!(resolved_for(&resolved, 1), None); - assert!(has_oklab_storage(&diagnostics)); - } - - #[test] - fn stack_resolved_oklab_degrades_on_unorm_storage() { - let mut base = view(1, 0, 0, None); - base.1.signed_float_storage = false; - let mut overlay = view(2, 0, 1, OKLAB); - overlay.1.signed_float_storage = false; - let (resolved, diagnostics) = resolve_spaces([base, overlay]); - assert_eq!(resolved_for(&resolved, 1), None); - assert_eq!(resolved_for(&resolved, 2), None); - assert!(has_oklab_storage(&diagnostics)); - } - - // The non-`Camera2d` rule runs before the storage rule, so a request - // forced to linear never warns twice. - #[test] - fn non_camera_2d_oklab_fires_non_2d_warning_not_storage_warning() { - let mut camera_3d = view(1, 0, 0, OKLAB); - camera_3d.1.is_camera_2d = false; - camera_3d.1.signed_float_storage = false; - let (resolved, diagnostics) = resolve_spaces([camera_3d]); - assert_eq!(resolved_for(&resolved, 1), None); - assert!(has_non_camera_2d(&diagnostics)); - assert!(!has_oklab_storage(&diagnostics)); - } - - #[test] - fn separate_textures_resolve_independently() { - let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), view(2, 1, 0, OKLAB)]); - assert_eq!(resolved_for(&resolved, 1), SRGB); - assert_eq!(resolved_for(&resolved, 2), OKLAB); - assert!(diagnostics.is_empty()); - } - - #[test] - fn sorted_index_orders_the_group_not_insertion_order() { - let mut base = view(1, 0, 0, None); - base.1.composites_fullscreen = false; - // The overlay comes first in the input. Sorting by sorted_index puts - // the clearing camera back at the front, so the group still counts as - // a stack. - let (resolved, diagnostics) = resolve_spaces([view(2, 0, 1, SRGB), base]); - assert_eq!(resolved_for(&resolved, 1), SRGB); - assert_eq!(resolved_for(&resolved, 2), SRGB); - assert!(diagnostics.is_empty()); - } -} diff --git a/crates/bevy_render/src/view/mod.rs b/crates/bevy_render/src/view/mod.rs index 8169cdd415f45..cbcc71130a049 100644 --- a/crates/bevy_render/src/view/mod.rs +++ b/crates/bevy_render/src/view/mod.rs @@ -1,4 +1,3 @@ -pub mod composition; pub mod visibility; pub mod window; @@ -7,7 +6,6 @@ use bevy_camera::{ CompositingSpace, Exposure, MainPassResolutionOverride, NormalizedRenderTarget, }; use bevy_diagnostic::FrameCount; -pub use composition::*; pub use visibility::*; pub use window::*; @@ -180,16 +178,9 @@ impl Plugin for ViewPlugin { )); if let Some(render_app) = app.get_sub_app_mut(RenderApp) { - render_app.configure_sets( - Render, - ResolveCompositingSpaces - .in_set(RenderSystems::CreateViews) - .after(crate::camera::sort_cameras), - ); render_app.add_systems( Render, ( - resolve_composition_spaces.in_set(ResolveCompositingSpaces), // `TextureView`s need to be dropped before reconfiguring window surfaces. clear_view_attachments .in_set(RenderSystems::PrepareViews) @@ -790,6 +781,8 @@ pub struct ViewTarget { main_texture: Arc, /// The final output attachment this view will present to, if available. out_texture: Option, + /// Color space of values stored in the main texture (for blit conversion to output) + pub compositing_space: Option, } /// Contains [`OutputColorAttachment`] used for each target present on any view in the current @@ -1367,9 +1360,6 @@ pub fn cleanup_view_targets_for_resize( } } -/// The settings that decide which cameras share main textures. Cameras with -/// equal keys share one allocation in [`prepare_view_targets`], and -/// [`resolve_composition_spaces`] groups them the same way. type MainTextureKey = ( Option, TextureUsages, @@ -1377,20 +1367,6 @@ type MainTextureKey = ( Msaa, ); -fn main_texture_key( - camera: &ExtractedCamera, - view: &ExtractedView, - texture_usage: &CameraMainTextureUsages, - msaa: Msaa, -) -> MainTextureKey { - ( - camera.target.clone(), - texture_usage.0, - view.target_format, - msaa, - ) -} - pub fn prepare_view_targets( mut commands: Commands, clear_color_global: Res, @@ -1402,7 +1378,6 @@ pub fn prepare_view_targets( &ExtractedView, &CameraMainTextureUsages, &Msaa, - Option<&ResolvedCompositingSpace>, )>, view_target_attachments: Res, mut main_texture_atomics: Local>>, @@ -1410,7 +1385,7 @@ pub fn prepare_view_targets( main_texture_atomics.retain(|_, weak| weak.strong_count() > 0); let mut textures = >::default(); - for (entity, camera, view, texture_usage, msaa, resolved_space) in cameras.iter() { + for (entity, camera, view, texture_usage, msaa) in cameras.iter() { let Some(target_size) = camera.physical_target_size else { // If we don't have a target size, we can't create the main texture and have to bail commands.entity(entity).try_remove::(); @@ -1438,16 +1413,20 @@ pub fn prepare_view_targets( }; // Convert clear color to the format expected by the main texture - let resolved_space = ResolvedCompositingSpace::space(resolved_space); let converted_clear_color: Option = - clear_color.map(|color| match resolved_space { + clear_color.map(|color| match camera.compositing_space { // If main texture stores Oklab or Srgb, convert Color to it for correct clear. Some(CompositingSpace::Oklab) => Oklaba::from(color).into(), Some(CompositingSpace::Srgb) => Srgba::from(color).into(), Some(CompositingSpace::Linear) | None => LinearRgba::from(color).into(), }); - let key = main_texture_key(camera, view, texture_usage, *msaa); + let key: MainTextureKey = ( + camera.target.clone(), + texture_usage.0, + main_texture_format, + *msaa, + ); let (a, b, sampled, main_texture) = textures.entry(key.clone()).or_insert_with(|| { let descriptor = TextureDescriptor { label: None, @@ -1522,6 +1501,7 @@ pub fn prepare_view_targets( main_textures, main_texture_format, out_texture: out_attachment.cloned(), + compositing_space: camera.compositing_space, }); } } diff --git a/crates/bevy_sprite_render/src/mesh2d/mesh.rs b/crates/bevy_sprite_render/src/mesh2d/mesh.rs index 52f318b3a8c80..7eaca49a8adcc 100644 --- a/crates/bevy_sprite_render/src/mesh2d/mesh.rs +++ b/crates/bevy_sprite_render/src/mesh2d/mesh.rs @@ -39,6 +39,7 @@ use bevy_mesh::{ MeshVertexBufferLayoutRef, }; use bevy_render::prelude::Msaa; +use bevy_render::RenderSystems::PrepareAssets; use bevy_render::{ batching::{ gpu_preprocessing::IndirectParametersMetadata, @@ -59,8 +60,8 @@ use bevy_render::{ sync_world::{MainEntity, MainEntityHashMap}, texture::{FallbackImage, GpuImage}, view::{ - texture_format_from_code, texture_format_to_code, ExtractedView, ResolveCompositingSpaces, - ResolvedCompositingSpace, ViewUniform, ViewUniformOffset, ViewUniforms, + texture_format_from_code, texture_format_to_code, ExtractedView, ViewUniform, + ViewUniformOffset, ViewUniforms, }, Extract, ExtractSchedule, GpuResourceAppExt, Render, RenderApp, RenderSystems, }; @@ -109,9 +110,7 @@ impl Plugin for Mesh2dRenderPlugin { Render, ( prepare_pending_mesh_material2d_queues.in_set(RenderSystems::Specialize), - check_views_need_specialization - .in_set(RenderSystems::CreateViews) - .after(ResolveCompositingSpaces), + check_views_need_specialization.in_set(PrepareAssets), batch_and_prepare_binned_render_phase:: .in_set(RenderSystems::PrepareResources), batch_and_prepare_binned_render_phase:: @@ -144,15 +143,24 @@ pub fn check_views_need_specialization( &Msaa, Option<&Tonemapping>, Option<&DebandDither>, - Option<&ResolvedCompositingSpace>, )>, ) { - for (view_entity, view, camera, msaa, tonemapping, dither, resolved_space) in &cameras { + for (view_entity, view, camera, msaa, tonemapping, dither) in &cameras { let mut view_key = Mesh2dPipelineKey::from_msaa_samples(msaa.samples()) - | Mesh2dPipelineKey::from_target_format(view.target_format) - | Mesh2dPipelineKey::from_compositing_space(ResolvedCompositingSpace::space( - resolved_space, - )); + | Mesh2dPipelineKey::from_target_format(view.target_format); + + if camera + .compositing_space + .is_some_and(|s| s == CompositingSpace::Srgb) + { + view_key |= Mesh2dPipelineKey::SRGB_COMPOSITING; + } + if camera + .compositing_space + .is_some_and(|s| s == CompositingSpace::Oklab) + { + view_key |= Mesh2dPipelineKey::OKLAB_COMPOSITING; + } if !camera.hdr && let Some(tonemapping) = tonemapping @@ -678,16 +686,6 @@ impl Mesh2dPipelineKey { .expect("Unknown bits in `COLOR_TARGET_FORMAT_MASK_BITS` of the pipeline key") } - /// Key bits for a view's resolved [`CompositingSpace`]. - #[inline] - pub fn from_compositing_space(space: Option) -> Self { - match space { - Some(CompositingSpace::Srgb) => Self::SRGB_COMPOSITING, - Some(CompositingSpace::Oklab) => Self::OKLAB_COMPOSITING, - Some(CompositingSpace::Linear) | None => Self::NONE, - } - } - pub fn msaa_samples(&self) -> u32 { 1 << ((self.bits() >> Self::MSAA_SHIFT_BITS) & Self::MSAA_MASK_BITS) } diff --git a/crates/bevy_sprite_render/src/mesh2d/wireframe2d.rs b/crates/bevy_sprite_render/src/mesh2d/wireframe2d.rs index 4ebcd28ac8fc1..b13211de46245 100644 --- a/crates/bevy_sprite_render/src/mesh2d/wireframe2d.rs +++ b/crates/bevy_sprite_render/src/mesh2d/wireframe2d.rs @@ -146,7 +146,7 @@ impl Plugin for Wireframe2dPlugin { Render, ( specialize_wireframes - .in_set(RenderSystems::Specialize) + .in_set(RenderSystems::PrepareMeshes) .after(prepare_assets::) .after(prepare_assets::), queue_wireframes diff --git a/crates/bevy_sprite_render/src/render/mod.rs b/crates/bevy_sprite_render/src/render/mod.rs index 83a43da53d7a4..cb990a49565e8 100644 --- a/crates/bevy_sprite_render/src/render/mod.rs +++ b/crates/bevy_sprite_render/src/render/mod.rs @@ -1,7 +1,6 @@ use core::ops::Range; use bevy_asset::{load_embedded_asset, AssetEvent, AssetId, AssetServer, Handle}; -use bevy_camera::CompositingSpace; use bevy_color::{ColorToComponents, LinearRgba}; use bevy_core_pipeline::{ core_2d::{Transparent2d, CORE_2D_DEPTH_FORMAT}, @@ -22,7 +21,7 @@ use bevy_mesh::VertexBufferLayout; use bevy_platform::collections::HashMap; use bevy_render::{ camera::ExtractedCamera, - view::{RenderVisibleEntities, ResolvedCompositingSpace, RetainedViewEntity}, + view::{RenderVisibleEntities, RetainedViewEntity}, }; use bevy_render::{ render_asset::RenderAssets, @@ -154,16 +153,6 @@ impl SpritePipelineKey { texture_format_from_code(code) .expect("Unknown bits in `COLOR_TARGET_FORMAT_MASK_BITS` of the pipeline key") } - - /// Key bits for a view's resolved [`CompositingSpace`]. - #[inline] - pub fn from_compositing_space(space: Option) -> Self { - match space { - Some(CompositingSpace::Srgb) => Self::SRGB_COMPOSITING, - Some(CompositingSpace::Oklab) => Self::OKLAB_COMPOSITING, - Some(CompositingSpace::Linear) | None => Self::NONE, - } - } } impl SpecializedRenderPipeline for SpritePipeline { @@ -439,23 +428,31 @@ pub fn queue_sprites( &Msaa, Option<&Tonemapping>, Option<&DebandDither>, - Option<&ResolvedCompositingSpace>, )>, ) { let draw_sprite_function = draw_functions.read().id::(); - for (visible_entities, camera, view, msaa, tonemapping, dither, resolved_space) in &mut cameras - { + for (visible_entities, camera, view, msaa, tonemapping, dither) in &mut cameras { let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity) else { continue; }; - let mut view_key = SpritePipelineKey::from_target_format(view.target_format) - | SpritePipelineKey::from_msaa_samples(msaa.samples()) - | SpritePipelineKey::from_compositing_space(ResolvedCompositingSpace::space( - resolved_space, - )); + let msaa_key = SpritePipelineKey::from_msaa_samples(msaa.samples()); + let mut view_key = SpritePipelineKey::from_target_format(view.target_format) | msaa_key; + + if camera + .compositing_space + .is_some_and(|s| s == bevy_camera::CompositingSpace::Srgb) + { + view_key |= SpritePipelineKey::SRGB_COMPOSITING; + } + if camera + .compositing_space + .is_some_and(|s| s == bevy_camera::CompositingSpace::Oklab) + { + view_key |= SpritePipelineKey::OKLAB_COMPOSITING; + } if !camera.hdr && let Some(tonemapping) = tonemapping