Skip to main content

bevy_render/view/
composition.rs

1//! Resolution of per-camera [`CompositingSpace`] requests.
2//!
3//! Cameras that render to the same target share main textures when their
4//! settings match, and composite over each other in that texture. Later
5//! passes need to know how the texture is encoded, so the whole stack has to
6//! agree on one compositing space. This module picks that space each frame
7//! and stores it in each view's [`ResolvedCompositingSpace`].
8
9use 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/// The compositing space a camera view actually uses this frame, written by
25/// [`resolve_composition_spaces`].
26///
27/// `None` means linear. An explicit [`CompositingSpace::Linear`] request also
28/// resolves to `None`, so both forms of linear produce the same pipeline
29/// keys.
30///
31/// The value depends on every camera in the stack. Adding or removing a
32/// camera on a render target can change it for the cameras already there,
33/// which respecializes their 2d pipelines for a frame.
34#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
35pub struct ResolvedCompositingSpace(pub Option<CompositingSpace>);
36
37impl ResolvedCompositingSpace {
38    /// Reads a view's resolved space. An absent component means linear.
39    pub fn space(this: Option<&Self>) -> Option<CompositingSpace> {
40        this.and_then(|resolved| resolved.0)
41    }
42}
43
44/// The system set that writes [`ResolvedCompositingSpace`]. It runs in
45/// [`RenderSystems::CreateViews`](crate::RenderSystems::CreateViews) after
46/// `sort_cameras`. Readers in `CreateViews` should order after this set.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SystemSet)]
48pub struct ResolveCompositingSpaces;
49
50/// Whether a camera composites over the previous camera's output and covers
51/// the whole render target. Decides membership in a compositing stack.
52pub fn composites_fullscreen(camera: &ExtractedCamera) -> bool {
53    matches!(camera.clear_color, ClearColorConfig::None) && camera.viewport.is_none()
54}
55
56/// Whether a main texture format can store the negative values that Oklab
57/// channels take. Float and snorm formats can. Unorm formats clamp them.
58fn 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
68/// Writes each camera view's [`ResolvedCompositingSpace`]. Runs in
69/// [`ResolveCompositingSpaces`].
70pub 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    // When every camera requests linear or nothing, all views resolve to
82    // linear and no diagnostic can fire, so skip the grouping.
83    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                        // Extraction seeded each component with the camera's
103                        // own request.
104                        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    // `resolve_spaces` returns an entry for every view it was given. A
116    // missing entry reads as linear.
117    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
150/// Per-view input to [`resolve_spaces`].
151struct SpaceInput {
152    entity: Entity,
153    /// The camera's position in its render target's sorted camera order.
154    sorted_index: usize,
155    request: Option<CompositingSpace>,
156    composites_fullscreen: bool,
157    is_camera_2d: bool,
158    /// Whether the main texture format stores signed values, see
159    /// [`stores_signed_values`]. The format is part of the texture key, so
160    /// this is the same for every view in a group.
161    signed_storage: bool,
162}
163
164/// A misconfiguration found while resolving compositing spaces.
165/// `resolve_composition_spaces` reports each one as a warning.
166/// `resolve_spaces` returns them so tests can check when each fires.
167#[derive(Debug, PartialEq, Eq)]
168enum CompositingSpaceResolutionError {
169    /// A compositing stack requests both `Srgb` and `Oklab`.
170    ConflictingStackRequests {
171        requests: Vec<(Entity, CompositingSpace)>,
172    },
173    /// Views that share a main texture without forming a stack disagree on a
174    /// compositing space.
175    MixedSharedTextureRequests {
176        requests: Vec<(Entity, Option<CompositingSpace>)>,
177    },
178    /// A view that isn't a `Camera2d`, or a stack that holds one, requests
179    /// `Srgb` or `Oklab`.
180    NonCamera2dRequest { non_camera_2d: Vec<Entity> },
181    /// A resolved `Oklab` lands on a main texture without signed storage.
182    OklabWithoutSignedStorage { entities: Vec<Entity> },
183}
184
185/// Resolves one compositing space per view. A stack must agree on its shared
186/// texture's encoding, so it resolves as one unit. Views that share a texture
187/// without forming a stack resolve on their own.
188fn 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            // Non-stack members resolve like solo views, each with its own
210            // request and its own overrides.
211            for member in 0..group.len() {
212                resolve_members(&group[member..=member], &mut resolved, &mut diagnostics);
213            }
214        }
215    }
216    (resolved, diagnostics)
217}
218
219/// Warns when views that share a texture without forming a stack disagree on
220/// a compositing space. Blending between their pixels is wrong where their
221/// regions meet.
222fn 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
244/// Resolves the compositing space for a list of views.
245///
246/// It reports a diagnostic and falls back to linear in the following cases:
247/// * The views request both [`Srgb`](CompositingSpace::Srgb) and
248///   [`Oklab`](CompositingSpace::Oklab).
249/// * A view that is not a [`Camera2d`] is in the list and any view requests a
250///   non-linear compositing space. 3d render paths write linear values.
251/// * The views request [`Oklab`](CompositingSpace::Oklab) on a texture format
252///   without signed storage, as Oklab requires negative numbers.
253///
254/// Otherwise the requested compositing space is chosen. With no request the
255/// views resolve to linear.
256fn 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        // Warn only when a request exists to be overridden.
287        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    /// A fullscreen `Camera2d` view whose main texture stores signed values.
325    /// Tests override the fields they care about. `texture` picks which of
326    /// two texture keys the view groups under.
327    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    // The non-`Camera2d` member has no request, so there is no non-2d warning.
524    #[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    // The non-`Camera2d` rule runs before the storage rule, so a request
580    // forced to linear never warns twice.
581    #[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        // The overlay comes first in the input. Sorting by sorted_index puts
605        // the clearing camera back at the front, so the group still counts as
606        // a stack.
607        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}