Skip to main content

proof_engine/render/postfx/
distortion.rs

1//! Screen-space distortion pass — gravitational lensing, heat shimmer, entropy warping.
2//!
3//! Force fields write distortion vectors into a CPU-side distortion map each frame.
4//! The distortion pass samples the scene at UV + distortion_offset, creating:
5//!   - Gravity wells: circular lensing toward field centers
6//!   - Heat shimmer: Perlin-noise-driven wavy distortion
7//!   - Chaos Rift: high-entropy chaotic UV scrambling
8//!   - Vortex: rotating UV distortion following Vortex force fields
9
10use glam::Vec2;
11
12/// Distortion pass parameters.
13#[derive(Clone, Debug)]
14pub struct DistortionParams {
15    pub enabled: bool,
16    /// Global distortion scale multiplier (1.0 = normal, 0.0 = disabled).
17    pub scale: f32,
18    /// Chromatic splitting of distorted UV offsets (0.0 = mono, 1.0 = full RGB split).
19    pub chromatic_split: f32,
20    /// Maximum distortion offset in UV units (clamp for safety).
21    pub max_offset: f32,
22    /// Time scale for animated distortions (1.0 = realtime, 0.5 = slow motion).
23    pub time_scale: f32,
24    /// Whether to apply a subtle edge-fade to prevent border artifacts.
25    pub edge_fade: bool,
26    /// Edge fade width in UV units (0.05 = 5% of screen from each edge).
27    pub edge_fade_width: f32,
28}
29
30impl Default for DistortionParams {
31    fn default() -> Self {
32        Self {
33            enabled:         true,
34            scale:           1.0,
35            chromatic_split: 0.2,
36            max_offset:      0.05,
37            time_scale:      1.0,
38            edge_fade:       true,
39            edge_fade_width: 0.05,
40        }
41    }
42}
43
44impl DistortionParams {
45    /// Disabled distortion.
46    pub fn none() -> Self { Self { enabled: false, ..Default::default() } }
47
48    /// Subtle heat shimmer (great for desert levels or forges).
49    pub fn heat_shimmer() -> Self {
50        Self { enabled: true, scale: 0.3, chromatic_split: 0.1,
51               max_offset: 0.008, time_scale: 1.5, edge_fade: true,
52               edge_fade_width: 0.1 }
53    }
54
55    /// Strong gravitational lensing (black hole or Gravity Nexus).
56    pub fn gravity_lens() -> Self {
57        Self { enabled: true, scale: 2.0, chromatic_split: 0.5,
58               max_offset: 0.08, time_scale: 0.3, edge_fade: true,
59               edge_fade_width: 0.05 }
60    }
61
62    /// Chaos Rift distortion (high entropy, chaotic, colorful).
63    pub fn chaos_rift(entropy: f32) -> Self {
64        let e = entropy.clamp(0.0, 1.0);
65        Self { enabled: e > 0.01, scale: e * 3.0, chromatic_split: e * 0.8,
66               max_offset: e * 0.12, time_scale: 1.0 + e * 4.0, edge_fade: false,
67               edge_fade_width: 0.0 }
68    }
69
70    /// Lerp between two distortion configs.
71    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
72        let t = t.clamp(0.0, 1.0);
73        Self {
74            enabled:         if t < 0.5 { a.enabled } else { b.enabled },
75            scale:           lerp_f32(a.scale,           b.scale,           t),
76            chromatic_split: lerp_f32(a.chromatic_split, b.chromatic_split, t),
77            max_offset:      lerp_f32(a.max_offset,      b.max_offset,      t),
78            time_scale:      lerp_f32(a.time_scale,      b.time_scale,      t),
79            edge_fade:       a.edge_fade,
80            edge_fade_width: lerp_f32(a.edge_fade_width, b.edge_fade_width, t),
81        }
82    }
83}
84
85// ── Distortion map ────────────────────────────────────────────────────────────
86
87/// A CPU-side distortion map — an array of 2D UV offsets, one per pixel.
88///
89/// The GPU reads this as a 2D texture (RG32F) and uses it to offset UV lookups
90/// when sampling the scene texture.
91pub struct DistortionMap {
92    pub width:  u32,
93    pub height: u32,
94    /// Flat array of (u_offset, v_offset) pairs, row-major.
95    offsets: Vec<Vec2>,
96}
97
98impl DistortionMap {
99    pub fn new(width: u32, height: u32) -> Self {
100        Self {
101            width,
102            height,
103            offsets: vec![Vec2::ZERO; (width * height) as usize],
104        }
105    }
106
107    pub fn clear(&mut self) {
108        self.offsets.iter_mut().for_each(|o| *o = Vec2::ZERO);
109    }
110
111    pub fn set(&mut self, x: u32, y: u32, offset: Vec2) {
112        if x < self.width && y < self.height {
113            self.offsets[(y * self.width + x) as usize] = offset;
114        }
115    }
116
117    pub fn get(&self, x: u32, y: u32) -> Vec2 {
118        if x < self.width && y < self.height {
119            self.offsets[(y * self.width + x) as usize]
120        } else {
121            Vec2::ZERO
122        }
123    }
124
125    pub fn add(&mut self, x: u32, y: u32, offset: Vec2) {
126        if x < self.width && y < self.height {
127            self.offsets[(y * self.width + x) as usize] += offset;
128        }
129    }
130
131    /// Raw f32 slice for GPU upload (RGBA32F, packed as [r_offset, g_offset, 0, 0] per pixel).
132    pub fn as_f32_slice(&self) -> Vec<f32> {
133        self.offsets.iter().flat_map(|o| [o.x, o.y, 0.0, 0.0]).collect()
134    }
135
136    /// Write a radial gravity lens distortion around a screen-space center.
137    ///
138    /// `center_uv` in [0, 1], `strength` is UV units of max displacement,
139    /// `radius_uv` is the falloff radius in UV units.
140    pub fn add_gravity_lens(&mut self, center_uv: Vec2, strength: f32, radius_uv: f32) {
141        for y in 0..self.height {
142            for x in 0..self.width {
143                let uv = Vec2::new(
144                    x as f32 / self.width  as f32,
145                    y as f32 / self.height as f32,
146                );
147                let delta = center_uv - uv;
148                let dist = delta.length();
149                if dist < radius_uv && dist > 0.0001 {
150                    let factor = (1.0 - dist / radius_uv).powi(2);
151                    let pull = delta / dist * strength * factor;
152                    self.add(x, y, pull);
153                }
154            }
155        }
156    }
157
158    /// Write a heat shimmer distortion (Perlin noise based, animated by time).
159    pub fn add_heat_shimmer(
160        &mut self,
161        region_min: Vec2,
162        region_max: Vec2,
163        strength: f32,
164        time: f32,
165    ) {
166        for y in 0..self.height {
167            for x in 0..self.width {
168                let uv = Vec2::new(
169                    x as f32 / self.width  as f32,
170                    y as f32 / self.height as f32,
171                );
172                if uv.x < region_min.x || uv.x > region_max.x
173                    || uv.y < region_min.y || uv.y > region_max.y {
174                    continue;
175                }
176                // Simple sine-wave shimmer (no Perlin dependency here for self-containment)
177                let phase_x = (uv.y * 20.0 + time * 3.0).sin();
178                let phase_y = (uv.x * 15.0 + time * 2.3).cos();
179                let offset = Vec2::new(phase_x, phase_y) * strength;
180                self.add(x, y, offset);
181            }
182        }
183    }
184
185    /// Clamp all offsets to `max_magnitude` UV units.
186    pub fn clamp_offsets(&mut self, max_magnitude: f32) {
187        for o in &mut self.offsets {
188            let len = o.length();
189            if len > max_magnitude {
190                *o = *o / len * max_magnitude;
191            }
192        }
193    }
194
195    pub fn pixel_count(&self) -> usize { self.offsets.len() }
196}
197
198fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
199
200// ── Tests ─────────────────────────────────────────────────────────────────────
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn new_map_is_all_zero() {
208        let map = DistortionMap::new(16, 16);
209        assert_eq!(map.get(0, 0), Vec2::ZERO);
210        assert_eq!(map.get(8, 8), Vec2::ZERO);
211    }
212
213    #[test]
214    fn set_and_get() {
215        let mut map = DistortionMap::new(16, 16);
216        map.set(3, 5, Vec2::new(0.01, -0.02));
217        let v = map.get(3, 5);
218        assert!((v.x - 0.01).abs() < 1e-6);
219        assert!((v.y + 0.02).abs() < 1e-6);
220    }
221
222    #[test]
223    fn clear_resets_all() {
224        let mut map = DistortionMap::new(8, 8);
225        map.set(2, 2, Vec2::new(1.0, 1.0));
226        map.clear();
227        assert_eq!(map.get(2, 2), Vec2::ZERO);
228    }
229
230    #[test]
231    fn gravity_lens_creates_offsets() {
232        let mut map = DistortionMap::new(64, 64);
233        map.add_gravity_lens(Vec2::new(0.5, 0.5), 0.05, 0.3);
234        // Near the center the offset should be near zero (distance = 0)
235        // At the edge it should be non-zero
236        let edge = map.get(0, 0);
237        let near_center = map.get(32, 32);
238        assert!(edge.length() > near_center.length());
239    }
240
241    #[test]
242    fn clamp_limits_magnitude() {
243        let mut map = DistortionMap::new(4, 4);
244        map.set(0, 0, Vec2::new(10.0, 10.0));
245        map.clamp_offsets(0.1);
246        assert!(map.get(0, 0).length() <= 0.11);
247    }
248
249    #[test]
250    fn as_f32_slice_length() {
251        let map = DistortionMap::new(8, 8);
252        let v = map.as_f32_slice();
253        assert_eq!(v.len(), 8 * 8 * 4);  // 4 floats per pixel (RGBA)
254    }
255
256    #[test]
257    fn chaos_rift_scales() {
258        let low  = DistortionParams::chaos_rift(0.1);
259        let high = DistortionParams::chaos_rift(0.9);
260        assert!(high.scale > low.scale);
261    }
262}