1use bevy_camera::{Camera2d, CameraMainTextureUsages, ClearColorConfig, CompositingSpace};
10use bevy_ecs::{
11 component::Component,
12 entity::{Entity, EntityHashMap},
13 query::Has,
14 schedule::SystemSet,
15 system::Query,
16};
17use bevy_log::warn_once;
18use bevy_platform::collections::HashMap;
19use wgpu::TextureFormat;
20
21use super::{main_texture_key, ExtractedView, MainTextureKey, Msaa};
22use crate::camera::ExtractedCamera;
23
24#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
35pub struct ResolvedCompositingSpace(pub Option<CompositingSpace>);
36
37impl ResolvedCompositingSpace {
38 pub fn space(this: Option<&Self>) -> Option<CompositingSpace> {
40 this.and_then(|resolved| resolved.0)
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SystemSet)]
48pub struct ResolveCompositingSpaces;
49
50pub fn composites_fullscreen(camera: &ExtractedCamera) -> bool {
53 matches!(camera.clear_color, ClearColorConfig::None) && camera.viewport.is_none()
54}
55
56fn stores_signed_values(format: TextureFormat) -> bool {
59 matches!(
60 format,
61 TextureFormat::Rgba16Float
62 | TextureFormat::Rgba32Float
63 | TextureFormat::Rgba8Snorm
64 | TextureFormat::Rgba16Snorm
65 )
66}
67
68pub fn resolve_composition_spaces(
71 mut views: Query<(
72 Entity,
73 &ExtractedCamera,
74 &ExtractedView,
75 &CameraMainTextureUsages,
76 &Msaa,
77 Has<Camera2d>,
78 &mut ResolvedCompositingSpace,
79 )>,
80) {
81 let any_request = views
84 .iter()
85 .any(|(.., resolved)| resolved.0.is_some_and(|space| !space.is_linear()));
86 if !any_request {
87 for (.., mut resolved) in views.iter_mut() {
88 *resolved = ResolvedCompositingSpace(None);
89 }
90 return;
91 }
92
93 let inputs: Vec<(MainTextureKey, SpaceInput)> = views
94 .iter()
95 .map(
96 |(entity, camera, view, texture_usage, msaa, is_camera_2d, resolved)| {
97 (
98 main_texture_key(camera, view, texture_usage, *msaa),
99 SpaceInput {
100 entity,
101 sorted_index: camera.sorted_camera_index_for_target,
102 request: resolved.0,
105 composites_fullscreen: composites_fullscreen(camera),
106 is_camera_2d,
107 signed_storage: stores_signed_values(view.target_format),
108 },
109 )
110 },
111 )
112 .collect();
113
114 let (spaces, diagnostics) = resolve_spaces(inputs);
115 for (entity, .., mut resolved) in views.iter_mut() {
118 *resolved = ResolvedCompositingSpace(spaces.get(&entity).copied().flatten());
119 }
120
121 for diagnostic in diagnostics {
122 match diagnostic {
123 CompositingSpaceResolutionError::ConflictingStackRequests { requests } => warn_once!(
124 "Cameras stacked on one shared main texture request conflicting compositing \
125 spaces: {requests:?}. The stack composites in linear instead. Give every \
126 camera in the stack the same CompositingSpace."
127 ),
128 CompositingSpaceResolutionError::MixedSharedTextureRequests { requests } => warn_once!(
129 "Cameras sharing a render target request different compositing spaces: \
130 {requests:?}. \
131 Blending between their pixels will be wrong where their regions meet. Use one \
132 CompositingSpace for every camera on a shared target."
133 ),
134 CompositingSpaceResolutionError::NonCamera2dRequest { non_camera_2d } => warn_once!(
135 "A CompositingSpace request resolves to linear because the views \
136 {non_camera_2d:?} are not Camera2d views and their render paths do not encode \
137 into compositing spaces. Remove the CompositingSpace component or use a Camera2d."
138 ),
139 CompositingSpaceResolutionError::OklabWithoutSignedStorage { entities } => {
140 warn_once!(
141 "CompositingSpace::Oklab on views {entities:?} resolves to linear because \
142 the main texture format cannot store the signed Oklab channels. Add the Hdr \
143 component to the camera to get a main texture format that can."
144 );
145 }
146 }
147 }
148}
149
150struct SpaceInput {
152 entity: Entity,
153 sorted_index: usize,
155 request: Option<CompositingSpace>,
156 composites_fullscreen: bool,
157 is_camera_2d: bool,
158 signed_storage: bool,
162}
163
164#[derive(Debug, PartialEq, Eq)]
168enum CompositingSpaceResolutionError {
169 ConflictingStackRequests {
171 requests: Vec<(Entity, CompositingSpace)>,
172 },
173 MixedSharedTextureRequests {
176 requests: Vec<(Entity, Option<CompositingSpace>)>,
177 },
178 NonCamera2dRequest { non_camera_2d: Vec<Entity> },
181 OklabWithoutSignedStorage { entities: Vec<Entity> },
183}
184
185fn resolve_spaces(
189 views: impl IntoIterator<Item = (MainTextureKey, SpaceInput)>,
190) -> (
191 EntityHashMap<Option<CompositingSpace>>,
192 Vec<CompositingSpaceResolutionError>,
193) {
194 let mut groups: HashMap<MainTextureKey, Vec<SpaceInput>> = HashMap::default();
195 for (texture, mut view) in views {
196 view.request = view.request.filter(|space| !space.is_linear());
197 groups.entry(texture).or_default().push(view);
198 }
199
200 let mut resolved = EntityHashMap::default();
201 let mut diagnostics = Vec::new();
202 for group in groups.values_mut() {
203 group.sort_unstable_by_key(|view| view.sorted_index);
204 let is_stack = group.len() >= 2 && group[1..].iter().all(|view| view.composites_fullscreen);
205 if is_stack {
206 resolve_members(group, &mut resolved, &mut diagnostics);
207 } else {
208 warn_on_mixed_requests(group, &mut diagnostics);
209 for member in 0..group.len() {
212 resolve_members(&group[member..=member], &mut resolved, &mut diagnostics);
213 }
214 }
215 }
216 (resolved, diagnostics)
217}
218
219fn warn_on_mixed_requests(
223 members: &[SpaceInput],
224 diagnostics: &mut Vec<CompositingSpaceResolutionError>,
225) {
226 if members.len() < 2 {
227 return;
228 }
229 let first = members[0].request;
230 let mixed = members[1..].iter().any(|member| member.request != first);
231 let any_space = members.iter().any(|member| member.request.is_some());
232 if mixed && any_space {
233 diagnostics.push(
234 CompositingSpaceResolutionError::MixedSharedTextureRequests {
235 requests: members
236 .iter()
237 .map(|member| (member.entity, member.request))
238 .collect(),
239 },
240 );
241 }
242}
243
244fn resolve_members(
257 members: &[SpaceInput],
258 resolved: &mut EntityHashMap<Option<CompositingSpace>>,
259 diagnostics: &mut Vec<CompositingSpaceResolutionError>,
260) {
261 let mut has_srgb = false;
262 let mut has_oklab = false;
263 let mut has_non_camera_2d = false;
264 for member in members {
265 has_srgb |= member.request == Some(CompositingSpace::Srgb);
266 has_oklab |= member.request == Some(CompositingSpace::Oklab);
267 has_non_camera_2d |= !member.is_camera_2d;
268 }
269
270 let mut space = match (has_srgb, has_oklab) {
271 (false, false) => None,
272 (true, false) => Some(CompositingSpace::Srgb),
273 (false, true) => Some(CompositingSpace::Oklab),
274 (true, true) => {
275 diagnostics.push(CompositingSpaceResolutionError::ConflictingStackRequests {
276 requests: members
277 .iter()
278 .filter_map(|member| member.request.map(|space| (member.entity, space)))
279 .collect(),
280 });
281 None
282 }
283 };
284
285 if has_non_camera_2d {
286 if has_srgb || has_oklab {
288 diagnostics.push(CompositingSpaceResolutionError::NonCamera2dRequest {
289 non_camera_2d: members
290 .iter()
291 .filter(|member| !member.is_camera_2d)
292 .map(|member| member.entity)
293 .collect(),
294 });
295 }
296 space = None;
297 }
298
299 if space == Some(CompositingSpace::Oklab) && !members[0].signed_storage {
300 diagnostics.push(CompositingSpaceResolutionError::OklabWithoutSignedStorage {
301 entities: members.iter().map(|member| member.entity).collect(),
302 });
303 space = None;
304 }
305
306 for member in members {
307 resolved.insert(member.entity, space);
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use wgpu::TextureUsages;
315
316 const SRGB: Option<CompositingSpace> = Some(CompositingSpace::Srgb);
317 const OKLAB: Option<CompositingSpace> = Some(CompositingSpace::Oklab);
318 const LINEAR: Option<CompositingSpace> = Some(CompositingSpace::Linear);
319
320 fn entity(raw: u32) -> Entity {
321 Entity::from_raw_u32(raw).unwrap()
322 }
323
324 fn view(
328 raw: u32,
329 texture: usize,
330 index: usize,
331 request: Option<CompositingSpace>,
332 ) -> (MainTextureKey, SpaceInput) {
333 let msaa = [Msaa::Off, Msaa::Sample4][texture];
334 (
335 (
336 None,
337 TextureUsages::RENDER_ATTACHMENT,
338 TextureFormat::Rgba16Float,
339 msaa,
340 ),
341 SpaceInput {
342 entity: entity(raw),
343 sorted_index: index,
344 request,
345 composites_fullscreen: true,
346 is_camera_2d: true,
347 signed_storage: true,
348 },
349 )
350 }
351
352 fn resolved_for(
353 output: &EntityHashMap<Option<CompositingSpace>>,
354 raw: u32,
355 ) -> Option<CompositingSpace> {
356 *output.get(&entity(raw)).expect("view must be resolved")
357 }
358
359 fn has_conflict(diagnostics: &[CompositingSpaceResolutionError]) -> bool {
360 diagnostics.iter().any(|d| {
361 matches!(
362 d,
363 CompositingSpaceResolutionError::ConflictingStackRequests { .. }
364 )
365 })
366 }
367
368 fn has_mixed(diagnostics: &[CompositingSpaceResolutionError]) -> bool {
369 diagnostics.iter().any(|d| {
370 matches!(
371 d,
372 CompositingSpaceResolutionError::MixedSharedTextureRequests { .. }
373 )
374 })
375 }
376
377 fn has_non_camera_2d(diagnostics: &[CompositingSpaceResolutionError]) -> bool {
378 diagnostics.iter().any(|d| {
379 matches!(
380 d,
381 CompositingSpaceResolutionError::NonCamera2dRequest { .. }
382 )
383 })
384 }
385
386 fn has_oklab_storage(diagnostics: &[CompositingSpaceResolutionError]) -> bool {
387 diagnostics.iter().any(|d| {
388 matches!(
389 d,
390 CompositingSpaceResolutionError::OklabWithoutSignedStorage { .. }
391 )
392 })
393 }
394
395 #[test]
396 fn solo_default_camera_keeps_no_request() {
397 let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, None)]);
398 assert_eq!(resolved_for(&resolved, 1), None);
399 assert!(diagnostics.is_empty());
400 }
401
402 #[test]
403 fn solo_linear_request_normalizes_to_none() {
404 let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, LINEAR)]);
405 assert_eq!(resolved_for(&resolved, 1), None);
406 assert!(diagnostics.is_empty());
407 }
408
409 #[test]
410 fn stack_of_linear_requests_normalizes_to_none() {
411 let (resolved, diagnostics) =
412 resolve_spaces([view(1, 0, 0, LINEAR), view(2, 0, 1, LINEAR)]);
413 assert_eq!(resolved_for(&resolved, 1), None);
414 assert_eq!(resolved_for(&resolved, 2), None);
415 assert!(diagnostics.is_empty());
416 }
417
418 #[test]
419 fn stack_with_one_distinct_space_resolves_every_member_to_it() {
420 let (resolved, diagnostics) = resolve_spaces([
421 view(1, 0, 0, None),
422 view(2, 0, 1, SRGB),
423 view(3, 0, 2, SRGB),
424 ]);
425 assert_eq!(resolved_for(&resolved, 1), SRGB);
426 assert_eq!(resolved_for(&resolved, 2), SRGB);
427 assert_eq!(resolved_for(&resolved, 3), SRGB);
428 assert!(diagnostics.is_empty());
429 }
430
431 #[test]
432 fn stack_with_conflicting_spaces_resolves_to_none_and_warns() {
433 let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), view(2, 0, 1, OKLAB)]);
434 assert_eq!(resolved_for(&resolved, 1), None);
435 assert_eq!(resolved_for(&resolved, 2), None);
436 assert!(has_conflict(&diagnostics));
437 assert!(!has_mixed(&diagnostics));
438 }
439
440 #[test]
441 fn viewport_splitscreen_keeps_per_view_requests() {
442 let mut base = view(1, 0, 0, SRGB);
443 base.1.composites_fullscreen = false;
444 let mut pip = view(2, 0, 1, OKLAB);
445 pip.1.composites_fullscreen = false;
446 let (resolved, diagnostics) = resolve_spaces([base, pip]);
447 assert_eq!(resolved_for(&resolved, 1), SRGB);
448 assert_eq!(resolved_for(&resolved, 2), OKLAB);
449 assert!(has_mixed(&diagnostics));
450 assert!(!has_conflict(&diagnostics));
451 }
452
453 #[test]
454 fn mixed_request_and_no_request_non_stack_warns() {
455 let mut upper = view(2, 0, 1, None);
456 upper.1.composites_fullscreen = false;
457 let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), upper]);
458 assert_eq!(resolved_for(&resolved, 1), SRGB);
459 assert_eq!(resolved_for(&resolved, 2), None);
460 assert!(has_mixed(&diagnostics));
461 }
462
463 #[test]
464 fn same_request_non_stack_does_not_warn() {
465 let mut upper = view(2, 0, 1, SRGB);
466 upper.1.composites_fullscreen = false;
467 let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), upper]);
468 assert_eq!(resolved_for(&resolved, 1), SRGB);
469 assert_eq!(resolved_for(&resolved, 2), SRGB);
470 assert!(diagnostics.is_empty());
471 }
472
473 #[test]
474 fn linear_vs_no_request_non_stack_does_not_warn() {
475 let mut upper = view(2, 0, 1, None);
476 upper.1.composites_fullscreen = false;
477 let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, LINEAR), upper]);
478 assert_eq!(resolved_for(&resolved, 1), None);
479 assert_eq!(resolved_for(&resolved, 2), None);
480 assert!(diagnostics.is_empty());
481 }
482
483 #[test]
484 fn solo_non_camera_2d_srgb_request_resolves_to_none() {
485 let mut camera_3d = view(1, 0, 0, SRGB);
486 camera_3d.1.is_camera_2d = false;
487 let (resolved, diagnostics) = resolve_spaces([camera_3d]);
488 assert_eq!(resolved_for(&resolved, 1), None);
489 assert!(has_non_camera_2d(&diagnostics));
490 }
491
492 #[test]
493 fn solo_non_camera_2d_linear_request_resolves_without_warning() {
494 let mut camera_3d = view(1, 0, 0, LINEAR);
495 camera_3d.1.is_camera_2d = false;
496 let (resolved, diagnostics) = resolve_spaces([camera_3d]);
497 assert_eq!(resolved_for(&resolved, 1), None);
498 assert!(diagnostics.is_empty());
499 }
500
501 #[test]
502 fn stack_with_non_camera_2d_member_resolves_to_none() {
503 let mut base = view(1, 0, 0, None);
504 base.1.is_camera_2d = false;
505 base.1.composites_fullscreen = false;
506 let (resolved, diagnostics) = resolve_spaces([base, view(2, 0, 1, SRGB)]);
507 assert_eq!(resolved_for(&resolved, 1), None);
508 assert_eq!(resolved_for(&resolved, 2), None);
509 assert!(has_non_camera_2d(&diagnostics));
510 }
511
512 #[test]
513 fn non_camera_2d_stack_without_requests_does_not_warn() {
514 let mut base = view(1, 0, 0, None);
515 base.1.is_camera_2d = false;
516 base.1.composites_fullscreen = false;
517 let (resolved, diagnostics) = resolve_spaces([base, view(2, 0, 1, None)]);
518 assert_eq!(resolved_for(&resolved, 1), None);
519 assert_eq!(resolved_for(&resolved, 2), None);
520 assert!(diagnostics.is_empty());
521 }
522
523 #[test]
525 fn camera_2d_member_of_mixed_non_stack_group_keeps_request() {
526 let mut camera_2d = view(1, 0, 0, SRGB);
527 camera_2d.1.composites_fullscreen = false;
528 let mut camera_3d = view(2, 0, 1, None);
529 camera_3d.1.composites_fullscreen = false;
530 camera_3d.1.is_camera_2d = false;
531 let (resolved, diagnostics) = resolve_spaces([camera_2d, camera_3d]);
532 assert_eq!(resolved_for(&resolved, 1), SRGB);
533 assert_eq!(resolved_for(&resolved, 2), None);
534 assert!(has_mixed(&diagnostics));
535 assert!(!has_non_camera_2d(&diagnostics));
536 }
537
538 #[test]
539 fn signed_storage_accepts_float_and_snorm_formats() {
540 for format in [
541 TextureFormat::Rgba16Float,
542 TextureFormat::Rgba32Float,
543 TextureFormat::Rgba8Snorm,
544 TextureFormat::Rgba16Snorm,
545 ] {
546 assert!(stores_signed_values(format), "{format:?}");
547 }
548 for format in [
549 TextureFormat::Rgba8Unorm,
550 TextureFormat::Rgba8UnormSrgb,
551 TextureFormat::Bgra8UnormSrgb,
552 TextureFormat::Rgb10a2Unorm,
553 ] {
554 assert!(!stores_signed_values(format), "{format:?}");
555 }
556 }
557
558 #[test]
559 fn oklab_without_signed_storage_degrades_to_linear() {
560 let mut camera = view(1, 0, 0, OKLAB);
561 camera.1.signed_storage = false;
562 let (resolved, diagnostics) = resolve_spaces([camera]);
563 assert_eq!(resolved_for(&resolved, 1), None);
564 assert!(has_oklab_storage(&diagnostics));
565 }
566
567 #[test]
568 fn stack_resolved_oklab_degrades_on_unorm_storage() {
569 let mut base = view(1, 0, 0, None);
570 base.1.signed_storage = false;
571 let mut overlay = view(2, 0, 1, OKLAB);
572 overlay.1.signed_storage = false;
573 let (resolved, diagnostics) = resolve_spaces([base, overlay]);
574 assert_eq!(resolved_for(&resolved, 1), None);
575 assert_eq!(resolved_for(&resolved, 2), None);
576 assert!(has_oklab_storage(&diagnostics));
577 }
578
579 #[test]
582 fn non_camera_2d_oklab_fires_non_2d_warning_not_storage_warning() {
583 let mut camera_3d = view(1, 0, 0, OKLAB);
584 camera_3d.1.is_camera_2d = false;
585 camera_3d.1.signed_storage = false;
586 let (resolved, diagnostics) = resolve_spaces([camera_3d]);
587 assert_eq!(resolved_for(&resolved, 1), None);
588 assert!(has_non_camera_2d(&diagnostics));
589 assert!(!has_oklab_storage(&diagnostics));
590 }
591
592 #[test]
593 fn separate_textures_resolve_independently() {
594 let (resolved, diagnostics) = resolve_spaces([view(1, 0, 0, SRGB), view(2, 1, 0, OKLAB)]);
595 assert_eq!(resolved_for(&resolved, 1), SRGB);
596 assert_eq!(resolved_for(&resolved, 2), OKLAB);
597 assert!(diagnostics.is_empty());
598 }
599
600 #[test]
601 fn sorted_index_orders_the_group_not_insertion_order() {
602 let mut base = view(1, 0, 0, None);
603 base.1.composites_fullscreen = false;
604 let (resolved, diagnostics) = resolve_spaces([view(2, 0, 1, SRGB), base]);
608 assert_eq!(resolved_for(&resolved, 1), SRGB);
609 assert_eq!(resolved_for(&resolved, 2), SRGB);
610 assert!(diagnostics.is_empty());
611 }
612}