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}