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}