Skip to main content

concinnity_core/components/
post_process_config.rs

1// src/components/post_process_config.rs
2//
3// Runtime behavior for the PostProcessConfig asset. The authored schema (the
4// struct, its enums, and their Default) lives in concinnity-asset; this file
5// keeps the `Component` impl plus the `PostProcessResolve` extension trait that
6// resolves the authored tunables into the renderer's clamped `gfx` settings.
7
8use crate::components::{IndirectLighting, PostProcessConfig};
9use crate::ecs::Component;
10use crate::gfx::render_types::PostProcessTunables;
11use crate::math::exp2;
12
13// `exposure_ev` is clamped to this range before resolving to a multiplier so a
14// stray value cannot push the scene to `inf` / `0`.
15const EXPOSURE_EV_LIMIT: f32 = 16.0;
16
17/// Resolves a `PostProcessConfig`'s authored tunables into the clamped,
18/// GPU-facing settings the renderer consumes. Kept in core (not concinnity-asset)
19/// because every return type is a `crate::gfx` settings struct.
20pub trait PostProcessResolve {
21    /// Resolve the authored fields into the GPU-facing `PostProcessTunables`:
22    /// clamps each tunable and converts `exposure_ev` (stops) into the linear
23    /// multiplier the shaders expect. The composite's display-output flags are
24    /// not authored, so they are absent here: the backend adds them to the full
25    /// `PostProcessParams` once it has negotiated EDR support with the display.
26    fn resolve(&self) -> PostProcessTunables;
27
28    /// Clamp the authored `ambient_intensity` to a safe `[0, 16]` multiplier the
29    /// backend folds into `LightUniforms` to scale the indirect (ambient / IBL)
30    /// term.
31    fn ambient_intensity(&self) -> f32;
32
33    /// Per-axis divisor for the roughness-aware reflection blur target, resolved
34    /// from `reflection_blur_resolution`. Always at least 1.
35    fn reflection_blur_divisor(&self) -> u32;
36
37    /// Resolve the SSAO tunables into clamped `SsaoSettings`, or `None` when the
38    /// `ssao` toggle is off so the backend can skip the SSAO passes entirely.
39    fn ssao_settings(&self) -> Option<crate::gfx::ssao::SsaoSettings>;
40
41    /// Resolve the SSR tunables into clamped `SsrSettings`, or `None` when the
42    /// `ssr` toggle is off.
43    fn ssr_settings(&self) -> Option<crate::gfx::ssr::SsrSettings>;
44
45    /// Resolve the ray-traced-reflection tunables into clamped
46    /// `RtReflectionSettings`, or `None` when `ray_traced_reflections` is off.
47    /// Reuses the SSR intensity / distance fields; the backend additionally gates
48    /// on GPU ray-tracing support.
49    fn rt_reflection_settings(&self) -> Option<crate::gfx::rt_reflections::RtReflectionSettings>;
50
51    /// Resolve the SSGI tunables into clamped `SsgiSettings`, or `None` when
52    /// `indirect_lighting` is not `Ssgi` so the backend can skip the SSGI passes.
53    fn ssgi_settings(&self) -> Option<crate::gfx::ssgi::SsgiSettings>;
54
55    /// Resolve the auto-exposure tunables into clamped `AutoExposureSettings`, or
56    /// `None` when the toggle is off so the backend can skip the histogram passes.
57    fn auto_exposure_settings(&self) -> Option<crate::gfx::auto_exposure::AutoExposureSettings>;
58}
59
60impl PostProcessResolve for PostProcessConfig {
61    fn resolve(&self) -> PostProcessTunables {
62        let ev = self
63            .exposure_ev
64            .clamp(-EXPOSURE_EV_LIMIT, EXPOSURE_EV_LIMIT);
65        PostProcessTunables {
66            bloom_intensity: self.bloom_intensity.max(0.0),
67            bloom_threshold: self.bloom_threshold.max(0.0),
68            bloom_knee: self.bloom_knee.max(0.0),
69            exposure: exp2(ev),
70            vignette: self.vignette_strength.clamp(0.0, 1.0),
71            lut_strength: self.lut_strength.clamp(0.0, 1.0),
72            fxaa: self.aa_mode.fxaa_flag(),
73        }
74    }
75
76    fn ambient_intensity(&self) -> f32 {
77        self.ambient_intensity.clamp(0.0, 16.0)
78    }
79
80    fn reflection_blur_divisor(&self) -> u32 {
81        self.reflection_blur_resolution.scale_divisor()
82    }
83
84    fn ssao_settings(&self) -> Option<crate::gfx::ssao::SsaoSettings> {
85        self.ssao
86            .then(|| crate::gfx::ssao::SsaoSettings::resolve(self.ssao_radius, self.ssao_intensity))
87    }
88
89    fn ssr_settings(&self) -> Option<crate::gfx::ssr::SsrSettings> {
90        self.ssr.then(|| {
91            crate::gfx::ssr::SsrSettings::resolve(self.ssr_intensity, self.ssr_max_distance)
92        })
93    }
94
95    fn rt_reflection_settings(&self) -> Option<crate::gfx::rt_reflections::RtReflectionSettings> {
96        self.ray_traced_reflections.then(|| {
97            crate::gfx::rt_reflections::RtReflectionSettings::resolve(
98                self.ssr_intensity,
99                self.ssr_max_distance,
100            )
101        })
102    }
103
104    fn ssgi_settings(&self) -> Option<crate::gfx::ssgi::SsgiSettings> {
105        (self.indirect_lighting == IndirectLighting::Ssgi).then(|| {
106            crate::gfx::ssgi::SsgiSettings::resolve(
107                self.ssgi_intensity,
108                self.ssgi_max_distance,
109                self.ssgi_rays,
110                self.ssgi_steps,
111                self.ssgi_resolution.scale_divisor(),
112            )
113        })
114    }
115
116    fn auto_exposure_settings(&self) -> Option<crate::gfx::auto_exposure::AutoExposureSettings> {
117        self.auto_exposure.then(|| {
118            // `hdr_display = true` shifts AE's pivot from scene-white
119            // (legacy SDR + ACES) to perceptual middle-grey, so the average
120            // pixel reads as a comfortable mid-tone on a panel that does no
121            // implicit tonemap. Falls back gracefully: even if the platform
122            // rejects the HDR request at swapchain time, SDR + ACES still
123            // produces a sensible (slightly darker) result.
124            crate::gfx::auto_exposure::AutoExposureSettings::resolve(
125                self.auto_exposure_min_ev,
126                self.auto_exposure_max_ev,
127                self.auto_exposure_speed,
128                self.hdr_display,
129            )
130        })
131    }
132}
133
134impl Component for PostProcessConfig {
135    const NAME: &'static str = "PostProcessConfig";
136
137    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
138        Ok(crate::blob::decode_exact(bytes)?)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::components::{
146        AaMode, ReflectionBlurResolution, SsgiResolution, UpscaleQuality, UpscalerBackend,
147    };
148    use alloc::format;
149
150    #[test]
151    fn default_resolves_to_neutral_params() {
152        let p = PostProcessConfig::default().resolve();
153        assert_eq!(p.bloom_intensity, 0.6);
154        assert_eq!(p.bloom_threshold, 1.0);
155        assert_eq!(p.bloom_knee, 0.5);
156        // No exposure offset and no vignette out of the box.
157        assert_eq!(p.exposure, 1.0);
158        assert_eq!(p.vignette, 0.0);
159        // Full LUT blend by default: a no-op until a ColorLut is declared.
160        assert_eq!(p.lut_strength, 1.0);
161        // The renderer's no-asset fallback has to resolve to the same thing.
162        assert_eq!(p, PostProcessTunables::DEFAULT);
163    }
164
165    #[test]
166    fn exposure_ev_resolves_to_power_of_two_multiplier() {
167        let cfg = PostProcessConfig {
168            exposure_ev: 2.0,
169            ..Default::default()
170        };
171        assert_eq!(cfg.resolve().exposure, 4.0);
172
173        let cfg = PostProcessConfig {
174            exposure_ev: -1.0,
175            ..Default::default()
176        };
177        assert_eq!(cfg.resolve().exposure, 0.5);
178    }
179
180    #[test]
181    fn exposure_ev_is_clamped_to_a_finite_multiplier() {
182        let cfg = PostProcessConfig {
183            exposure_ev: 1.0e9,
184            ..Default::default()
185        };
186        let exposure = cfg.resolve().exposure;
187        assert!(exposure.is_finite());
188        assert_eq!(exposure, EXPOSURE_EV_LIMIT.exp2());
189    }
190
191    #[test]
192    fn negative_and_overrange_inputs_are_clamped() {
193        let cfg = PostProcessConfig {
194            bloom_intensity: -3.0,
195            bloom_threshold: -1.0,
196            bloom_knee: -0.2,
197            vignette_strength: 5.0,
198            lut_strength: -2.0,
199            ..Default::default()
200        };
201        let p = cfg.resolve();
202        assert_eq!(p.bloom_intensity, 0.0);
203        assert_eq!(p.bloom_threshold, 0.0);
204        assert_eq!(p.bloom_knee, 0.0);
205        assert_eq!(p.vignette, 1.0);
206        assert_eq!(p.lut_strength, 0.0);
207    }
208
209    #[test]
210    fn lut_strength_is_clamped_to_unit_range() {
211        let cfg = PostProcessConfig {
212            lut_strength: 3.0,
213            ..Default::default()
214        };
215        assert_eq!(cfg.resolve().lut_strength, 1.0);
216    }
217
218    #[test]
219    fn aa_mode_defaults_to_fxaa_and_round_trips_through_args() {
220        assert_eq!(PostProcessConfig::default().aa_mode, AaMode::Fxaa);
221        let cfg = PostProcessConfig {
222            aa_mode: AaMode::Taa,
223            ..Default::default()
224        };
225        assert_eq!(cfg.clone().aa_mode, AaMode::Taa);
226    }
227
228    #[test]
229    fn aa_mode_gates_taa_and_fxaa() {
230        assert!(!AaMode::Off.taa_enabled());
231        assert!(!AaMode::Fxaa.taa_enabled());
232        assert!(AaMode::Taa.taa_enabled());
233        // resolve() carries the FXAA gate into the composite uniform.
234        let off = PostProcessConfig {
235            aa_mode: AaMode::Off,
236            ..Default::default()
237        };
238        assert_eq!(off.resolve().fxaa, 0.0);
239        assert_eq!(PostProcessConfig::default().resolve().fxaa, 1.0);
240    }
241
242    #[test]
243    fn ssao_defaults_off_with_neutral_tunables() {
244        let cfg = PostProcessConfig::default();
245        assert!(!cfg.ssao);
246        assert_eq!(cfg.ssao_radius, 0.5);
247        assert_eq!(cfg.ssao_intensity, 1.0);
248        // No SsaoSettings produced while the toggle is off.
249        assert!(cfg.ssao_settings().is_none());
250    }
251
252    #[test]
253    fn ssao_settings_resolve_and_clamp_when_enabled() {
254        let cfg = PostProcessConfig {
255            ssao: true,
256            ssao_radius: -1.0,
257            ssao_intensity: 99.0,
258            ..Default::default()
259        };
260        let s = cfg.ssao_settings().expect("ssao on");
261        assert!(s.radius > 0.0);
262        assert_eq!(s.intensity, 4.0);
263    }
264
265    #[test]
266    fn ssao_deserialises_from_jsonl_args() {
267        let cfg: PostProcessConfig =
268            serde_json::from_str(r#"{"ssao":true,"ssao_radius":0.6}"#).expect("parse");
269        assert!(cfg.ssao);
270        assert_eq!(cfg.ssao_radius, 0.6);
271        // Omitted intensity falls back to the default.
272        assert_eq!(cfg.ssao_intensity, 1.0);
273    }
274
275    #[test]
276    fn ssr_defaults_off_with_neutral_tunables() {
277        let cfg = PostProcessConfig::default();
278        assert!(!cfg.ssr);
279        assert_eq!(cfg.ssr_intensity, 0.7);
280        assert_eq!(cfg.ssr_max_distance, 40.0);
281        // No SsrSettings produced while the toggle is off.
282        assert!(cfg.ssr_settings().is_none());
283    }
284
285    #[test]
286    fn ssr_settings_resolve_and_clamp_when_enabled() {
287        let cfg = PostProcessConfig {
288            ssr: true,
289            ssr_intensity: 9.0,
290            ssr_max_distance: 1.0e6,
291            ..Default::default()
292        };
293        let s = cfg.ssr_settings().expect("ssr on");
294        assert_eq!(s.intensity, 1.0);
295        assert!(s.max_distance > 0.0 && s.max_distance.is_finite());
296    }
297
298    #[test]
299    fn ssr_deserialises_from_jsonl_args() {
300        let cfg: PostProcessConfig =
301            serde_json::from_str(r#"{"ssr":true,"ssr_intensity":0.5}"#).expect("parse");
302        assert!(cfg.ssr);
303        assert_eq!(cfg.ssr_intensity, 0.5);
304        // Omitted distance falls back to the default.
305        assert_eq!(cfg.ssr_max_distance, 40.0);
306    }
307
308    #[test]
309    fn rt_reflections_default_off_and_resolve_to_none() {
310        let cfg = PostProcessConfig::default();
311        assert!(!cfg.ray_traced_reflections);
312        // No RtReflectionSettings produced while the toggle is off.
313        assert!(cfg.rt_reflection_settings().is_none());
314    }
315
316    #[test]
317    fn rt_reflection_settings_reuse_ssr_tunables_when_enabled() {
318        let cfg = PostProcessConfig {
319            ray_traced_reflections: true,
320            ssr_intensity: 9.0,
321            ssr_max_distance: 1.0e6,
322            ..Default::default()
323        };
324        let s = cfg.rt_reflection_settings().expect("rt on");
325        // Reuses the SSR intensity / distance fields, clamped by the RT resolve.
326        assert_eq!(s.intensity, 1.0);
327        assert!(s.max_distance > 0.0 && s.max_distance.is_finite());
328    }
329
330    #[test]
331    fn rt_reflections_deserialise_from_jsonl_args() {
332        let cfg: PostProcessConfig =
333            serde_json::from_str(r#"{"ray_traced_reflections":true,"ssr_intensity":0.5}"#)
334                .expect("parse");
335        assert!(cfg.ray_traced_reflections);
336        assert!(cfg.rt_reflection_settings().is_some());
337        // Omitting the field leaves ray tracing off.
338        let cfg: PostProcessConfig =
339            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
340        assert!(!cfg.ray_traced_reflections);
341        assert!(cfg.rt_reflection_settings().is_none());
342    }
343
344    #[test]
345    fn ambient_intensity_defaults_neutral_and_clamps() {
346        // Default is a no-op multiplier.
347        assert_eq!(PostProcessConfig::default().ambient_intensity(), 1.0);
348        // Authored values clamp into [0, 16].
349        let hot = PostProcessConfig {
350            ambient_intensity: 100.0,
351            ..Default::default()
352        };
353        assert_eq!(hot.ambient_intensity(), 16.0);
354        let neg = PostProcessConfig {
355            ambient_intensity: -2.0,
356            ..Default::default()
357        };
358        assert_eq!(neg.ambient_intensity(), 0.0);
359        // Round-trips through JSONL like any other tunable.
360        let cfg: PostProcessConfig =
361            serde_json::from_str(r#"{"ambient_intensity":3.5}"#).expect("parse");
362        assert_eq!(cfg.ambient_intensity(), 3.5);
363    }
364
365    #[test]
366    fn ssgi_defaults_to_ibl_with_neutral_tunables() {
367        let cfg = PostProcessConfig::default();
368        assert_eq!(cfg.indirect_lighting, IndirectLighting::Ibl);
369        assert_eq!(cfg.ssgi_intensity, 0.5);
370        assert_eq!(cfg.ssgi_max_distance, 8.0);
371        // The gather defaults to half resolution with the historical 8x12
372        // ray/step counts.
373        assert_eq!(cfg.ssgi_resolution, SsgiResolution::Half);
374        assert_eq!(cfg.ssgi_rays, 8);
375        assert_eq!(cfg.ssgi_steps, 12);
376        // No SsgiSettings produced while indirect lighting is IBL-only.
377        assert!(cfg.ssgi_settings().is_none());
378    }
379
380    #[test]
381    fn ssgi_resolution_maps_to_a_per_axis_divisor() {
382        assert_eq!(SsgiResolution::Full.scale_divisor(), 1);
383        assert_eq!(SsgiResolution::Half.scale_divisor(), 2);
384        assert_eq!(SsgiResolution::Quarter.scale_divisor(), 4);
385        assert_eq!(SsgiResolution::default(), SsgiResolution::Half);
386    }
387
388    #[test]
389    fn ssgi_resolution_and_counts_flow_into_settings() {
390        let cfg = PostProcessConfig {
391            indirect_lighting: IndirectLighting::Ssgi,
392            ssgi_resolution: SsgiResolution::Quarter,
393            ssgi_rays: 4,
394            ssgi_steps: 20,
395            ..Default::default()
396        };
397        let s = cfg.ssgi_settings().expect("ssgi on");
398        assert_eq!(s.rays, 4);
399        assert_eq!(s.steps, 20);
400        assert_eq!(s.gi_scale, 4);
401    }
402
403    #[test]
404    fn ssgi_resolution_and_counts_deserialise_from_jsonl_args() {
405        let cfg: PostProcessConfig = serde_json::from_str(
406            r#"{"indirect_lighting":"ssgi","ssgi_resolution":"full","ssgi_rays":16,"ssgi_steps":8}"#,
407        )
408        .expect("parse");
409        assert_eq!(cfg.ssgi_resolution, SsgiResolution::Full);
410        assert_eq!(cfg.ssgi_rays, 16);
411        assert_eq!(cfg.ssgi_steps, 8);
412        // Omitting them falls back to the half-resolution 8x12 defaults.
413        let cfg: PostProcessConfig =
414            serde_json::from_str(r#"{"indirect_lighting":"ssgi"}"#).expect("parse");
415        assert_eq!(cfg.ssgi_resolution, SsgiResolution::Half);
416        assert_eq!(cfg.ssgi_rays, 8);
417        assert_eq!(cfg.ssgi_steps, 12);
418    }
419
420    #[test]
421    fn reflection_blur_resolution_defaults_to_half() {
422        let cfg = PostProcessConfig::default();
423        assert_eq!(
424            cfg.reflection_blur_resolution,
425            ReflectionBlurResolution::Half
426        );
427        assert_eq!(cfg.reflection_blur_divisor(), 2);
428    }
429
430    #[test]
431    fn reflection_blur_resolution_maps_to_a_per_axis_divisor() {
432        assert_eq!(ReflectionBlurResolution::Full.scale_divisor(), 1);
433        assert_eq!(ReflectionBlurResolution::Half.scale_divisor(), 2);
434        assert_eq!(ReflectionBlurResolution::Quarter.scale_divisor(), 4);
435        assert_eq!(
436            ReflectionBlurResolution::default(),
437            ReflectionBlurResolution::Half
438        );
439    }
440
441    #[test]
442    fn reflection_blur_resolution_deserialises_from_jsonl_args() {
443        let cfg: PostProcessConfig =
444            serde_json::from_str(r#"{"ssr":true,"reflection_blur_resolution":"quarter"}"#)
445                .expect("parse");
446        assert_eq!(
447            cfg.reflection_blur_resolution,
448            ReflectionBlurResolution::Quarter
449        );
450        assert_eq!(cfg.reflection_blur_divisor(), 4);
451        // Omitting the field falls back to the half-resolution default.
452        let cfg: PostProcessConfig = serde_json::from_str(r#"{"ssr":true}"#).expect("parse");
453        assert_eq!(
454            cfg.reflection_blur_resolution,
455            ReflectionBlurResolution::Half
456        );
457        assert_eq!(cfg.reflection_blur_divisor(), 2);
458    }
459
460    #[test]
461    fn ssgi_settings_resolve_and_clamp_when_enabled() {
462        let cfg = PostProcessConfig {
463            indirect_lighting: IndirectLighting::Ssgi,
464            ssgi_intensity: 99.0,
465            ssgi_max_distance: 1.0e6,
466            ..Default::default()
467        };
468        let s = cfg.ssgi_settings().expect("ssgi on");
469        assert_eq!(s.intensity, 4.0);
470        assert!(s.max_distance > 0.0 && s.max_distance.is_finite());
471    }
472
473    #[test]
474    fn ssgi_deserialises_from_jsonl_args() {
475        let cfg: PostProcessConfig =
476            serde_json::from_str(r#"{"indirect_lighting":"ssgi","ssgi_intensity":0.8}"#)
477                .expect("parse");
478        assert_eq!(cfg.indirect_lighting, IndirectLighting::Ssgi);
479        assert_eq!(cfg.ssgi_intensity, 0.8);
480        // Omitted distance falls back to the default.
481        assert_eq!(cfg.ssgi_max_distance, 8.0);
482        // Omitting the field leaves indirect lighting on IBL.
483        let cfg: PostProcessConfig =
484            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
485        assert_eq!(cfg.indirect_lighting, IndirectLighting::Ibl);
486        assert!(cfg.ssgi_settings().is_none());
487    }
488
489    #[test]
490    fn auto_exposure_defaults_off_with_neutral_tunables() {
491        let cfg = PostProcessConfig::default();
492        assert!(!cfg.auto_exposure);
493        assert_eq!(cfg.auto_exposure_min_ev, -8.0);
494        assert_eq!(cfg.auto_exposure_max_ev, 8.0);
495        assert_eq!(cfg.auto_exposure_speed, 1.5);
496        assert!(cfg.auto_exposure_settings().is_none());
497    }
498
499    #[test]
500    fn auto_exposure_settings_resolve_when_enabled() {
501        let cfg = PostProcessConfig {
502            auto_exposure: true,
503            auto_exposure_min_ev: -4.0,
504            auto_exposure_max_ev: 6.0,
505            auto_exposure_speed: 2.0,
506            ..Default::default()
507        };
508        let s = cfg.auto_exposure_settings().expect("auto-exposure on");
509        assert_eq!(s.min_ev, -4.0);
510        assert_eq!(s.max_ev, 6.0);
511        assert_eq!(s.speed, 2.0);
512    }
513
514    #[test]
515    fn auto_exposure_deserialises_from_jsonl_args() {
516        let cfg: PostProcessConfig =
517            serde_json::from_str(r#"{"auto_exposure":true,"auto_exposure_speed":3.0}"#)
518                .expect("parse");
519        assert!(cfg.auto_exposure);
520        assert_eq!(cfg.auto_exposure_speed, 3.0);
521        // Omitted bounds fall back to the defaults.
522        assert_eq!(cfg.auto_exposure_min_ev, -8.0);
523        assert_eq!(cfg.auto_exposure_max_ev, 8.0);
524    }
525
526    #[test]
527    fn aa_mode_deserialises_from_jsonl_args() {
528        let cfg: PostProcessConfig = serde_json::from_str(r#"{"aa_mode":"taa"}"#).expect("parse");
529        assert_eq!(cfg.aa_mode, AaMode::Taa);
530        // Omitting the field falls back to the FXAA default.
531        let cfg: PostProcessConfig =
532            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
533        assert_eq!(cfg.aa_mode, AaMode::Fxaa);
534        // "off" disables edge smoothing entirely.
535        let cfg: PostProcessConfig = serde_json::from_str(r#"{"aa_mode":"off"}"#).expect("parse");
536        assert_eq!(cfg.aa_mode, AaMode::Off);
537    }
538
539    #[test]
540    fn hdr_display_defaults_off() {
541        assert!(!PostProcessConfig::default().hdr_display);
542    }
543
544    #[test]
545    fn hdr_display_round_trips_through_args_and_jsonl() {
546        let cfg = PostProcessConfig {
547            hdr_display: true,
548            ..Default::default()
549        };
550        assert!(cfg.clone().hdr_display);
551
552        let cfg: PostProcessConfig =
553            serde_json::from_str(r#"{"hdr_display":true}"#).expect("parse");
554        assert!(cfg.hdr_display);
555    }
556
557    #[test]
558    fn temporal_upscaling_defaults_off_with_quality_preset() {
559        let cfg = PostProcessConfig::default();
560        assert!(!cfg.temporal_upscaling);
561        assert_eq!(cfg.upscale_quality, UpscaleQuality::Quality);
562    }
563
564    #[test]
565    fn upscale_quality_scales_are_monotonic() {
566        // Each step down in quality must reduce the per-axis ratio so render
567        // cost drops monotonically as users dial quality lower.
568        let q = UpscaleQuality::Quality.scale();
569        let b = UpscaleQuality::Balanced.scale();
570        let p = UpscaleQuality::Performance.scale();
571        let u = UpscaleQuality::UltraPerformance.scale();
572        assert!(q > b && b > p && p > u);
573        assert!(u > 0.0);
574    }
575
576    #[test]
577    fn occlusion_two_pass_defaults_off_and_round_trips() {
578        assert!(!PostProcessConfig::default().occlusion_two_pass);
579        let cfg = PostProcessConfig {
580            occlusion_two_pass: true,
581            ..Default::default()
582        };
583        assert!(cfg.clone().occlusion_two_pass);
584        // Deserialises from jsonl args; omitting it leaves the feature off.
585        let cfg: PostProcessConfig =
586            serde_json::from_str(r#"{"occlusion_two_pass":true}"#).expect("parse");
587        assert!(cfg.occlusion_two_pass);
588        let cfg: PostProcessConfig =
589            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
590        assert!(!cfg.occlusion_two_pass);
591    }
592
593    #[test]
594    fn upscale_backend_defaults_to_auto() {
595        assert_eq!(
596            PostProcessConfig::default().upscale_backend,
597            UpscalerBackend::Auto
598        );
599        assert_eq!(UpscalerBackend::default(), UpscalerBackend::Auto);
600    }
601
602    #[test]
603    fn upscale_backend_round_trips_via_snake_case_json() {
604        for (s, want) in [
605            ("auto", UpscalerBackend::Auto),
606            ("fsr3", UpscalerBackend::Fsr3),
607            ("dlss", UpscalerBackend::Dlss),
608            ("xess", UpscalerBackend::Xess),
609        ] {
610            let json = format!(r#"{{"temporal_upscaling":true,"upscale_backend":"{s}"}}"#);
611            let cfg: PostProcessConfig = serde_json::from_str(&json).expect("parse");
612            assert_eq!(cfg.upscale_backend, want, "for {s}");
613        }
614        // Omitting the field falls back to Auto.
615        let cfg: PostProcessConfig =
616            serde_json::from_str(r#"{"temporal_upscaling":true}"#).expect("parse");
617        assert_eq!(cfg.upscale_backend, UpscalerBackend::Auto);
618    }
619
620    #[test]
621    fn upscale_backend_round_trips_through_args() {
622        let cfg = PostProcessConfig {
623            upscale_backend: UpscalerBackend::Xess,
624            ..Default::default()
625        };
626        assert_eq!(cfg.clone().upscale_backend, UpscalerBackend::Xess);
627    }
628
629    #[test]
630    fn upscale_quality_round_trips_via_snake_case_json() {
631        let cfg: PostProcessConfig =
632            serde_json::from_str(r#"{"temporal_upscaling":true,"upscale_quality":"performance"}"#)
633                .expect("parse");
634        assert!(cfg.temporal_upscaling);
635        assert_eq!(cfg.upscale_quality, UpscaleQuality::Performance);
636        // Omitting the preset falls back to the default.
637        let cfg: PostProcessConfig =
638            serde_json::from_str(r#"{"temporal_upscaling":true}"#).expect("parse");
639        assert_eq!(cfg.upscale_quality, UpscaleQuality::Quality);
640    }
641}