Skip to main content

concinnity_core/gfx/
ssgi.rs

1//! Screen-space global illumination (SSGI) configuration. Backend-agnostic
2//! resolve of the authored `PostProcessConfig` SSGI fields into clamped
3//! settings, plus the per-frame GPU uniform. SSGI is a refinement of SSR: it
4//! reuses the same depth + normal pre-pass G-buffer and screen-space ray-march,
5//! but integrates bounced radiance over a cosine-weighted hemisphere instead of
6//! along a single reflection vector, and adds the result on top of the IBL
7//! ambient term. The hemisphere gather itself lives in each backend's shader;
8//! this module owns only the parameter math so it can be unit-tested without a
9//! GPU.
10
11use crate::gfx::camera::view_ray_scale;
12
13use crate::gfx::render_types::SsgiParams;
14
15// Upper bound on `intensity`. The composite pass adds the gathered indirect
16// radiance on top of the existing shading, so this is an additive multiplier
17// rather than a `[0, 1]` blend: values above 1 exaggerate the bounce.
18const MAX_INTENSITY: f32 = 4.0;
19
20// Smallest usable march distance: a ray shorter than this finds nothing.
21const MIN_DISTANCE: f32 = 0.5;
22// Largest march distance. SSGI is a near-field effect (the far field is the
23// IBL term's job), so the reach is capped well below SSR's.
24const MAX_DISTANCE: f32 = 100.0;
25
26// Hemisphere rays cast per pixel, clamped to a sane range. More rays trade
27// performance for a smoother, less noisy gather. The default is the authored
28// `PostProcessConfig.ssgi_rays` default, owned by the schema crate and
29// re-exported here so the authored default and the runtime clamp path stay a
30// single source of truth.
31#[cfg(test)]
32pub(crate) const DEFAULT_RAYS: u32 = concinnity_asset::DEFAULT_SSGI_RAYS;
33const MIN_RAYS: u32 = 1;
34const MAX_RAYS: u32 = 32;
35
36// Ray-march samples taken per ray. The step length is `max_distance / steps`,
37// so a longer ray spends a longer stride rather than more samples. The default
38// is the authored `PostProcessConfig.ssgi_steps` default, owned by the schema
39// crate and re-exported here.
40#[cfg(test)]
41pub(crate) const DEFAULT_STEPS: u32 = concinnity_asset::DEFAULT_SSGI_STEPS;
42const MIN_STEPS: u32 = 1;
43const MAX_STEPS: u32 = 64;
44
45// View-space intersection tolerance as a multiple of the march stride. A ray
46// point is a hit when it lands behind the scene surface by less than this:
47// wide enough to catch a crossing between two samples, tight enough not to
48// punch through thin geometry.
49const THICKNESS_SCALE: f32 = 2.0;
50
51/// Clamped SSGI tunables resolved from the authored asset fields. Held by the
52/// backend and turned into a per-frame [`SsgiParams`] once the camera is known.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct SsgiSettings {
55    /// Indirect-bounce blend strength multiplier in `[0, MAX_INTENSITY]`.
56    pub intensity: f32,
57    /// World-space distance a hemisphere ray marches before giving up.
58    pub max_distance: f32,
59    /// Hemisphere rays cast per pixel, clamped to `[MIN_RAYS, MAX_RAYS]`.
60    pub rays: u32,
61    /// Ray-march samples per ray, clamped to `[MIN_STEPS, MAX_STEPS]`.
62    pub steps: u32,
63    /// Render-resolution divisor for the gather target: 1 is full resolution,
64    /// 2 is half (a quarter of the pixels), 4 a quarter. The composite pass is a
65    /// depth-aware bilateral filter, so it upsamples the lower-resolution gather
66    /// back to full resolution for free. Backends that always allocate the
67    /// gather at full resolution treat this as 1.
68    pub gi_scale: u32,
69}
70
71impl SsgiSettings {
72    /// Clamp the authored tunables into safe ranges.
73    pub fn resolve(
74        intensity: f32,
75        max_distance: f32,
76        rays: u32,
77        steps: u32,
78        gi_scale: u32,
79    ) -> Self {
80        Self {
81            intensity: intensity.clamp(0.0, MAX_INTENSITY),
82            max_distance: max_distance.clamp(MIN_DISTANCE, MAX_DISTANCE),
83            rays: rays.clamp(MIN_RAYS, MAX_RAYS),
84            steps: steps.clamp(MIN_STEPS, MAX_STEPS),
85            gi_scale: gi_scale.max(1),
86        }
87    }
88
89    /// Gather-target dimensions for a given render resolution: the render size
90    /// divided by `gi_scale`, never below 1x1.
91    pub fn gi_dimensions(&self, render_w: u32, render_h: u32) -> (u32, u32) {
92        (
93            (render_w / self.gi_scale).max(1),
94            (render_h / self.gi_scale).max(1),
95        )
96    }
97
98    /// Build the per-frame GPU uniform from these settings and the active
99    /// camera. `fov_y_radians` is the vertical field of view and `aspect` the
100    /// viewport width / height ratio: together they give the view-ray scale
101    /// the gather pass needs to project a view-space ray point to a UV.
102    pub fn params(&self, fov_y_radians: f32, aspect: f32) -> SsgiParams {
103        let stride = self.max_distance / self.steps as f32;
104        let (tan_half_fov_y, aspect) = view_ray_scale(fov_y_radians, aspect);
105        SsgiParams {
106            intensity: self.intensity,
107            max_distance: self.max_distance,
108            tan_half_fov_y,
109            aspect,
110            stride,
111            thickness: stride * THICKNESS_SCALE,
112            rays: self.rays as f32,
113            steps: self.steps as f32,
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::gfx::camera::MIN_ASPECT;
122
123    #[test]
124    fn resolve_clamps_intensity_and_distance() {
125        let s = SsgiSettings::resolve(9.0, 1.0e6, DEFAULT_RAYS, DEFAULT_STEPS, 1);
126        assert_eq!(s.intensity, MAX_INTENSITY);
127        assert_eq!(s.max_distance, MAX_DISTANCE);
128
129        let s = SsgiSettings::resolve(-2.0, -10.0, DEFAULT_RAYS, DEFAULT_STEPS, 1);
130        assert_eq!(s.intensity, 0.0);
131        assert_eq!(s.max_distance, MIN_DISTANCE);
132    }
133
134    #[test]
135    fn resolve_passes_through_in_range_values() {
136        let s = SsgiSettings::resolve(0.6, 8.0, DEFAULT_RAYS, DEFAULT_STEPS, 2);
137        assert_eq!(s.intensity, 0.6);
138        assert_eq!(s.max_distance, 8.0);
139        assert_eq!(s.rays, DEFAULT_RAYS);
140        assert_eq!(s.steps, DEFAULT_STEPS);
141        assert_eq!(s.gi_scale, 2);
142    }
143
144    #[test]
145    fn resolve_clamps_rays_steps_and_scale() {
146        // Over-range rays / steps clamp to their maxima; a zero scale floors to
147        // full resolution (1).
148        let s = SsgiSettings::resolve(0.6, 8.0, 9999, 9999, 0);
149        assert_eq!(s.rays, MAX_RAYS);
150        assert_eq!(s.steps, MAX_STEPS);
151        assert_eq!(s.gi_scale, 1);
152        // Under-range rays / steps clamp to their minima.
153        let s = SsgiSettings::resolve(0.6, 8.0, 0, 0, 4);
154        assert_eq!(s.rays, MIN_RAYS);
155        assert_eq!(s.steps, MIN_STEPS);
156        assert_eq!(s.gi_scale, 4);
157    }
158
159    #[test]
160    fn gi_dimensions_divide_by_scale_and_floor_at_one() {
161        let full = SsgiSettings::resolve(0.6, 8.0, DEFAULT_RAYS, DEFAULT_STEPS, 1);
162        assert_eq!(full.gi_dimensions(1920, 1080), (1920, 1080));
163        let half = SsgiSettings::resolve(0.6, 8.0, DEFAULT_RAYS, DEFAULT_STEPS, 2);
164        assert_eq!(half.gi_dimensions(1920, 1080), (960, 540));
165        // A tiny render target never collapses below 1x1.
166        assert_eq!(half.gi_dimensions(1, 1), (1, 1));
167    }
168
169    #[test]
170    fn params_derive_stride_and_thickness_from_configured_steps() {
171        // 12 units over the default 12 steps -> a 1-unit stride.
172        let s = SsgiSettings::resolve(0.6, 12.0, DEFAULT_RAYS, DEFAULT_STEPS, 1);
173        let p = s.params(core::f32::consts::FRAC_PI_2, 1.6);
174        assert!((p.stride - 1.0).abs() < 1.0e-5);
175        assert!((p.thickness - THICKNESS_SCALE).abs() < 1.0e-5);
176        // A 90-degree vertical FOV has tan(45 deg) == 1.
177        assert!((p.tan_half_fov_y - 1.0).abs() < 1.0e-5);
178        assert_eq!(p.aspect, 1.6);
179        // The ray / step counts ride along in the uniform for the shader loops.
180        assert_eq!(p.rays, DEFAULT_RAYS as f32);
181        assert_eq!(p.steps, DEFAULT_STEPS as f32);
182
183        // Halving the step count doubles the stride (same reach, fewer samples).
184        let s = SsgiSettings::resolve(0.6, 12.0, DEFAULT_RAYS, 6, 1);
185        let p = s.params(core::f32::consts::FRAC_PI_2, 1.6);
186        assert!((p.stride - 2.0).abs() < 1.0e-5);
187    }
188
189    #[test]
190    fn params_floor_a_degenerate_aspect() {
191        let s = SsgiSettings::resolve(0.6, 8.0, DEFAULT_RAYS, DEFAULT_STEPS, 1);
192        let p = s.params(core::f32::consts::FRAC_PI_2, 0.0);
193        assert!(p.aspect >= MIN_ASPECT);
194    }
195}