Skip to main content

concinnity_asset/
post_process_config.rs

1// Post-process stack schema.
2
3/// Tunables for the post-process stack. One per world; the first declared
4/// instance wins. With no `PostProcessConfig` present, the defaults below are
5/// used (bloom on at a moderate intensity).
6///
7/// Colour-LUT grading is a separate [ColorLut](#colorlut) asset; `lut_strength`
8/// here is the blend amount applied to whichever [ColorLut](#colorlut) the world
9/// declares.
10///
11/// When `auto_exposure` is on, the scene's average brightness is measured each
12/// frame and exposure adapts toward a balanced mid-tone. The authored
13/// `exposure_ev` then acts as an additive bias (in stops) on top of the adapted
14/// value.
15///
16/// ```rust
17/// # use concinnity_asset::PostProcessConfig;
18/// PostProcessConfig {
19///     bloom_intensity: 0.8,
20///     ..Default::default()
21/// };
22/// ```
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
24#[serde(default)]
25pub struct PostProcessConfig {
26    /// Additive bloom contribution. 0 skips bloom entirely.
27    pub bloom_intensity: f32,
28    /// Brightness threshold for bloom. Pixels brighter than this contribute
29    /// fully; pixels within `bloom_knee` below it ramp in softly.
30    pub bloom_threshold: f32,
31    /// Width of the soft knee just below `bloom_threshold`.
32    pub bloom_knee: f32,
33    /// Exposure offset in photographic stops. Each +1 doubles scene
34    /// brightness before bloom and tonemapping; 0 is neutral.
35    pub exposure_ev: f32,
36    /// Vignette strength in `[0, 1]`. 0 disables the corner darkening.
37    pub vignette_strength: f32,
38    /// Colour-LUT blend in `[0, 1]`. Mixes the graded colour over the ungraded
39    /// one by this amount. Only matters when the world declares a
40    /// [ColorLut](#colorlut); with none, grading is a no-op at any strength.
41    pub lut_strength: f32,
42    /// Anti-aliasing mode. `fxaa` (default) applies a cheap composite-pass edge
43    /// filter; `taa` adds a temporal pass that jitters the projection and
44    /// accumulates detail across frames for the cleanest edges, at the cost of a
45    /// velocity pre-pass and a history buffer; `off` disables edge smoothing.
46    pub aa_mode: AaMode,
47    /// Screen-space ambient occlusion toggle. Darkens creases and contact areas
48    /// where ambient light is occluded.
49    pub ssao: bool,
50    /// How far the ambient-occlusion search reaches for occluders, in world
51    /// units. Larger values pick up broader, softer occlusion.
52    pub ssao_radius: f32,
53    /// Ambient-occlusion strength, clamped to `[0, 4]`. 1.0 is the natural
54    /// amount; higher values exaggerate the contact darkening.
55    pub ssao_intensity: f32,
56    /// Screen-space reflection toggle. Mixes reflected scene colour over glossy
57    /// surfaces (water, polished floors).
58    pub ssr: bool,
59    /// Reflection blend strength, clamped to `[0, 1]`. Scales the
60    /// Fresnel-weighted reflection mixed over the base shading.
61    pub ssr_intensity: f32,
62    /// How far a reflection reaches, in world units. Longer reaches catch more
63    /// distant reflections, more coarsely.
64    pub ssr_max_distance: f32,
65    /// Hardware ray-traced reflection toggle. When the GPU supports ray tracing,
66    /// traces real reflection rays so off-screen geometry still appears, instead
67    /// of the screen-space method. Reuses the `ssr_intensity` /
68    /// `ssr_max_distance` tunables and takes precedence over `ssr`, falling back
69    /// to it where ray tracing isn't available.
70    pub ray_traced_reflections: bool,
71    /// Internal resolution of the roughness-aware reflection blur the SSR /
72    /// ray-traced reflection composite runs. `half` (default) blurs at a
73    /// quarter of the pixels for a large saving and bilinearly upsamples;
74    /// `full` blurs at native resolution; `quarter` is the cheapest. Smooth
75    /// mirror surfaces stay sharp at any setting (the composite keeps the sharp
76    /// reflection for low roughness). Only matters when `ssr` or
77    /// `ray_traced_reflections` is on.
78    pub reflection_blur_resolution: ReflectionBlurResolution,
79    /// Indirect-diffuse lighting source. `ibl` (default) uses the environment
80    /// map's ambient alone. `ssgi` adds a screen-space global-illumination pass
81    /// on top, so nearby lit surfaces bleed colour onto one another; the
82    /// environment ambient still covers the off-screen / sky fallback.
83    pub indirect_lighting: IndirectLighting,
84    /// Multiplier on the indirect (ambient / IBL) lighting term, clamped to
85    /// `[0, 16]`. 1.0 (default) leaves the environment-derived ambient at its
86    /// physical level. Raising it lifts fill light in areas the directional
87    /// light cannot reach (shadowed facades, alleys) without brightening
88    /// directly lit surfaces, which the sun already dominates. Scales the
89    /// diffuse and specular IBL together, so reflections stay consistent with
90    /// the brighter ambient. Useful for high-contrast exterior scenes where a
91    /// strong sun would otherwise crush shadows to black.
92    pub ambient_intensity: f32,
93    /// Indirect-bounce strength, clamped to `[0, 4]`. Scales the gathered
94    /// indirect light added on top of the existing shading; 0 makes it a no-op.
95    /// Only matters when `indirect_lighting` is `ssgi`.
96    pub ssgi_intensity: f32,
97    /// How far the indirect-light gather reaches, in world units. A near-field
98    /// effect, so it defaults well below `ssr_max_distance`. Only matters when
99    /// `indirect_lighting` is `ssgi`.
100    pub ssgi_max_distance: f32,
101    /// Internal resolution of the SSGI gather. `half` (default) trades a little
102    /// sharpness for a large performance saving; `full` is native; `quarter` is
103    /// the cheapest. Only matters when `indirect_lighting` is `ssgi`.
104    pub ssgi_resolution: SsgiResolution,
105    /// Hemisphere rays cast per pixel by the SSGI gather, clamped to `[1, 32]`.
106    /// More rays reduce noise at a higher cost. Only matters when
107    /// `indirect_lighting` is `ssgi`.
108    pub ssgi_rays: u32,
109    /// Ray-march samples per SSGI ray, clamped to `[1, 64]`. More samples catch
110    /// finer occlusion at a higher cost. Only matters when `indirect_lighting`
111    /// is `ssgi`.
112    pub ssgi_steps: u32,
113    /// Auto-exposure toggle. Adapts exposure each frame toward a balanced
114    /// mid-tone. The authored `exposure_ev` then acts as an additive bias in
115    /// stops on top of the adapted value.
116    pub auto_exposure: bool,
117    /// Lower bound on the adapted exposure (EV). The `exposure_ev` bias is
118    /// applied before this clamp.
119    pub auto_exposure_min_ev: f32,
120    /// Upper bound on the adapted exposure (EV).
121    pub auto_exposure_max_ev: f32,
122    /// How quickly exposure chases a new target (per second). Higher converges
123    /// faster but can pump under flickering content; 1-3 is comfortable.
124    pub auto_exposure_speed: f32,
125    /// HDR display output toggle. On a capable display, emits extended-range
126    /// HDR instead of the standard tonemapped output. Falls back to standard
127    /// output when the display or platform doesn't support HDR.
128    pub hdr_display: bool,
129    /// PQ (HDR10) output mode. When true, and `hdr_display` is on, and the
130    /// display has HDR headroom, output is PQ-encoded for HDR10 panels. No
131    /// effect when `hdr_display` is off.
132    pub hdr_pq: bool,
133    /// Temporal upscaling toggle. Renders the 3D scene at a lower resolution
134    /// (set by `upscale_quality`) and reconstructs a full-resolution image,
135    /// trading some sharpness for performance. Replaces TAA while on (the `taa`
136    /// flag is ignored).
137    pub temporal_upscaling: bool,
138    /// Render-scale preset for `temporal_upscaling`; each step progressively
139    /// lowers the internal resolution. No effect when `temporal_upscaling` is
140    /// off.
141    pub upscale_quality: UpscaleQuality,
142    /// Which upscaler backend `temporal_upscaling` uses. `auto` (default) picks
143    /// the best available at runtime (DLSS on NVIDIA RTX, else XeSS, else FSR3);
144    /// `fsr3` / `dlss` / `xess` request a specific one and fall back when it is
145    /// unavailable on the current GPU or build. No effect when
146    /// `temporal_upscaling` is off. DLSS and XeSS are DirectX-only.
147    pub upscale_backend: UpscalerBackend,
148    /// Two-pass occlusion culling toggle. Reduces objects popping in a frame
149    /// late when they're revealed by camera or occluder motion, at the cost of
150    /// extra culling work each frame.
151    pub occlusion_two_pass: bool,
152}
153
154/// Render-scale preset for `PostProcessConfig.temporal_upscaling`. The ratio
155/// applies to both axes (input pixel count = output * ratio per axis), so
156/// `Quality` renders at 4/9 of the output pixel count, `Performance` at 1/4,
157/// and `UltraPerformance` at 1/9.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
159#[serde(rename_all = "snake_case")]
160#[derive(Default)]
161pub enum UpscaleQuality {
162    /// 4/9 of the output pixel count.
163    #[default]
164    Quality,
165    /// Roughly a third of the output pixel count.
166    Balanced,
167    /// A quarter of the output pixel count.
168    Performance,
169    /// A ninth of the output pixel count.
170    UltraPerformance,
171}
172
173impl UpscaleQuality {
174    /// Per-axis input-to-output ratio. The render target's width/height are
175    /// `(output_w * scale(), output_h * scale())`.
176    pub fn scale(self) -> f32 {
177        match self {
178            UpscaleQuality::Quality => 2.0 / 3.0,
179            UpscaleQuality::Balanced => 0.587,
180            UpscaleQuality::Performance => 0.5,
181            UpscaleQuality::UltraPerformance => 1.0 / 3.0,
182        }
183    }
184}
185
186/// Upscaler backend selector for `PostProcessConfig.temporal_upscaling`.
187/// `Auto` resolves at runtime to the best available (DLSS, then XeSS, then
188/// FSR3); the explicit variants request a specific backend and fall back when
189/// it is unavailable. DLSS (NVIDIA NGX) and XeSS (Intel) are DirectX-only;
190/// Metal uses MetalFX and Vulkan has no upscaler yet, so both treat any value
191/// as their native path.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
193#[serde(rename_all = "snake_case")]
194#[derive(Default)]
195pub enum UpscalerBackend {
196    /// Pick the best backend the device offers.
197    #[default]
198    Auto,
199    /// AMD FidelityFX Super Resolution 3.
200    Fsr3,
201    /// NVIDIA DLSS, through NGX.
202    Dlss,
203    /// Intel XeSS.
204    Xess,
205}
206
207/// Anti-aliasing mode for `PostProcessConfig.aa_mode`. `Off` runs no edge
208/// smoothing; `Fxaa` (default) applies the composite's single-frame edge
209/// filter, which is nearly free; `Taa` adds a temporal pass that jitters the
210/// projection and reprojects detail across frames for the cleanest edges, at
211/// the cost of a velocity pre-pass and a per-frame history buffer.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
213#[serde(rename_all = "snake_case")]
214#[derive(Default)]
215pub enum AaMode {
216    /// No edge smoothing.
217    Off,
218    /// Single-frame edge filter in the composite.
219    #[default]
220    Fxaa,
221    /// Temporal anti-aliasing: jittered projection plus a reprojected history.
222    Taa,
223}
224
225impl AaMode {
226    /// Whether the temporal anti-aliasing pass runs. Only the `Taa` mode does;
227    /// it needs the velocity pre-pass and the history buffer the other modes
228    /// skip.
229    pub fn taa_enabled(self) -> bool {
230        matches!(self, AaMode::Taa)
231    }
232
233    // Whether the composite's FXAA edge filter runs. Every mode except `Off`
234    // does (so `Taa` keeps FXAA as a cheap spatial cleanup on top of the
235    // temporal resolve).
236    fn fxaa_enabled(self) -> bool {
237        !matches!(self, AaMode::Off)
238    }
239
240    /// The composite's FXAA gate as the `0.0` / `1.0` flag `PostProcessParams`
241    /// carries to the shader.
242    pub fn fxaa_flag(self) -> f32 {
243        if self.fxaa_enabled() { 1.0 } else { 0.0 }
244    }
245}
246
247/// Indirect-diffuse lighting source for `PostProcessConfig.indirect_lighting`.
248/// `Ibl` is the image-based-lighting-only ambient term the renderer has always
249/// used; `Ssgi` layers a screen-space global-illumination bounce on top.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
251#[serde(rename_all = "snake_case")]
252#[derive(Default)]
253pub enum IndirectLighting {
254    /// Image-based lighting only.
255    #[default]
256    Ibl,
257    /// Image-based lighting plus a screen-space bounce.
258    Ssgi,
259}
260
261/// Internal render resolution of the SSGI gather pass (only meaningful when
262/// `indirect_lighting` is `ssgi`). The gather is the expensive part (a
263/// hemisphere ray-march per pixel), and its composite is a depth-aware
264/// bilateral filter that upsamples a lower-resolution gather back to full
265/// resolution at little visible cost. `half` (the default) gathers at a quarter
266/// of the pixels for a large saving; `full` keeps the gather at native
267/// resolution; `quarter` is the cheapest, for low-end GPUs or debugging.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
269#[serde(rename_all = "snake_case")]
270#[derive(Default)]
271pub enum SsgiResolution {
272    /// Gather at native resolution.
273    Full,
274    /// Gather at half resolution per axis.
275    #[default]
276    Half,
277    /// Gather at quarter resolution per axis.
278    Quarter,
279}
280
281impl SsgiResolution {
282    /// Per-axis render-resolution divisor the gather target is scaled by.
283    pub fn scale_divisor(self) -> u32 {
284        match self {
285            SsgiResolution::Full => 1,
286            SsgiResolution::Half => 2,
287            SsgiResolution::Quarter => 4,
288        }
289    }
290}
291
292/// Internal render resolution of the roughness-aware reflection blur (only
293/// meaningful when `ssr` or `ray_traced_reflections` is on). The blur is the
294/// expensive multi-tap part of the reflection composite and is low-frequency
295/// (a widening glossy cone), so running it at a fraction of the pixels and
296/// bilinearly upsampling is visually free. `half` (the default) blurs at a
297/// quarter of the pixels; `full` keeps it at native resolution; `quarter` is
298/// the cheapest. Mirrors stay sharp regardless: the composite lerps in the
299/// full-resolution reflection for low roughness.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301#[serde(rename_all = "snake_case")]
302#[derive(Default)]
303pub enum ReflectionBlurResolution {
304    /// Blur at native resolution.
305    Full,
306    /// Blur at half resolution per axis.
307    #[default]
308    Half,
309    /// Blur at quarter resolution per axis.
310    Quarter,
311}
312
313impl ReflectionBlurResolution {
314    /// Per-axis render-resolution divisor the reflection blur target is scaled
315    /// by.
316    pub fn scale_divisor(self) -> u32 {
317        match self {
318            ReflectionBlurResolution::Full => 1,
319            ReflectionBlurResolution::Half => 2,
320            ReflectionBlurResolution::Quarter => 4,
321        }
322    }
323}
324
325/// Default SSGI hemisphere-ray and ray-march-step counts for the authored
326/// `ssgi_rays` / `ssgi_steps` fields. Defined here (the schema default) and
327/// re-exported by `concinnity-core`' `gfx::ssgi` for its runtime clamp path, so
328/// the authored default and the runtime code stay a single source of truth.
329pub const DEFAULT_SSGI_RAYS: u32 = 8;
330/// Default ray-march steps per SSGI ray. See [`DEFAULT_SSGI_RAYS`].
331pub const DEFAULT_SSGI_STEPS: u32 = 12;
332
333impl Default for PostProcessConfig {
334    fn default() -> Self {
335        Self {
336            bloom_intensity: 0.6,
337            bloom_threshold: 1.0,
338            bloom_knee: 0.5,
339            exposure_ev: 0.0,
340            vignette_strength: 0.0,
341            lut_strength: 1.0,
342            aa_mode: AaMode::Fxaa,
343            ssao: false,
344            ssao_radius: 0.5,
345            ssao_intensity: 1.0,
346            ssr: false,
347            ssr_intensity: 0.7,
348            ssr_max_distance: 40.0,
349            ray_traced_reflections: false,
350            reflection_blur_resolution: ReflectionBlurResolution::default(),
351            indirect_lighting: IndirectLighting::Ibl,
352            ambient_intensity: 1.0,
353            ssgi_intensity: 0.5,
354            ssgi_max_distance: 8.0,
355            ssgi_resolution: SsgiResolution::default(),
356            ssgi_rays: DEFAULT_SSGI_RAYS,
357            ssgi_steps: DEFAULT_SSGI_STEPS,
358            auto_exposure: false,
359            auto_exposure_min_ev: -8.0,
360            auto_exposure_max_ev: 8.0,
361            auto_exposure_speed: 1.5,
362            hdr_display: false,
363            hdr_pq: false,
364            temporal_upscaling: false,
365            upscale_quality: UpscaleQuality::default(),
366            upscale_backend: UpscalerBackend::default(),
367            occlusion_two_pass: false,
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn defaults_leave_the_expensive_effects_off() {
378        // Bloom and FXAA are cheap enough to ship on; everything that costs a
379        // full-screen pass is opt-in so a blank world runs on any hardware.
380        let c = PostProcessConfig::default();
381        assert_eq!(c.aa_mode, AaMode::Fxaa);
382        assert_eq!(c.bloom_intensity, 0.6);
383        assert!(!c.ssao);
384        assert!(!c.ssr);
385        assert!(!c.ray_traced_reflections);
386        assert!(!c.auto_exposure);
387        assert!(!c.temporal_upscaling);
388        assert!(!c.hdr_display);
389        assert!(!c.occlusion_two_pass);
390        assert_eq!(c.indirect_lighting, IndirectLighting::Ibl);
391        assert_eq!(c.ssgi_rays, DEFAULT_SSGI_RAYS);
392        assert_eq!(c.ssgi_steps, DEFAULT_SSGI_STEPS);
393    }
394
395    #[test]
396    fn every_enum_default_matches_the_config_default() {
397        let c = PostProcessConfig::default();
398        assert_eq!(c.upscale_quality, UpscaleQuality::Quality);
399        assert_eq!(c.upscale_backend, UpscalerBackend::Auto);
400        assert_eq!(c.ssgi_resolution, SsgiResolution::Half);
401        assert_eq!(c.reflection_blur_resolution, ReflectionBlurResolution::Half);
402        assert_eq!(AaMode::default(), AaMode::Fxaa);
403        assert_eq!(IndirectLighting::default(), IndirectLighting::Ibl);
404    }
405
406    #[test]
407    fn upscale_quality_scales_the_render_resolution_down() {
408        // Ordered coarsest-last: each tier renders strictly fewer pixels.
409        assert_eq!(UpscaleQuality::Quality.scale(), 2.0 / 3.0);
410        assert_eq!(UpscaleQuality::Balanced.scale(), 0.587);
411        assert_eq!(UpscaleQuality::Performance.scale(), 0.5);
412        assert_eq!(UpscaleQuality::UltraPerformance.scale(), 1.0 / 3.0);
413        let tiers = [
414            UpscaleQuality::Quality,
415            UpscaleQuality::Balanced,
416            UpscaleQuality::Performance,
417            UpscaleQuality::UltraPerformance,
418        ];
419        assert!(tiers.windows(2).all(|w| w[0].scale() > w[1].scale()));
420    }
421
422    #[test]
423    fn fxaa_runs_for_every_mode_but_off_and_taa_only_for_taa() {
424        // Taa keeps the FXAA pass: the temporal resolve does not replace it.
425
426        assert!(!AaMode::Off.taa_enabled());
427        assert!(!AaMode::Fxaa.taa_enabled());
428        assert!(AaMode::Taa.taa_enabled());
429
430        // The shader-side flag is the enabled bit as a float.
431        assert_eq!(AaMode::Off.fxaa_flag(), 0.0);
432        assert_eq!(AaMode::Fxaa.fxaa_flag(), 1.0);
433        assert_eq!(AaMode::Taa.fxaa_flag(), 1.0);
434    }
435
436    #[test]
437    fn half_and_quarter_resolutions_divide_the_target() {
438        assert_eq!(SsgiResolution::Full.scale_divisor(), 1);
439        assert_eq!(SsgiResolution::Half.scale_divisor(), 2);
440        assert_eq!(SsgiResolution::Quarter.scale_divisor(), 4);
441        assert_eq!(ReflectionBlurResolution::Full.scale_divisor(), 1);
442        assert_eq!(ReflectionBlurResolution::Half.scale_divisor(), 2);
443        assert_eq!(ReflectionBlurResolution::Quarter.scale_divisor(), 4);
444    }
445
446    #[test]
447    fn enum_names_parse_in_snake_case() {
448        let aa = |s: &str| serde_json::from_str::<AaMode>(s).unwrap();
449        assert_eq!(aa(r#""off""#), AaMode::Off);
450        assert_eq!(aa(r#""fxaa""#), AaMode::Fxaa);
451        assert_eq!(aa(r#""taa""#), AaMode::Taa);
452
453        let q = |s: &str| serde_json::from_str::<UpscaleQuality>(s).unwrap();
454        assert_eq!(q(r#""balanced""#), UpscaleQuality::Balanced);
455        assert_eq!(
456            q(r#""ultra_performance""#),
457            UpscaleQuality::UltraPerformance
458        );
459        assert_eq!(
460            serde_json::to_string(&UpscaleQuality::UltraPerformance).unwrap(),
461            r#""ultra_performance""#
462        );
463
464        let b = |s: &str| serde_json::from_str::<UpscalerBackend>(s).unwrap();
465        assert_eq!(b(r#""auto""#), UpscalerBackend::Auto);
466        assert_eq!(b(r#""fsr3""#), UpscalerBackend::Fsr3);
467        assert_eq!(b(r#""dlss""#), UpscalerBackend::Dlss);
468        assert_eq!(b(r#""xess""#), UpscalerBackend::Xess);
469
470        assert_eq!(
471            serde_json::from_str::<IndirectLighting>(r#""ssgi""#).unwrap(),
472            IndirectLighting::Ssgi
473        );
474        assert_eq!(
475            serde_json::from_str::<SsgiResolution>(r#""quarter""#).unwrap(),
476            SsgiResolution::Quarter
477        );
478        assert_eq!(
479            serde_json::from_str::<ReflectionBlurResolution>(r#""full""#).unwrap(),
480            ReflectionBlurResolution::Full
481        );
482    }
483
484    #[test]
485    fn an_authored_stack_round_trips_through_postcard() {
486        let c: PostProcessConfig = serde_json::from_str(
487            r#"{"aa_mode":"taa","ssao":true,"ssr":true,"indirect_lighting":"ssgi",
488                "ssgi_resolution":"quarter","temporal_upscaling":true,
489                "upscale_quality":"performance","upscale_backend":"dlss",
490                "auto_exposure":true,"hdr_display":true,"hdr_pq":true}"#,
491        )
492        .unwrap();
493        assert!(c.aa_mode.taa_enabled());
494        assert_eq!(c.ssgi_resolution.scale_divisor(), 4);
495        // Fields the args did not mention keep the schema defaults.
496        assert_eq!(c.bloom_intensity, 0.6);
497
498        let bytes = postcard::to_allocvec(&c).unwrap();
499        let back: PostProcessConfig = postcard::from_bytes(&bytes).unwrap();
500        assert_eq!(back.aa_mode, AaMode::Taa);
501        assert_eq!(back.upscale_backend, UpscalerBackend::Dlss);
502        assert_eq!(back.upscale_quality, UpscaleQuality::Performance);
503        assert_eq!(back.indirect_lighting, IndirectLighting::Ssgi);
504        assert!(back.hdr_pq);
505    }
506}