Skip to main content

concinnity_core/gfx/
rt_reflections.rs

1//! Hardware ray-traced reflection configuration. Backend-agnostic resolve of the
2//! authored `PostProcessConfig` fields into clamped settings, plus the per-frame
3//! GPU uniform. The acceleration-structure build and the inline ray-trace itself
4//! live in the backend (Metal); this module owns only the parameter math so it
5//! can be unit-tested without a GPU.
6//!
7//! RT reflections replace SSR's screen-space resolve: they reuse the same
8//! authored `ssr_intensity` / `ssr_max_distance` tunables (so a world toggling
9//! from SSR to RT keeps the same look knobs) but trace a real ray against the
10//! scene BVH, so reflected geometry that is off-screen still appears.
11
12use crate::gfx::camera::{camera_to_world, view_ray_scale};
13
14use crate::gfx::render_types::RtParams;
15
16// Upper bound on `intensity`. The kernel mixes the reflection over the base
17// shading by a Fresnel-weighted amount, so a value above 1.0 would just
18// over-brighten grazing edges; 1.0 is full physically-weighted reflection.
19const MAX_INTENSITY: f32 = 1.0;
20
21// Smallest usable ray reach: a ray shorter than this finds nothing.
22const MIN_DISTANCE: f32 = 1.0;
23// Largest ray reach. Unlike SSR's screen-march this is a true world-space
24// `t_max` on the BVH traversal, so it can reach farther than the SSR cap
25// without the per-step cost; still bounded so a stray value can't explode it.
26const MAX_DISTANCE: f32 = 1000.0;
27
28/// Clamped RT-reflection tunables resolved from the authored asset fields. Held
29/// by the backend and turned into a per-frame [`RtParams`] once the camera and
30/// sun are known.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct RtReflectionSettings {
33    /// Reflection blend strength multiplier in `[0, 1]`.
34    pub intensity: f32,
35    /// World-space distance the reflection ray travels before it misses.
36    pub max_distance: f32,
37}
38
39/// Per-frame camera + sun inputs for building the RT-reflection GPU uniform.
40/// `fov_y_radians` / `aspect` give the view-ray scale used to rebuild a
41/// view-space position from the SSR pre-pass G-buffer. `inv_view_rot` is the
42/// view-to-world rotation (the transpose of the view matrix's orthonormal 3x3)
43/// and `cam_pos` the world camera position; together they form the
44/// camera-to-world transform that lifts the reconstructed hit point + normal
45/// into the BVH's world space. `sun_dir` is the world-space unit direction
46/// toward the sun and `sun_color` its radiance; `prefilter_mip_count` is the IBL
47/// cubemap mip count (0 = no IBL) for the miss fallback.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct RtParamsInputs {
50    /// Vertical field of view in radians.
51    pub fov_y_radians: f32,
52    /// Viewport aspect ratio, width over height.
53    pub aspect: f32,
54    /// View-to-world rotation, column-major.
55    pub inv_view_rot: [[f32; 4]; 4],
56    /// World-space camera position.
57    pub cam_pos: [f32; 3],
58    /// World-space unit direction toward the sun.
59    pub sun_dir: [f32; 3],
60    /// Sun radiance, linear RGB.
61    pub sun_color: [f32; 3],
62    /// IBL cubemap mip count; 0 when there is no IBL fallback.
63    pub prefilter_mip_count: f32,
64    /// Rows of the sky's inverse rotation, so a missed ray samples the same
65    /// oriented sky the main pass shows. Identity when the sky does not turn.
66    pub sky_rot: [[f32; 4]; 3],
67}
68
69impl RtReflectionSettings {
70    /// Clamp the authored intensity / distance into a safe range.
71    pub fn resolve(intensity: f32, max_distance: f32) -> Self {
72        Self {
73            intensity: intensity.clamp(0.0, MAX_INTENSITY),
74            max_distance: max_distance.clamp(MIN_DISTANCE, MAX_DISTANCE),
75        }
76    }
77
78    /// Build the per-frame GPU uniform from these settings, the active camera,
79    /// and the sun.
80    pub fn params(&self, inputs: RtParamsInputs) -> RtParams {
81        let RtParamsInputs {
82            fov_y_radians,
83            aspect,
84            inv_view_rot,
85            cam_pos,
86            sun_dir,
87            sun_color,
88            prefilter_mip_count,
89            sky_rot,
90        } = inputs;
91        let inv_view = camera_to_world(inv_view_rot, cam_pos);
92        let (tan_half_fov_y, aspect) = view_ray_scale(fov_y_radians, aspect);
93        RtParams {
94            intensity: self.intensity,
95            max_distance: self.max_distance,
96            tan_half_fov_y,
97            aspect,
98            prefilter_mip_count,
99            _pad0: 0.0,
100            _pad1: 0.0,
101            _pad2: 0.0,
102            cam_pos: [cam_pos[0], cam_pos[1], cam_pos[2], 0.0],
103            sun_dir: [sun_dir[0], sun_dir[1], sun_dir[2], 0.0],
104            sun_color: [sun_color[0], sun_color[1], sun_color[2], 0.0],
105            inv_view,
106            sky_rot,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::gfx::camera::MIN_ASPECT;
115    use crate::gfx::transform::IDENTITY;
116    use crate::sky::SkyOrientation;
117
118    #[test]
119    fn resolve_clamps_intensity_and_distance() {
120        let s = RtReflectionSettings::resolve(5.0, 1.0e6);
121        assert_eq!(s.intensity, MAX_INTENSITY);
122        assert_eq!(s.max_distance, MAX_DISTANCE);
123
124        let s = RtReflectionSettings::resolve(-2.0, -10.0);
125        assert_eq!(s.intensity, 0.0);
126        assert_eq!(s.max_distance, MIN_DISTANCE);
127    }
128
129    #[test]
130    fn resolve_passes_through_in_range_values() {
131        let s = RtReflectionSettings::resolve(0.7, 60.0);
132        assert_eq!(s.intensity, 0.7);
133        assert_eq!(s.max_distance, 60.0);
134    }
135
136    #[test]
137    fn params_carry_camera_and_sun_inputs() {
138        let s = RtReflectionSettings::resolve(0.8, 40.0);
139        let p = s.params(RtParamsInputs {
140            fov_y_radians: core::f32::consts::FRAC_PI_2,
141            aspect: 1.6,
142            inv_view_rot: IDENTITY,
143            cam_pos: [3.0, 4.0, 5.0],
144            sun_dir: [0.0, 1.0, 0.0],
145            sun_color: [1.0, 0.9, 0.8],
146            prefilter_mip_count: 6.0,
147            sky_rot: SkyOrientation::IDENTITY_ROWS,
148        });
149        assert_eq!(p.intensity, 0.8);
150        assert_eq!(p.max_distance, 40.0);
151        // A 90-degree vertical FOV has tan(45 deg) == 1.
152        assert!((p.tan_half_fov_y - 1.0).abs() < 1.0e-5);
153        assert_eq!(p.aspect, 1.6);
154        assert_eq!(p.prefilter_mip_count, 6.0);
155        assert_eq!(p.cam_pos, [3.0, 4.0, 5.0, 0.0]);
156        assert_eq!(p.sun_dir, [0.0, 1.0, 0.0, 0.0]);
157        assert_eq!(p.sun_color, [1.0, 0.9, 0.8, 0.0]);
158    }
159
160    #[test]
161    fn params_assemble_camera_to_world_translation_column() {
162        // inv_view's translation column must be the world camera position so the
163        // reconstructed view-space hit point lifts to the right world point.
164        let s = RtReflectionSettings::resolve(0.8, 40.0);
165        let p = s.params(RtParamsInputs {
166            fov_y_radians: core::f32::consts::FRAC_PI_2,
167            aspect: 1.6,
168            inv_view_rot: IDENTITY,
169            cam_pos: [3.0, 4.0, 5.0],
170            sun_dir: [0.0, 1.0, 0.0],
171            sun_color: [1.0, 1.0, 1.0],
172            prefilter_mip_count: 6.0,
173            sky_rot: SkyOrientation::IDENTITY_ROWS,
174        });
175        assert_eq!(p.inv_view[3], [3.0, 4.0, 5.0, 1.0]);
176        // The rotation columns are untouched.
177        assert_eq!(p.inv_view[0], [1.0, 0.0, 0.0, 0.0]);
178    }
179
180    #[test]
181    fn params_floor_a_degenerate_aspect() {
182        let s = RtReflectionSettings::resolve(0.7, 40.0);
183        let p = s.params(RtParamsInputs {
184            fov_y_radians: core::f32::consts::FRAC_PI_2,
185            aspect: 0.0,
186            inv_view_rot: IDENTITY,
187            cam_pos: [0.0, 0.0, 0.0],
188            sun_dir: [0.0, 1.0, 0.0],
189            sun_color: [1.0, 1.0, 1.0],
190            prefilter_mip_count: 0.0,
191            sky_rot: SkyOrientation::IDENTITY_ROWS,
192        });
193        assert!(p.aspect >= MIN_ASPECT);
194    }
195}