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