Skip to main content

concinnity_core/components/
post_process_config.rs

1// src/components/post_process_config.rs
2//
3// The PostProcessConfig asset: the authored schema (the struct, its enums and
4// their `Default`), the `Component` impl, and the `PostProcessResolve` extension
5// trait that resolves the authored tunables into the renderer's clamped `gfx`
6// settings.
7
8use crate::ecs::Component;
9use crate::gfx::render_types::PostProcessTunables;
10use crate::math::exp2;
11
12/// Tunables for the post-process stack. One per world; the first declared
13/// instance wins. With no `PostProcessConfig` present, the defaults below are
14/// used (bloom on at a moderate intensity).
15///
16/// Colour-LUT grading is a separate [ColorLut](#colorlut) asset; `lut_strength`
17/// here is the blend amount applied to whichever [ColorLut](#colorlut) the world
18/// declares.
19///
20/// When `auto_exposure` is on, the scene's average brightness is measured each
21/// frame and exposure adapts toward a balanced mid-tone. The authored
22/// `exposure_ev` then acts as an additive bias (in stops) on top of the adapted
23/// value.
24///
25/// ```rust
26/// # use concinnity_core::components::PostProcessConfig;
27/// PostProcessConfig {
28///     bloom_intensity: 0.8,
29///     ..Default::default()
30/// };
31/// ```
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33#[serde(default)]
34pub struct PostProcessConfig {
35    /// Additive bloom contribution. 0 skips bloom entirely.
36    pub bloom_intensity: f32,
37    /// Brightness threshold for bloom. Pixels brighter than this contribute
38    /// fully; pixels within `bloom_knee` below it ramp in softly.
39    pub bloom_threshold: f32,
40    /// Width of the soft knee just below `bloom_threshold`.
41    pub bloom_knee: f32,
42    /// Exposure offset in photographic stops. Each +1 doubles scene
43    /// brightness before bloom and tonemapping; 0 is neutral.
44    pub exposure_ev: f32,
45    /// Vignette strength in `[0, 1]`. 0 disables the corner darkening.
46    pub vignette_strength: f32,
47    /// Colour-LUT blend in `[0, 1]`. Mixes the graded colour over the ungraded
48    /// one by this amount. Only matters when the world declares a
49    /// [ColorLut](#colorlut); with none, grading is a no-op at any strength.
50    pub lut_strength: f32,
51    /// Anti-aliasing mode. `fxaa` (default) applies a cheap composite-pass edge
52    /// filter; `taa` adds a temporal pass that jitters the projection and
53    /// accumulates detail across frames for the cleanest edges, at the cost of a
54    /// velocity pre-pass and a history buffer; `off` disables edge smoothing.
55    pub aa_mode: AaMode,
56    /// Screen-space ambient occlusion toggle. Darkens creases and contact areas
57    /// where ambient light is occluded.
58    pub ssao: bool,
59    /// How far the ambient-occlusion search reaches for occluders, in world
60    /// units. Larger values pick up broader, softer occlusion.
61    pub ssao_radius: f32,
62    /// Ambient-occlusion strength, clamped to `[0, 4]`. 1.0 is the natural
63    /// amount; higher values exaggerate the contact darkening.
64    pub ssao_intensity: f32,
65    /// Screen-space reflection toggle. Mixes reflected scene colour over glossy
66    /// surfaces (water, polished floors).
67    pub ssr: bool,
68    /// Reflection blend strength, clamped to `[0, 1]`. Scales the
69    /// Fresnel-weighted reflection mixed over the base shading.
70    pub ssr_intensity: f32,
71    /// How far a reflection reaches, in world units. Longer reaches catch more
72    /// distant reflections, more coarsely.
73    pub ssr_max_distance: f32,
74    /// Hardware ray-traced reflection toggle. When the GPU supports ray tracing,
75    /// traces real reflection rays so off-screen geometry still appears, instead
76    /// of the screen-space method. Reuses the `ssr_intensity` /
77    /// `ssr_max_distance` tunables and takes precedence over `ssr`, falling back
78    /// to it where ray tracing isn't available.
79    pub ray_traced_reflections: bool,
80    /// Internal resolution of the roughness-aware reflection blur the SSR /
81    /// ray-traced reflection composite runs. `half` (default) blurs at a
82    /// quarter of the pixels for a large saving and bilinearly upsamples;
83    /// `full` blurs at native resolution; `quarter` is the cheapest. Smooth
84    /// mirror surfaces stay sharp at any setting (the composite keeps the sharp
85    /// reflection for low roughness). Only matters when `ssr` or
86    /// `ray_traced_reflections` is on.
87    pub reflection_blur_resolution: ReflectionBlurResolution,
88    /// Indirect-diffuse lighting source. `ibl` (default) uses the environment
89    /// map's ambient alone. `ssgi` adds a screen-space global-illumination pass
90    /// on top, so nearby lit surfaces bleed colour onto one another; the
91    /// environment ambient still covers the off-screen / sky fallback.
92    pub indirect_lighting: IndirectLighting,
93    /// Multiplier on the indirect (ambient / IBL) lighting term, clamped to
94    /// `[0, 16]`. 1.0 (default) leaves the environment-derived ambient at its
95    /// physical level. Raising it lifts fill light in areas the directional
96    /// light cannot reach (shadowed facades, alleys) without brightening
97    /// directly lit surfaces, which the sun already dominates. Scales the
98    /// diffuse and specular IBL together, so reflections stay consistent with
99    /// the brighter ambient. Useful for high-contrast exterior scenes where a
100    /// strong sun would otherwise crush shadows to black.
101    pub ambient_intensity: f32,
102    /// Indirect-bounce strength, clamped to `[0, 4]`. Scales the gathered
103    /// indirect light added on top of the existing shading; 0 makes it a no-op.
104    /// Only matters when `indirect_lighting` is `ssgi`.
105    pub ssgi_intensity: f32,
106    /// How far the indirect-light gather reaches, in world units. A near-field
107    /// effect, so it defaults well below `ssr_max_distance`. Only matters when
108    /// `indirect_lighting` is `ssgi`.
109    pub ssgi_max_distance: f32,
110    /// Internal resolution of the SSGI gather. `half` (default) trades a little
111    /// sharpness for a large performance saving; `full` is native; `quarter` is
112    /// the cheapest. Only matters when `indirect_lighting` is `ssgi`.
113    pub ssgi_resolution: SsgiResolution,
114    /// Hemisphere rays cast per pixel by the SSGI gather, clamped to `[1, 32]`.
115    /// More rays reduce noise at a higher cost. Only matters when
116    /// `indirect_lighting` is `ssgi`.
117    pub ssgi_rays: u32,
118    /// Ray-march samples per SSGI ray, clamped to `[1, 64]`. More samples catch
119    /// finer occlusion at a higher cost. Only matters when `indirect_lighting`
120    /// is `ssgi`.
121    pub ssgi_steps: u32,
122    /// Auto-exposure toggle. Adapts exposure each frame toward a balanced
123    /// mid-tone. The authored `exposure_ev` then acts as an additive bias in
124    /// stops on top of the adapted value.
125    pub auto_exposure: bool,
126    /// Lower bound on the adapted exposure (EV). The `exposure_ev` bias is
127    /// applied before this clamp.
128    pub auto_exposure_min_ev: f32,
129    /// Upper bound on the adapted exposure (EV).
130    pub auto_exposure_max_ev: f32,
131    /// How quickly exposure chases a new target (per second). Higher converges
132    /// faster but can pump under flickering content; 1-3 is comfortable.
133    pub auto_exposure_speed: f32,
134    /// HDR display output toggle. On a capable display, emits extended-range
135    /// HDR instead of the standard tonemapped output. Falls back to standard
136    /// output when the display or platform doesn't support HDR.
137    pub hdr_display: bool,
138    /// PQ (HDR10) output mode. When true, and `hdr_display` is on, and the
139    /// display has HDR headroom, output is PQ-encoded for HDR10 panels. No
140    /// effect when `hdr_display` is off.
141    pub hdr_pq: bool,
142    /// Temporal upscaling toggle. Renders the 3D scene at a lower resolution
143    /// (set by `upscale_quality`) and reconstructs a full-resolution image,
144    /// trading some sharpness for performance. Replaces TAA while on (the `taa`
145    /// flag is ignored).
146    pub temporal_upscaling: bool,
147    /// Render-scale preset for `temporal_upscaling`; each step progressively
148    /// lowers the internal resolution. No effect when `temporal_upscaling` is
149    /// off.
150    pub upscale_quality: UpscaleQuality,
151    /// Which upscaler backend `temporal_upscaling` uses. `auto` (default) picks
152    /// the best available at runtime (DLSS on NVIDIA RTX, else XeSS, else FSR3);
153    /// `fsr3` / `dlss` / `xess` request a specific one and fall back when it is
154    /// unavailable on the current GPU or build. No effect when
155    /// `temporal_upscaling` is off. DLSS and XeSS are DirectX-only.
156    pub upscale_backend: UpscalerBackend,
157    /// Two-pass occlusion culling toggle. Reduces objects popping in a frame
158    /// late when they're revealed by camera or occluder motion, at the cost of
159    /// extra culling work each frame.
160    pub occlusion_two_pass: bool,
161}
162
163/// Render-scale preset for `PostProcessConfig.temporal_upscaling`. The ratio
164/// applies to both axes (input pixel count = output * ratio per axis), so
165/// `Quality` renders at 4/9 of the output pixel count, `Performance` at 1/4,
166/// and `UltraPerformance` at 1/9.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
168#[serde(rename_all = "snake_case")]
169#[derive(Default)]
170pub enum UpscaleQuality {
171    /// 4/9 of the output pixel count.
172    #[default]
173    Quality,
174    /// Roughly a third of the output pixel count.
175    Balanced,
176    /// A quarter of the output pixel count.
177    Performance,
178    /// A ninth of the output pixel count.
179    UltraPerformance,
180}
181
182impl UpscaleQuality {
183    /// Per-axis input-to-output ratio. The render target's width/height are
184    /// `(output_w * scale(), output_h * scale())`.
185    pub fn scale(self) -> f32 {
186        match self {
187            UpscaleQuality::Quality => 2.0 / 3.0,
188            UpscaleQuality::Balanced => 0.587,
189            UpscaleQuality::Performance => 0.5,
190            UpscaleQuality::UltraPerformance => 1.0 / 3.0,
191        }
192    }
193}
194
195/// Upscaler backend selector for `PostProcessConfig.temporal_upscaling`.
196/// `Auto` resolves at runtime to the best available (DLSS, then XeSS, then
197/// FSR3); the explicit variants request a specific backend and fall back when
198/// it is unavailable. DLSS (NVIDIA NGX) and XeSS (Intel) are DirectX-only;
199/// Metal uses MetalFX and Vulkan has no upscaler yet, so both treat any value
200/// as their native path.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
202#[serde(rename_all = "snake_case")]
203#[derive(Default)]
204pub enum UpscalerBackend {
205    /// Pick the best backend the device offers.
206    #[default]
207    Auto,
208    /// AMD FidelityFX Super Resolution 3.
209    Fsr3,
210    /// NVIDIA DLSS, through NGX.
211    Dlss,
212    /// Intel XeSS.
213    Xess,
214}
215
216/// Anti-aliasing mode for `PostProcessConfig.aa_mode`. `Off` runs no edge
217/// smoothing; `Fxaa` (default) applies the composite's single-frame edge
218/// filter, which is nearly free; `Taa` adds a temporal pass that jitters the
219/// projection and reprojects detail across frames for the cleanest edges, at
220/// the cost of a velocity pre-pass and a per-frame history buffer.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
222#[serde(rename_all = "snake_case")]
223#[derive(Default)]
224pub enum AaMode {
225    /// No edge smoothing.
226    Off,
227    /// Single-frame edge filter in the composite.
228    #[default]
229    Fxaa,
230    /// Temporal anti-aliasing: jittered projection plus a reprojected history.
231    Taa,
232}
233
234impl AaMode {
235    /// Whether the temporal anti-aliasing pass runs. Only the `Taa` mode does;
236    /// it needs the velocity pre-pass and the history buffer the other modes
237    /// skip.
238    pub fn taa_enabled(self) -> bool {
239        matches!(self, AaMode::Taa)
240    }
241
242    // Whether the composite's FXAA edge filter runs. Every mode except `Off`
243    // does (so `Taa` keeps FXAA as a cheap spatial cleanup on top of the
244    // temporal resolve).
245    fn fxaa_enabled(self) -> bool {
246        !matches!(self, AaMode::Off)
247    }
248
249    /// The composite's FXAA gate as the `0.0` / `1.0` flag `PostProcessParams`
250    /// carries to the shader.
251    pub fn fxaa_flag(self) -> f32 {
252        if self.fxaa_enabled() { 1.0 } else { 0.0 }
253    }
254}
255
256/// Indirect-diffuse lighting source for `PostProcessConfig.indirect_lighting`.
257/// `Ibl` is the image-based-lighting-only ambient term the renderer has always
258/// used; `Ssgi` layers a screen-space global-illumination bounce on top.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
260#[serde(rename_all = "snake_case")]
261#[derive(Default)]
262pub enum IndirectLighting {
263    /// Image-based lighting only.
264    #[default]
265    Ibl,
266    /// Image-based lighting plus a screen-space bounce.
267    Ssgi,
268}
269
270/// Internal render resolution of the SSGI gather pass (only meaningful when
271/// `indirect_lighting` is `ssgi`). The gather is the expensive part (a
272/// hemisphere ray-march per pixel), and its composite is a depth-aware
273/// bilateral filter that upsamples a lower-resolution gather back to full
274/// resolution at little visible cost. `half` (the default) gathers at a quarter
275/// of the pixels for a large saving; `full` keeps the gather at native
276/// resolution; `quarter` is the cheapest, for low-end GPUs or debugging.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
278#[serde(rename_all = "snake_case")]
279#[derive(Default)]
280pub enum SsgiResolution {
281    /// Gather at native resolution.
282    Full,
283    /// Gather at half resolution per axis.
284    #[default]
285    Half,
286    /// Gather at quarter resolution per axis.
287    Quarter,
288}
289
290impl SsgiResolution {
291    /// Per-axis render-resolution divisor the gather target is scaled by.
292    pub fn scale_divisor(self) -> u32 {
293        match self {
294            SsgiResolution::Full => 1,
295            SsgiResolution::Half => 2,
296            SsgiResolution::Quarter => 4,
297        }
298    }
299}
300
301/// Internal render resolution of the roughness-aware reflection blur (only
302/// meaningful when `ssr` or `ray_traced_reflections` is on). The blur is the
303/// expensive multi-tap part of the reflection composite and is low-frequency
304/// (a widening glossy cone), so running it at a fraction of the pixels and
305/// bilinearly upsampling is visually free. `half` (the default) blurs at a
306/// quarter of the pixels; `full` keeps it at native resolution; `quarter` is
307/// the cheapest. Mirrors stay sharp regardless: the composite lerps in the
308/// full-resolution reflection for low roughness.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
310#[serde(rename_all = "snake_case")]
311#[derive(Default)]
312pub enum ReflectionBlurResolution {
313    /// Blur at native resolution.
314    Full,
315    /// Blur at half resolution per axis.
316    #[default]
317    Half,
318    /// Blur at quarter resolution per axis.
319    Quarter,
320}
321
322impl ReflectionBlurResolution {
323    /// Per-axis render-resolution divisor the reflection blur target is scaled
324    /// by.
325    pub fn scale_divisor(self) -> u32 {
326        match self {
327            ReflectionBlurResolution::Full => 1,
328            ReflectionBlurResolution::Half => 2,
329            ReflectionBlurResolution::Quarter => 4,
330        }
331    }
332}
333
334/// Default SSGI hemisphere-ray and ray-march-step counts for the authored
335/// `ssgi_rays` / `ssgi_steps` fields. Defined here (the schema default) and
336/// re-exported by `concinnity-core`' `gfx::ssgi` for its runtime clamp path, so
337/// the authored default and the runtime code stay a single source of truth.
338pub const DEFAULT_SSGI_RAYS: u32 = 8;
339/// Default ray-march steps per SSGI ray. See [`DEFAULT_SSGI_RAYS`].
340pub const DEFAULT_SSGI_STEPS: u32 = 12;
341
342impl Default for PostProcessConfig {
343    fn default() -> Self {
344        Self {
345            bloom_intensity: 0.6,
346            bloom_threshold: 1.0,
347            bloom_knee: 0.5,
348            exposure_ev: 0.0,
349            vignette_strength: 0.0,
350            lut_strength: 1.0,
351            aa_mode: AaMode::Fxaa,
352            ssao: false,
353            ssao_radius: 0.5,
354            ssao_intensity: 1.0,
355            ssr: false,
356            ssr_intensity: 0.7,
357            ssr_max_distance: 40.0,
358            ray_traced_reflections: false,
359            reflection_blur_resolution: ReflectionBlurResolution::default(),
360            indirect_lighting: IndirectLighting::Ibl,
361            ambient_intensity: 1.0,
362            ssgi_intensity: 0.5,
363            ssgi_max_distance: 8.0,
364            ssgi_resolution: SsgiResolution::default(),
365            ssgi_rays: DEFAULT_SSGI_RAYS,
366            ssgi_steps: DEFAULT_SSGI_STEPS,
367            auto_exposure: false,
368            auto_exposure_min_ev: -8.0,
369            auto_exposure_max_ev: 8.0,
370            auto_exposure_speed: 1.5,
371            hdr_display: false,
372            hdr_pq: false,
373            temporal_upscaling: false,
374            upscale_quality: UpscaleQuality::default(),
375            upscale_backend: UpscalerBackend::default(),
376            occlusion_two_pass: false,
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn defaults_leave_the_expensive_effects_off() {
387        // Bloom and FXAA are cheap enough to ship on; everything that costs a
388        // full-screen pass is opt-in so a blank world runs on any hardware.
389        let c = PostProcessConfig::default();
390        assert_eq!(c.aa_mode, AaMode::Fxaa);
391        assert_eq!(c.bloom_intensity, 0.6);
392        assert!(!c.ssao);
393        assert!(!c.ssr);
394        assert!(!c.ray_traced_reflections);
395        assert!(!c.auto_exposure);
396        assert!(!c.temporal_upscaling);
397        assert!(!c.hdr_display);
398        assert!(!c.occlusion_two_pass);
399        assert_eq!(c.indirect_lighting, IndirectLighting::Ibl);
400        assert_eq!(c.ssgi_rays, DEFAULT_SSGI_RAYS);
401        assert_eq!(c.ssgi_steps, DEFAULT_SSGI_STEPS);
402    }
403
404    #[test]
405    fn every_enum_default_matches_the_config_default() {
406        let c = PostProcessConfig::default();
407        assert_eq!(c.upscale_quality, UpscaleQuality::Quality);
408        assert_eq!(c.upscale_backend, UpscalerBackend::Auto);
409        assert_eq!(c.ssgi_resolution, SsgiResolution::Half);
410        assert_eq!(c.reflection_blur_resolution, ReflectionBlurResolution::Half);
411        assert_eq!(AaMode::default(), AaMode::Fxaa);
412        assert_eq!(IndirectLighting::default(), IndirectLighting::Ibl);
413    }
414
415    #[test]
416    fn upscale_quality_scales_the_render_resolution_down() {
417        // Ordered coarsest-last: each tier renders strictly fewer pixels.
418        assert_eq!(UpscaleQuality::Quality.scale(), 2.0 / 3.0);
419        assert_eq!(UpscaleQuality::Balanced.scale(), 0.587);
420        assert_eq!(UpscaleQuality::Performance.scale(), 0.5);
421        assert_eq!(UpscaleQuality::UltraPerformance.scale(), 1.0 / 3.0);
422        let tiers = [
423            UpscaleQuality::Quality,
424            UpscaleQuality::Balanced,
425            UpscaleQuality::Performance,
426            UpscaleQuality::UltraPerformance,
427        ];
428        assert!(tiers.windows(2).all(|w| w[0].scale() > w[1].scale()));
429    }
430
431    #[test]
432    fn fxaa_runs_for_every_mode_but_off_and_taa_only_for_taa() {
433        // Taa keeps the FXAA pass: the temporal resolve does not replace it.
434
435        assert!(!AaMode::Off.taa_enabled());
436        assert!(!AaMode::Fxaa.taa_enabled());
437        assert!(AaMode::Taa.taa_enabled());
438
439        // The shader-side flag is the enabled bit as a float.
440        assert_eq!(AaMode::Off.fxaa_flag(), 0.0);
441        assert_eq!(AaMode::Fxaa.fxaa_flag(), 1.0);
442        assert_eq!(AaMode::Taa.fxaa_flag(), 1.0);
443    }
444
445    #[test]
446    fn half_and_quarter_resolutions_divide_the_target() {
447        assert_eq!(SsgiResolution::Full.scale_divisor(), 1);
448        assert_eq!(SsgiResolution::Half.scale_divisor(), 2);
449        assert_eq!(SsgiResolution::Quarter.scale_divisor(), 4);
450        assert_eq!(ReflectionBlurResolution::Full.scale_divisor(), 1);
451        assert_eq!(ReflectionBlurResolution::Half.scale_divisor(), 2);
452        assert_eq!(ReflectionBlurResolution::Quarter.scale_divisor(), 4);
453    }
454
455    #[test]
456    fn enum_names_parse_in_snake_case() {
457        let aa = |s: &str| serde_json::from_str::<AaMode>(s).unwrap();
458        assert_eq!(aa(r#""off""#), AaMode::Off);
459        assert_eq!(aa(r#""fxaa""#), AaMode::Fxaa);
460        assert_eq!(aa(r#""taa""#), AaMode::Taa);
461
462        let q = |s: &str| serde_json::from_str::<UpscaleQuality>(s).unwrap();
463        assert_eq!(q(r#""balanced""#), UpscaleQuality::Balanced);
464        assert_eq!(
465            q(r#""ultra_performance""#),
466            UpscaleQuality::UltraPerformance
467        );
468        assert_eq!(
469            serde_json::to_string(&UpscaleQuality::UltraPerformance).unwrap(),
470            r#""ultra_performance""#
471        );
472
473        let b = |s: &str| serde_json::from_str::<UpscalerBackend>(s).unwrap();
474        assert_eq!(b(r#""auto""#), UpscalerBackend::Auto);
475        assert_eq!(b(r#""fsr3""#), UpscalerBackend::Fsr3);
476        assert_eq!(b(r#""dlss""#), UpscalerBackend::Dlss);
477        assert_eq!(b(r#""xess""#), UpscalerBackend::Xess);
478
479        assert_eq!(
480            serde_json::from_str::<IndirectLighting>(r#""ssgi""#).unwrap(),
481            IndirectLighting::Ssgi
482        );
483        assert_eq!(
484            serde_json::from_str::<SsgiResolution>(r#""quarter""#).unwrap(),
485            SsgiResolution::Quarter
486        );
487        assert_eq!(
488            serde_json::from_str::<ReflectionBlurResolution>(r#""full""#).unwrap(),
489            ReflectionBlurResolution::Full
490        );
491    }
492
493    #[test]
494    fn an_authored_stack_round_trips_through_postcard() {
495        let c: PostProcessConfig = serde_json::from_str(
496            r#"{"aa_mode":"taa","ssao":true,"ssr":true,"indirect_lighting":"ssgi",
497                "ssgi_resolution":"quarter","temporal_upscaling":true,
498                "upscale_quality":"performance","upscale_backend":"dlss",
499                "auto_exposure":true,"hdr_display":true,"hdr_pq":true}"#,
500        )
501        .unwrap();
502        assert!(c.aa_mode.taa_enabled());
503        assert_eq!(c.ssgi_resolution.scale_divisor(), 4);
504        // Fields the args did not mention keep the schema defaults.
505        assert_eq!(c.bloom_intensity, 0.6);
506
507        let bytes = postcard::to_allocvec(&c).unwrap();
508        let back: PostProcessConfig = postcard::from_bytes(&bytes).unwrap();
509        assert_eq!(back.aa_mode, AaMode::Taa);
510        assert_eq!(back.upscale_backend, UpscalerBackend::Dlss);
511        assert_eq!(back.upscale_quality, UpscaleQuality::Performance);
512        assert_eq!(back.indirect_lighting, IndirectLighting::Ssgi);
513        assert!(back.hdr_pq);
514    }
515}
516
517// `exposure_ev` is clamped to this range before resolving to a multiplier so a
518// stray value cannot push the scene to `inf` / `0`.
519const EXPOSURE_EV_LIMIT: f32 = 16.0;
520
521/// Resolves a `PostProcessConfig`'s authored tunables into the clamped,
522/// GPU-facing settings the renderer consumes. Kept in `gfx` (not the schema)
523/// because every return type is a `crate::gfx` settings struct.
524pub trait PostProcessResolve {
525    /// Resolve the authored fields into the GPU-facing `PostProcessTunables`:
526    /// clamps each tunable and converts `exposure_ev` (stops) into the linear
527    /// multiplier the shaders expect. The composite's display-output flags are
528    /// not authored, so they are absent here: the backend adds them to the full
529    /// `PostProcessParams` once it has negotiated EDR support with the display.
530    fn resolve(&self) -> PostProcessTunables;
531
532    /// Clamp the authored `ambient_intensity` to a safe `[0, 16]` multiplier the
533    /// backend folds into `LightUniforms` to scale the indirect (ambient / IBL)
534    /// term.
535    fn ambient_intensity(&self) -> f32;
536
537    /// Per-axis divisor for the roughness-aware reflection blur target, resolved
538    /// from `reflection_blur_resolution`. Always at least 1.
539    fn reflection_blur_divisor(&self) -> u32;
540
541    /// Resolve the SSAO tunables into clamped `SsaoSettings`, or `None` when the
542    /// `ssao` toggle is off so the backend can skip the SSAO passes entirely.
543    fn ssao_settings(&self) -> Option<crate::gfx::ssao::SsaoSettings>;
544
545    /// Resolve the SSR tunables into clamped `SsrSettings`, or `None` when the
546    /// `ssr` toggle is off.
547    fn ssr_settings(&self) -> Option<crate::gfx::ssr::SsrSettings>;
548
549    /// Resolve the ray-traced-reflection tunables into clamped
550    /// `RtReflectionSettings`, or `None` when `ray_traced_reflections` is off.
551    /// Reuses the SSR intensity / distance fields; the backend additionally gates
552    /// on GPU ray-tracing support.
553    fn rt_reflection_settings(&self) -> Option<crate::gfx::rt_reflections::RtReflectionSettings>;
554
555    /// Resolve the SSGI tunables into clamped `SsgiSettings`, or `None` when
556    /// `indirect_lighting` is not `Ssgi` so the backend can skip the SSGI passes.
557    fn ssgi_settings(&self) -> Option<crate::gfx::ssgi::SsgiSettings>;
558
559    /// Resolve the auto-exposure tunables into clamped `AutoExposureSettings`, or
560    /// `None` when the toggle is off so the backend can skip the histogram passes.
561    fn auto_exposure_settings(&self) -> Option<crate::gfx::auto_exposure::AutoExposureSettings>;
562}
563
564impl PostProcessResolve for PostProcessConfig {
565    fn resolve(&self) -> PostProcessTunables {
566        let ev = self
567            .exposure_ev
568            .clamp(-EXPOSURE_EV_LIMIT, EXPOSURE_EV_LIMIT);
569        PostProcessTunables {
570            bloom_intensity: self.bloom_intensity.max(0.0),
571            bloom_threshold: self.bloom_threshold.max(0.0),
572            bloom_knee: self.bloom_knee.max(0.0),
573            exposure: exp2(ev),
574            vignette: self.vignette_strength.clamp(0.0, 1.0),
575            lut_strength: self.lut_strength.clamp(0.0, 1.0),
576            fxaa: self.aa_mode.fxaa_flag(),
577        }
578    }
579
580    fn ambient_intensity(&self) -> f32 {
581        self.ambient_intensity.clamp(0.0, 16.0)
582    }
583
584    fn reflection_blur_divisor(&self) -> u32 {
585        self.reflection_blur_resolution.scale_divisor()
586    }
587
588    fn ssao_settings(&self) -> Option<crate::gfx::ssao::SsaoSettings> {
589        self.ssao
590            .then(|| crate::gfx::ssao::SsaoSettings::resolve(self.ssao_radius, self.ssao_intensity))
591    }
592
593    fn ssr_settings(&self) -> Option<crate::gfx::ssr::SsrSettings> {
594        self.ssr.then(|| {
595            crate::gfx::ssr::SsrSettings::resolve(self.ssr_intensity, self.ssr_max_distance)
596        })
597    }
598
599    fn rt_reflection_settings(&self) -> Option<crate::gfx::rt_reflections::RtReflectionSettings> {
600        self.ray_traced_reflections.then(|| {
601            crate::gfx::rt_reflections::RtReflectionSettings::resolve(
602                self.ssr_intensity,
603                self.ssr_max_distance,
604            )
605        })
606    }
607
608    fn ssgi_settings(&self) -> Option<crate::gfx::ssgi::SsgiSettings> {
609        (self.indirect_lighting == IndirectLighting::Ssgi).then(|| {
610            crate::gfx::ssgi::SsgiSettings::resolve(
611                self.ssgi_intensity,
612                self.ssgi_max_distance,
613                self.ssgi_rays,
614                self.ssgi_steps,
615                self.ssgi_resolution.scale_divisor(),
616            )
617        })
618    }
619
620    fn auto_exposure_settings(&self) -> Option<crate::gfx::auto_exposure::AutoExposureSettings> {
621        self.auto_exposure.then(|| {
622            // `hdr_display = true` shifts AE's pivot from scene-white
623            // (legacy SDR + ACES) to perceptual middle-grey, so the average
624            // pixel reads as a comfortable mid-tone on a panel that does no
625            // implicit tonemap. Falls back gracefully: even if the platform
626            // rejects the HDR request at swapchain time, SDR + ACES still
627            // produces a sensible (slightly darker) result.
628            crate::gfx::auto_exposure::AutoExposureSettings::resolve(
629                self.auto_exposure_min_ev,
630                self.auto_exposure_max_ev,
631                self.auto_exposure_speed,
632                self.hdr_display,
633            )
634        })
635    }
636}
637
638impl Component for PostProcessConfig {
639    const NAME: &'static str = "PostProcessConfig";
640
641    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
642        Ok(crate::blob::decode_exact(bytes)?)
643    }
644}
645
646#[cfg(test)]
647mod runtime_tests {
648    use super::*;
649    use crate::components::{
650        AaMode, ReflectionBlurResolution, SsgiResolution, UpscaleQuality, UpscalerBackend,
651    };
652    use alloc::format;
653
654    #[test]
655    fn default_resolves_to_neutral_params() {
656        let p = PostProcessConfig::default().resolve();
657        assert_eq!(p.bloom_intensity, 0.6);
658        assert_eq!(p.bloom_threshold, 1.0);
659        assert_eq!(p.bloom_knee, 0.5);
660        // No exposure offset and no vignette out of the box.
661        assert_eq!(p.exposure, 1.0);
662        assert_eq!(p.vignette, 0.0);
663        // Full LUT blend by default: a no-op until a ColorLut is declared.
664        assert_eq!(p.lut_strength, 1.0);
665        // The renderer's no-asset fallback has to resolve to the same thing.
666        assert_eq!(p, PostProcessTunables::DEFAULT);
667    }
668
669    #[test]
670    fn exposure_ev_resolves_to_power_of_two_multiplier() {
671        let cfg = PostProcessConfig {
672            exposure_ev: 2.0,
673            ..Default::default()
674        };
675        assert_eq!(cfg.resolve().exposure, 4.0);
676
677        let cfg = PostProcessConfig {
678            exposure_ev: -1.0,
679            ..Default::default()
680        };
681        assert_eq!(cfg.resolve().exposure, 0.5);
682    }
683
684    #[test]
685    fn exposure_ev_is_clamped_to_a_finite_multiplier() {
686        let cfg = PostProcessConfig {
687            exposure_ev: 1.0e9,
688            ..Default::default()
689        };
690        let exposure = cfg.resolve().exposure;
691        assert!(exposure.is_finite());
692        assert_eq!(exposure, EXPOSURE_EV_LIMIT.exp2());
693    }
694
695    #[test]
696    fn negative_and_overrange_inputs_are_clamped() {
697        let cfg = PostProcessConfig {
698            bloom_intensity: -3.0,
699            bloom_threshold: -1.0,
700            bloom_knee: -0.2,
701            vignette_strength: 5.0,
702            lut_strength: -2.0,
703            ..Default::default()
704        };
705        let p = cfg.resolve();
706        assert_eq!(p.bloom_intensity, 0.0);
707        assert_eq!(p.bloom_threshold, 0.0);
708        assert_eq!(p.bloom_knee, 0.0);
709        assert_eq!(p.vignette, 1.0);
710        assert_eq!(p.lut_strength, 0.0);
711    }
712
713    #[test]
714    fn lut_strength_is_clamped_to_unit_range() {
715        let cfg = PostProcessConfig {
716            lut_strength: 3.0,
717            ..Default::default()
718        };
719        assert_eq!(cfg.resolve().lut_strength, 1.0);
720    }
721
722    #[test]
723    fn aa_mode_defaults_to_fxaa_and_round_trips_through_args() {
724        assert_eq!(PostProcessConfig::default().aa_mode, AaMode::Fxaa);
725        let cfg = PostProcessConfig {
726            aa_mode: AaMode::Taa,
727            ..Default::default()
728        };
729        assert_eq!(cfg.clone().aa_mode, AaMode::Taa);
730    }
731
732    #[test]
733    fn aa_mode_gates_taa_and_fxaa() {
734        assert!(!AaMode::Off.taa_enabled());
735        assert!(!AaMode::Fxaa.taa_enabled());
736        assert!(AaMode::Taa.taa_enabled());
737        // resolve() carries the FXAA gate into the composite uniform.
738        let off = PostProcessConfig {
739            aa_mode: AaMode::Off,
740            ..Default::default()
741        };
742        assert_eq!(off.resolve().fxaa, 0.0);
743        assert_eq!(PostProcessConfig::default().resolve().fxaa, 1.0);
744    }
745
746    #[test]
747    fn ssao_defaults_off_with_neutral_tunables() {
748        let cfg = PostProcessConfig::default();
749        assert!(!cfg.ssao);
750        assert_eq!(cfg.ssao_radius, 0.5);
751        assert_eq!(cfg.ssao_intensity, 1.0);
752        // No SsaoSettings produced while the toggle is off.
753        assert!(cfg.ssao_settings().is_none());
754    }
755
756    #[test]
757    fn ssao_settings_resolve_and_clamp_when_enabled() {
758        let cfg = PostProcessConfig {
759            ssao: true,
760            ssao_radius: -1.0,
761            ssao_intensity: 99.0,
762            ..Default::default()
763        };
764        let s = cfg.ssao_settings().expect("ssao on");
765        assert!(s.radius > 0.0);
766        assert_eq!(s.intensity, 4.0);
767    }
768
769    #[test]
770    fn ssao_deserialises_from_jsonl_args() {
771        let cfg: PostProcessConfig =
772            serde_json::from_str(r#"{"ssao":true,"ssao_radius":0.6}"#).expect("parse");
773        assert!(cfg.ssao);
774        assert_eq!(cfg.ssao_radius, 0.6);
775        // Omitted intensity falls back to the default.
776        assert_eq!(cfg.ssao_intensity, 1.0);
777    }
778
779    #[test]
780    fn ssr_defaults_off_with_neutral_tunables() {
781        let cfg = PostProcessConfig::default();
782        assert!(!cfg.ssr);
783        assert_eq!(cfg.ssr_intensity, 0.7);
784        assert_eq!(cfg.ssr_max_distance, 40.0);
785        // No SsrSettings produced while the toggle is off.
786        assert!(cfg.ssr_settings().is_none());
787    }
788
789    #[test]
790    fn ssr_settings_resolve_and_clamp_when_enabled() {
791        let cfg = PostProcessConfig {
792            ssr: true,
793            ssr_intensity: 9.0,
794            ssr_max_distance: 1.0e6,
795            ..Default::default()
796        };
797        let s = cfg.ssr_settings().expect("ssr on");
798        assert_eq!(s.intensity, 1.0);
799        assert!(s.max_distance > 0.0 && s.max_distance.is_finite());
800    }
801
802    #[test]
803    fn ssr_deserialises_from_jsonl_args() {
804        let cfg: PostProcessConfig =
805            serde_json::from_str(r#"{"ssr":true,"ssr_intensity":0.5}"#).expect("parse");
806        assert!(cfg.ssr);
807        assert_eq!(cfg.ssr_intensity, 0.5);
808        // Omitted distance falls back to the default.
809        assert_eq!(cfg.ssr_max_distance, 40.0);
810    }
811
812    #[test]
813    fn rt_reflections_default_off_and_resolve_to_none() {
814        let cfg = PostProcessConfig::default();
815        assert!(!cfg.ray_traced_reflections);
816        // No RtReflectionSettings produced while the toggle is off.
817        assert!(cfg.rt_reflection_settings().is_none());
818    }
819
820    #[test]
821    fn rt_reflection_settings_reuse_ssr_tunables_when_enabled() {
822        let cfg = PostProcessConfig {
823            ray_traced_reflections: true,
824            ssr_intensity: 9.0,
825            ssr_max_distance: 1.0e6,
826            ..Default::default()
827        };
828        let s = cfg.rt_reflection_settings().expect("rt on");
829        // Reuses the SSR intensity / distance fields, clamped by the RT resolve.
830        assert_eq!(s.intensity, 1.0);
831        assert!(s.max_distance > 0.0 && s.max_distance.is_finite());
832    }
833
834    #[test]
835    fn rt_reflections_deserialise_from_jsonl_args() {
836        let cfg: PostProcessConfig =
837            serde_json::from_str(r#"{"ray_traced_reflections":true,"ssr_intensity":0.5}"#)
838                .expect("parse");
839        assert!(cfg.ray_traced_reflections);
840        assert!(cfg.rt_reflection_settings().is_some());
841        // Omitting the field leaves ray tracing off.
842        let cfg: PostProcessConfig =
843            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
844        assert!(!cfg.ray_traced_reflections);
845        assert!(cfg.rt_reflection_settings().is_none());
846    }
847
848    #[test]
849    fn ambient_intensity_defaults_neutral_and_clamps() {
850        // Default is a no-op multiplier.
851        assert_eq!(PostProcessConfig::default().ambient_intensity(), 1.0);
852        // Authored values clamp into [0, 16].
853        let hot = PostProcessConfig {
854            ambient_intensity: 100.0,
855            ..Default::default()
856        };
857        assert_eq!(hot.ambient_intensity(), 16.0);
858        let neg = PostProcessConfig {
859            ambient_intensity: -2.0,
860            ..Default::default()
861        };
862        assert_eq!(neg.ambient_intensity(), 0.0);
863        // Round-trips through JSONL like any other tunable.
864        let cfg: PostProcessConfig =
865            serde_json::from_str(r#"{"ambient_intensity":3.5}"#).expect("parse");
866        assert_eq!(cfg.ambient_intensity(), 3.5);
867    }
868
869    #[test]
870    fn ssgi_defaults_to_ibl_with_neutral_tunables() {
871        let cfg = PostProcessConfig::default();
872        assert_eq!(cfg.indirect_lighting, IndirectLighting::Ibl);
873        assert_eq!(cfg.ssgi_intensity, 0.5);
874        assert_eq!(cfg.ssgi_max_distance, 8.0);
875        // The gather defaults to half resolution with the historical 8x12
876        // ray/step counts.
877        assert_eq!(cfg.ssgi_resolution, SsgiResolution::Half);
878        assert_eq!(cfg.ssgi_rays, 8);
879        assert_eq!(cfg.ssgi_steps, 12);
880        // No SsgiSettings produced while indirect lighting is IBL-only.
881        assert!(cfg.ssgi_settings().is_none());
882    }
883
884    #[test]
885    fn ssgi_resolution_maps_to_a_per_axis_divisor() {
886        assert_eq!(SsgiResolution::Full.scale_divisor(), 1);
887        assert_eq!(SsgiResolution::Half.scale_divisor(), 2);
888        assert_eq!(SsgiResolution::Quarter.scale_divisor(), 4);
889        assert_eq!(SsgiResolution::default(), SsgiResolution::Half);
890    }
891
892    #[test]
893    fn ssgi_resolution_and_counts_flow_into_settings() {
894        let cfg = PostProcessConfig {
895            indirect_lighting: IndirectLighting::Ssgi,
896            ssgi_resolution: SsgiResolution::Quarter,
897            ssgi_rays: 4,
898            ssgi_steps: 20,
899            ..Default::default()
900        };
901        let s = cfg.ssgi_settings().expect("ssgi on");
902        assert_eq!(s.rays, 4);
903        assert_eq!(s.steps, 20);
904        assert_eq!(s.gi_scale, 4);
905    }
906
907    #[test]
908    fn ssgi_resolution_and_counts_deserialise_from_jsonl_args() {
909        let cfg: PostProcessConfig = serde_json::from_str(
910            r#"{"indirect_lighting":"ssgi","ssgi_resolution":"full","ssgi_rays":16,"ssgi_steps":8}"#,
911        )
912        .expect("parse");
913        assert_eq!(cfg.ssgi_resolution, SsgiResolution::Full);
914        assert_eq!(cfg.ssgi_rays, 16);
915        assert_eq!(cfg.ssgi_steps, 8);
916        // Omitting them falls back to the half-resolution 8x12 defaults.
917        let cfg: PostProcessConfig =
918            serde_json::from_str(r#"{"indirect_lighting":"ssgi"}"#).expect("parse");
919        assert_eq!(cfg.ssgi_resolution, SsgiResolution::Half);
920        assert_eq!(cfg.ssgi_rays, 8);
921        assert_eq!(cfg.ssgi_steps, 12);
922    }
923
924    #[test]
925    fn reflection_blur_resolution_defaults_to_half() {
926        let cfg = PostProcessConfig::default();
927        assert_eq!(
928            cfg.reflection_blur_resolution,
929            ReflectionBlurResolution::Half
930        );
931        assert_eq!(cfg.reflection_blur_divisor(), 2);
932    }
933
934    #[test]
935    fn reflection_blur_resolution_maps_to_a_per_axis_divisor() {
936        assert_eq!(ReflectionBlurResolution::Full.scale_divisor(), 1);
937        assert_eq!(ReflectionBlurResolution::Half.scale_divisor(), 2);
938        assert_eq!(ReflectionBlurResolution::Quarter.scale_divisor(), 4);
939        assert_eq!(
940            ReflectionBlurResolution::default(),
941            ReflectionBlurResolution::Half
942        );
943    }
944
945    #[test]
946    fn reflection_blur_resolution_deserialises_from_jsonl_args() {
947        let cfg: PostProcessConfig =
948            serde_json::from_str(r#"{"ssr":true,"reflection_blur_resolution":"quarter"}"#)
949                .expect("parse");
950        assert_eq!(
951            cfg.reflection_blur_resolution,
952            ReflectionBlurResolution::Quarter
953        );
954        assert_eq!(cfg.reflection_blur_divisor(), 4);
955        // Omitting the field falls back to the half-resolution default.
956        let cfg: PostProcessConfig = serde_json::from_str(r#"{"ssr":true}"#).expect("parse");
957        assert_eq!(
958            cfg.reflection_blur_resolution,
959            ReflectionBlurResolution::Half
960        );
961        assert_eq!(cfg.reflection_blur_divisor(), 2);
962    }
963
964    #[test]
965    fn ssgi_settings_resolve_and_clamp_when_enabled() {
966        let cfg = PostProcessConfig {
967            indirect_lighting: IndirectLighting::Ssgi,
968            ssgi_intensity: 99.0,
969            ssgi_max_distance: 1.0e6,
970            ..Default::default()
971        };
972        let s = cfg.ssgi_settings().expect("ssgi on");
973        assert_eq!(s.intensity, 4.0);
974        assert!(s.max_distance > 0.0 && s.max_distance.is_finite());
975    }
976
977    #[test]
978    fn ssgi_deserialises_from_jsonl_args() {
979        let cfg: PostProcessConfig =
980            serde_json::from_str(r#"{"indirect_lighting":"ssgi","ssgi_intensity":0.8}"#)
981                .expect("parse");
982        assert_eq!(cfg.indirect_lighting, IndirectLighting::Ssgi);
983        assert_eq!(cfg.ssgi_intensity, 0.8);
984        // Omitted distance falls back to the default.
985        assert_eq!(cfg.ssgi_max_distance, 8.0);
986        // Omitting the field leaves indirect lighting on IBL.
987        let cfg: PostProcessConfig =
988            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
989        assert_eq!(cfg.indirect_lighting, IndirectLighting::Ibl);
990        assert!(cfg.ssgi_settings().is_none());
991    }
992
993    #[test]
994    fn auto_exposure_defaults_off_with_neutral_tunables() {
995        let cfg = PostProcessConfig::default();
996        assert!(!cfg.auto_exposure);
997        assert_eq!(cfg.auto_exposure_min_ev, -8.0);
998        assert_eq!(cfg.auto_exposure_max_ev, 8.0);
999        assert_eq!(cfg.auto_exposure_speed, 1.5);
1000        assert!(cfg.auto_exposure_settings().is_none());
1001    }
1002
1003    #[test]
1004    fn auto_exposure_settings_resolve_when_enabled() {
1005        let cfg = PostProcessConfig {
1006            auto_exposure: true,
1007            auto_exposure_min_ev: -4.0,
1008            auto_exposure_max_ev: 6.0,
1009            auto_exposure_speed: 2.0,
1010            ..Default::default()
1011        };
1012        let s = cfg.auto_exposure_settings().expect("auto-exposure on");
1013        assert_eq!(s.min_ev, -4.0);
1014        assert_eq!(s.max_ev, 6.0);
1015        assert_eq!(s.speed, 2.0);
1016    }
1017
1018    #[test]
1019    fn auto_exposure_deserialises_from_jsonl_args() {
1020        let cfg: PostProcessConfig =
1021            serde_json::from_str(r#"{"auto_exposure":true,"auto_exposure_speed":3.0}"#)
1022                .expect("parse");
1023        assert!(cfg.auto_exposure);
1024        assert_eq!(cfg.auto_exposure_speed, 3.0);
1025        // Omitted bounds fall back to the defaults.
1026        assert_eq!(cfg.auto_exposure_min_ev, -8.0);
1027        assert_eq!(cfg.auto_exposure_max_ev, 8.0);
1028    }
1029
1030    #[test]
1031    fn aa_mode_deserialises_from_jsonl_args() {
1032        let cfg: PostProcessConfig = serde_json::from_str(r#"{"aa_mode":"taa"}"#).expect("parse");
1033        assert_eq!(cfg.aa_mode, AaMode::Taa);
1034        // Omitting the field falls back to the FXAA default.
1035        let cfg: PostProcessConfig =
1036            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
1037        assert_eq!(cfg.aa_mode, AaMode::Fxaa);
1038        // "off" disables edge smoothing entirely.
1039        let cfg: PostProcessConfig = serde_json::from_str(r#"{"aa_mode":"off"}"#).expect("parse");
1040        assert_eq!(cfg.aa_mode, AaMode::Off);
1041    }
1042
1043    #[test]
1044    fn hdr_display_defaults_off() {
1045        assert!(!PostProcessConfig::default().hdr_display);
1046    }
1047
1048    #[test]
1049    fn hdr_display_round_trips_through_args_and_jsonl() {
1050        let cfg = PostProcessConfig {
1051            hdr_display: true,
1052            ..Default::default()
1053        };
1054        assert!(cfg.clone().hdr_display);
1055
1056        let cfg: PostProcessConfig =
1057            serde_json::from_str(r#"{"hdr_display":true}"#).expect("parse");
1058        assert!(cfg.hdr_display);
1059    }
1060
1061    #[test]
1062    fn temporal_upscaling_defaults_off_with_quality_preset() {
1063        let cfg = PostProcessConfig::default();
1064        assert!(!cfg.temporal_upscaling);
1065        assert_eq!(cfg.upscale_quality, UpscaleQuality::Quality);
1066    }
1067
1068    #[test]
1069    fn upscale_quality_scales_are_monotonic() {
1070        // Each step down in quality must reduce the per-axis ratio so render
1071        // cost drops monotonically as users dial quality lower.
1072        let q = UpscaleQuality::Quality.scale();
1073        let b = UpscaleQuality::Balanced.scale();
1074        let p = UpscaleQuality::Performance.scale();
1075        let u = UpscaleQuality::UltraPerformance.scale();
1076        assert!(q > b && b > p && p > u);
1077        assert!(u > 0.0);
1078    }
1079
1080    #[test]
1081    fn occlusion_two_pass_defaults_off_and_round_trips() {
1082        assert!(!PostProcessConfig::default().occlusion_two_pass);
1083        let cfg = PostProcessConfig {
1084            occlusion_two_pass: true,
1085            ..Default::default()
1086        };
1087        assert!(cfg.clone().occlusion_two_pass);
1088        // Deserialises from jsonl args; omitting it leaves the feature off.
1089        let cfg: PostProcessConfig =
1090            serde_json::from_str(r#"{"occlusion_two_pass":true}"#).expect("parse");
1091        assert!(cfg.occlusion_two_pass);
1092        let cfg: PostProcessConfig =
1093            serde_json::from_str(r#"{"bloom_intensity":0.5}"#).expect("parse");
1094        assert!(!cfg.occlusion_two_pass);
1095    }
1096
1097    #[test]
1098    fn upscale_backend_defaults_to_auto() {
1099        assert_eq!(
1100            PostProcessConfig::default().upscale_backend,
1101            UpscalerBackend::Auto
1102        );
1103        assert_eq!(UpscalerBackend::default(), UpscalerBackend::Auto);
1104    }
1105
1106    #[test]
1107    fn upscale_backend_round_trips_via_snake_case_json() {
1108        for (s, want) in [
1109            ("auto", UpscalerBackend::Auto),
1110            ("fsr3", UpscalerBackend::Fsr3),
1111            ("dlss", UpscalerBackend::Dlss),
1112            ("xess", UpscalerBackend::Xess),
1113        ] {
1114            let json = format!(r#"{{"temporal_upscaling":true,"upscale_backend":"{s}"}}"#);
1115            let cfg: PostProcessConfig = serde_json::from_str(&json).expect("parse");
1116            assert_eq!(cfg.upscale_backend, want, "for {s}");
1117        }
1118        // Omitting the field falls back to Auto.
1119        let cfg: PostProcessConfig =
1120            serde_json::from_str(r#"{"temporal_upscaling":true}"#).expect("parse");
1121        assert_eq!(cfg.upscale_backend, UpscalerBackend::Auto);
1122    }
1123
1124    #[test]
1125    fn upscale_backend_round_trips_through_args() {
1126        let cfg = PostProcessConfig {
1127            upscale_backend: UpscalerBackend::Xess,
1128            ..Default::default()
1129        };
1130        assert_eq!(cfg.clone().upscale_backend, UpscalerBackend::Xess);
1131    }
1132
1133    #[test]
1134    fn upscale_quality_round_trips_via_snake_case_json() {
1135        let cfg: PostProcessConfig =
1136            serde_json::from_str(r#"{"temporal_upscaling":true,"upscale_quality":"performance"}"#)
1137                .expect("parse");
1138        assert!(cfg.temporal_upscaling);
1139        assert_eq!(cfg.upscale_quality, UpscaleQuality::Performance);
1140        // Omitting the preset falls back to the default.
1141        let cfg: PostProcessConfig =
1142            serde_json::from_str(r#"{"temporal_upscaling":true}"#).expect("parse");
1143        assert_eq!(cfg.upscale_quality, UpscaleQuality::Quality);
1144    }
1145}