concinnity_core/gfx/ssr.rs
1//! Screen-space reflection (SSR) configuration. Backend-agnostic resolve of the
2//! authored `PostProcessConfig` SSR fields into clamped settings, plus the
3//! per-frame GPU uniform. The screen-space ray-march itself lives in each
4//! backend's shader; this module owns only the parameter math so it can be
5//! unit-tested without a GPU.
6
7use crate::gfx::camera::{camera_to_world, view_ray_scale};
8
9use crate::gfx::render_types::SsrParams;
10
11// Upper bound on `intensity`. The resolve pass mixes the reflection over the
12// base shading by a Fresnel-weighted amount, so a value above 1.0 would just
13// over-brighten grazing edges; 1.0 is full physically-weighted reflection.
14const MAX_INTENSITY: f32 = 1.0;
15
16// Smallest usable march distance: a ray shorter than this finds nothing.
17const MIN_DISTANCE: f32 = 1.0;
18// Largest march distance. Caps the per-pixel work; beyond this the reflection
19// would be too unreliable (and too expensive) to be worth marching.
20const MAX_DISTANCE: f32 = 200.0;
21
22// Number of ray-march samples the resolve shader takes. The step length is
23// `max_distance / MARCH_STEPS`, so a longer ray spends a longer stride rather
24// than more samples. Must match `SSR_MAX_STEPS` in the SSR resolve MSL.
25const MARCH_STEPS: f32 = 48.0;
26
27// View-space intersection tolerance as a multiple of the march stride. A ray
28// point is a hit when it lands behind the scene surface by less than this:
29// wide enough to catch a crossing between two samples, tight enough not to
30// punch through thin geometry.
31const THICKNESS_SCALE: f32 = 2.5;
32
33/// Canonical roughness cut for sharp reflections: surfaces rougher than this get
34/// no screen-space / ray-traced reflection. One value drives four shaders that
35/// must agree for the reflection pipeline to be self-consistent:
36///
37/// - the SSR resolve gate (ssr.metal)
38/// - the RT-reflection resolve gate (rt_reflections.slang)
39/// - the roughness blur ramp (reflection_composite.metal)
40/// - the forward double-count fade (`REFL_RESOLVE_CUT` in main.metal)
41///
42/// All four shaders are compiled offline, so each declares the literal itself
43/// and a unit test locks every declaration to this value (the engine shaders in
44/// reflection_shaders_lock_shared_roughness_cut, main.metal alongside this
45/// module). As an MSL `constant` it folds at compile time: sharing it costs
46/// nothing at runtime.
47pub const REFLECTION_ROUGHNESS_CUT: f32 = 0.6;
48
49/// Clamped SSR tunables resolved from the authored asset fields. Held by the
50/// backend and turned into a per-frame [`SsrParams`] once the camera is known.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct SsrSettings {
53 /// Reflection blend strength multiplier in `[0, 1]`.
54 pub intensity: f32,
55 /// World-space distance the reflection ray marches before giving up.
56 pub max_distance: f32,
57}
58
59impl SsrSettings {
60 /// Clamp the authored intensity / distance into a safe range.
61 pub fn resolve(intensity: f32, max_distance: f32) -> Self {
62 Self {
63 intensity: intensity.clamp(0.0, MAX_INTENSITY),
64 max_distance: max_distance.clamp(MIN_DISTANCE, MAX_DISTANCE),
65 }
66 }
67
68 /// Build the per-frame GPU uniform from these settings and the active
69 /// camera. `fov_y_radians` is the vertical field of view and `aspect` the
70 /// viewport width / height ratio: together they give the view-ray scale
71 /// the resolve pass needs to project a view-space ray point to a UV.
72 /// `inv_view_rot` is the view-space to world-space rotation and `cam_pos` the
73 /// world camera position (together the rigid camera-to-world transform), and
74 /// `prefilter_mip_count` the IBL prefilter cubemap mip count (0 = no IBL); the
75 /// resolve uses these to sample the cubemap (or a reflection probe) as a
76 /// reflection fallback.
77 pub fn params(
78 &self,
79 fov_y_radians: f32,
80 aspect: f32,
81 inv_view_rot: [[f32; 4]; 4],
82 cam_pos: [f32; 3],
83 prefilter_mip_count: f32,
84 ) -> SsrParams {
85 let stride = self.max_distance / MARCH_STEPS;
86 // The resolve rebuilds the world-space surface position the reflection
87 // probe box-projects against, so it needs the full camera-to-world.
88 let inv_view = camera_to_world(inv_view_rot, cam_pos);
89 let (tan_half_fov_y, aspect) = view_ray_scale(fov_y_radians, aspect);
90 SsrParams {
91 intensity: self.intensity,
92 max_distance: self.max_distance,
93 tan_half_fov_y,
94 aspect,
95 stride,
96 thickness: stride * THICKNESS_SCALE,
97 prefilter_mip_count,
98 _pad: 0.0,
99 inv_view,
100 }
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::gfx::camera::MIN_ASPECT;
108 use crate::gfx::transform::IDENTITY;
109
110 #[test]
111 fn resolve_clamps_intensity_and_distance() {
112 let s = SsrSettings::resolve(5.0, 1.0e6);
113 assert_eq!(s.intensity, MAX_INTENSITY);
114 assert_eq!(s.max_distance, MAX_DISTANCE);
115
116 let s = SsrSettings::resolve(-2.0, -10.0);
117 assert_eq!(s.intensity, 0.0);
118 assert_eq!(s.max_distance, MIN_DISTANCE);
119 }
120
121 #[test]
122 fn resolve_passes_through_in_range_values() {
123 let s = SsrSettings::resolve(0.7, 40.0);
124 assert_eq!(s.intensity, 0.7);
125 assert_eq!(s.max_distance, 40.0);
126 }
127
128 #[test]
129 fn params_derive_stride_and_thickness_from_distance() {
130 let s = SsrSettings::resolve(0.7, 48.0);
131 let p = s.params(core::f32::consts::FRAC_PI_2, 1.6, IDENTITY, [0.0; 3], 6.0);
132 // 48 units over 48 steps -> a 1-unit stride.
133 assert!((p.stride - 1.0).abs() < 1.0e-5);
134 assert!((p.thickness - THICKNESS_SCALE).abs() < 1.0e-5);
135 // A 90-degree vertical FOV has tan(45 deg) == 1.
136 assert!((p.tan_half_fov_y - 1.0).abs() < 1.0e-5);
137 assert_eq!(p.aspect, 1.6);
138 }
139
140 #[test]
141 fn params_floor_a_degenerate_aspect() {
142 let s = SsrSettings::resolve(0.7, 40.0);
143 let p = s.params(core::f32::consts::FRAC_PI_2, 0.0, IDENTITY, [0.0; 3], 0.0);
144 assert!(p.aspect >= MIN_ASPECT);
145 }
146
147 #[test]
148 fn params_pass_through_ibl_fallback_inputs() {
149 let s = SsrSettings::resolve(0.7, 40.0);
150 let p = s.params(core::f32::consts::FRAC_PI_2, 1.6, IDENTITY, [0.0; 3], 7.0);
151 assert_eq!(p.prefilter_mip_count, 7.0);
152 // Identity rotation + a zero camera position leaves the matrix identity.
153 assert_eq!(p.inv_view, IDENTITY);
154 }
155
156 #[test]
157 fn params_assemble_camera_to_world_translation_column() {
158 // inv_view's translation column must be the world camera position so the
159 // resolve can lift a reconstructed view-space surface point to the right
160 // world point for reflection-probe box projection.
161 let s = SsrSettings::resolve(0.7, 40.0);
162 let p = s.params(
163 core::f32::consts::FRAC_PI_2,
164 1.6,
165 IDENTITY,
166 [3.0, 4.0, 5.0],
167 6.0,
168 );
169 assert_eq!(p.inv_view[3], [3.0, 4.0, 5.0, 1.0]);
170 // The rotation columns are untouched.
171 assert_eq!(p.inv_view[0], [1.0, 0.0, 0.0, 0.0]);
172 }
173}