Skip to main content

proof_engine/relativistic/
doppler.rs

1//! Relativistic Doppler effect.
2
3use glam::{Vec3, Vec4};
4use super::lorentz::lorentz_factor;
5
6/// Relativistic longitudinal Doppler effect.
7/// Returns observed frequency given source frequency f_source, speed v, and whether approaching.
8/// f_obs = f_source * sqrt((1+beta)/(1-beta)) for approaching
9/// f_obs = f_source * sqrt((1-beta)/(1+beta)) for receding
10pub fn relativistic_doppler(f_source: f64, v: f64, c: f64, approaching: bool) -> f64 {
11    let beta = (v / c).abs().min(0.9999999);
12    if approaching {
13        f_source * ((1.0 + beta) / (1.0 - beta)).sqrt()
14    } else {
15        f_source * ((1.0 - beta) / (1.0 + beta)).sqrt()
16    }
17}
18
19/// Transverse Doppler effect (purely relativistic, no classical analog).
20/// f_obs = f_source / gamma (always a redshift).
21pub fn transverse_doppler(f_source: f64, v: f64, c: f64) -> f64 {
22    let gamma = lorentz_factor(v, c);
23    f_source / gamma
24}
25
26/// Wavelength shift: returns observed wavelength.
27/// lambda_obs = lambda_source / doppler_factor
28/// Positive v = receding (redshift), negative v = approaching (blueshift).
29pub fn wavelength_shift(lambda_source: f64, v: f64, c: f64) -> f64 {
30    let beta = v / c;
31    let beta_clamped = beta.clamp(-0.9999999, 0.9999999);
32    lambda_source * ((1.0 + beta_clamped) / (1.0 - beta_clamped)).sqrt()
33}
34
35/// Cosmological redshift parameter z.
36/// z = sqrt((1+beta)/(1-beta)) - 1
37pub fn redshift_z(v: f64, c: f64) -> f64 {
38    let beta = (v / c).abs().min(0.9999999);
39    ((1.0 + beta) / (1.0 - beta)).sqrt() - 1.0
40}
41
42/// Convert a wavelength in nanometers to an approximate RGBA color.
43/// Maps the visible spectrum (380-780nm) to RGB.
44/// Outside visible range, returns dim values.
45pub fn color_from_wavelength(wavelength_nm: f64) -> Vec4 {
46    let w = wavelength_nm;
47    let (mut r, mut g, mut b);
48
49    if w < 380.0 {
50        // Ultraviolet - faint violet
51        r = 0.2;
52        g = 0.0;
53        b = 0.3;
54    } else if w < 440.0 {
55        r = -(w - 440.0) / (440.0 - 380.0);
56        g = 0.0;
57        b = 1.0;
58    } else if w < 490.0 {
59        r = 0.0;
60        g = (w - 440.0) / (490.0 - 440.0);
61        b = 1.0;
62    } else if w < 510.0 {
63        r = 0.0;
64        g = 1.0;
65        b = -(w - 510.0) / (510.0 - 490.0);
66    } else if w < 580.0 {
67        r = (w - 510.0) / (580.0 - 510.0);
68        g = 1.0;
69        b = 0.0;
70    } else if w < 645.0 {
71        r = 1.0;
72        g = -(w - 645.0) / (645.0 - 580.0);
73        b = 0.0;
74    } else if w < 780.0 {
75        r = 1.0;
76        g = 0.0;
77        b = 0.0;
78    } else {
79        // Infrared - faint red
80        r = 0.3;
81        g = 0.0;
82        b = 0.0;
83    }
84
85    // Intensity fall-off at edges of visible spectrum
86    let intensity = if w < 380.0 || w > 780.0 {
87        0.3
88    } else if w < 420.0 {
89        0.3 + 0.7 * (w - 380.0) / (420.0 - 380.0)
90    } else if w > 700.0 {
91        0.3 + 0.7 * (780.0 - w) / (780.0 - 700.0)
92    } else {
93        1.0
94    };
95
96    r *= intensity;
97    g *= intensity;
98    b *= intensity;
99
100    Vec4::new(r as f32, g as f32, b as f32, 1.0)
101}
102
103/// Shift a base color by the relativistic Doppler effect.
104/// Uses the radial velocity component (projection of v onto observer direction).
105pub fn doppler_color_shift(
106    base_color: Vec4,
107    v: f64,
108    c: f64,
109    direction: Vec3,
110    observer_dir: Vec3,
111) -> Vec4 {
112    let dir_norm = direction.normalize_or_zero();
113    let obs_norm = observer_dir.normalize_or_zero();
114    // Radial velocity component: positive = receding
115    let v_radial = -(v as f32) * dir_norm.dot(obs_norm);
116    let v_r = v_radial as f64;
117
118    // Estimate a "dominant wavelength" from the color and shift it
119    // Simple approach: shift the color temperature
120    let beta = (v_r / c).clamp(-0.9999999, 0.9999999);
121    let doppler = ((1.0 + beta) / (1.0 - beta)).sqrt();
122
123    // Shift RGB channels: blueshift moves energy up, redshift moves it down
124    let shift = (1.0 / doppler) as f32;
125
126    // Apply shift by interpolating channels
127    if shift > 1.0 {
128        // Blueshift: move red->green->blue
129        let t = (shift - 1.0).min(1.0);
130        Vec4::new(
131            base_color.x * (1.0 - t) + base_color.y * t,
132            base_color.y * (1.0 - t) + base_color.z * t,
133            base_color.z + base_color.x * t,
134            base_color.w,
135        )
136    } else {
137        // Redshift: move blue->green->red
138        let t = (1.0 - shift).min(1.0);
139        Vec4::new(
140            base_color.x + base_color.z * t,
141            base_color.y * (1.0 - t) + base_color.x * t,
142            base_color.z * (1.0 - t) + base_color.y * t,
143            base_color.w,
144        )
145    }
146}
147
148/// Renderer that shifts entity colors based on radial velocity toward observer.
149#[derive(Debug, Clone)]
150pub struct DopplerRenderer {
151    pub c: f64,
152    pub observer_pos: Vec3,
153    pub intensity_shift: bool,
154}
155
156impl DopplerRenderer {
157    pub fn new(c: f64, observer_pos: Vec3) -> Self {
158        Self {
159            c,
160            observer_pos,
161            intensity_shift: true,
162        }
163    }
164
165    /// Compute the Doppler-shifted color for an entity.
166    pub fn shifted_color(
167        &self,
168        base_color: Vec4,
169        entity_pos: Vec3,
170        entity_velocity: Vec3,
171    ) -> Vec4 {
172        let to_observer = (self.observer_pos - entity_pos).normalize_or_zero();
173        let v = entity_velocity.length() as f64;
174        if v < 1e-10 {
175            return base_color;
176        }
177        let direction = entity_velocity.normalize();
178        doppler_color_shift(base_color, v, self.c, direction, to_observer)
179    }
180
181    /// Compute the Doppler frequency ratio (observed/emitted).
182    pub fn frequency_ratio(&self, entity_pos: Vec3, entity_velocity: Vec3) -> f64 {
183        let to_observer = (self.observer_pos - entity_pos).normalize_or_zero();
184        // Positive v_radial = receding. Observed/emitted frequency is
185        // sqrt((1 - beta) / (1 + beta)) for a receding source; this returned
186        // the reciprocal (the wavelength ratio), so approaching looked red.
187        let v_radial = -entity_velocity.dot(to_observer) as f64;
188        let beta = (v_radial / self.c).clamp(-0.9999999, 0.9999999);
189        ((1.0 - beta) / (1.0 + beta)).sqrt()
190    }
191
192    /// Batch process: shift colors for multiple entities.
193    pub fn shift_colors(
194        &self,
195        entities: &[(Vec3, Vec3, Vec4)], // (position, velocity, base_color)
196    ) -> Vec<Vec4> {
197        entities.iter().map(|(pos, vel, col)| {
198            self.shifted_color(*col, *pos, *vel)
199        }).collect()
200    }
201
202    /// Compute intensity scaling from Doppler effect.
203    /// Intensity scales as (f_obs/f_source)^3 for a moving isotropic source.
204    pub fn intensity_factor(&self, entity_pos: Vec3, entity_velocity: Vec3) -> f32 {
205        let ratio = self.frequency_ratio(entity_pos, entity_velocity);
206        (ratio.powi(3)).min(10.0) as f32
207    }
208}
209
210/// Cosmological redshift: shifts wavelength by factor (1 + z).
211pub fn cosmic_redshift(z: f64, base_wavelength: f64) -> f64 {
212    base_wavelength * (1.0 + z)
213}
214
215/// Compute the velocity from redshift z (special relativistic formula).
216pub fn velocity_from_redshift(z: f64, c: f64) -> f64 {
217    let z1 = z + 1.0;
218    c * (z1 * z1 - 1.0) / (z1 * z1 + 1.0)
219}
220
221/// General Doppler formula for arbitrary angle theta between velocity and line of sight.
222/// f_obs = f_source / (gamma * (1 - beta * cos(theta)))
223/// theta = 0 means source moving directly toward observer.
224pub fn general_doppler(f_source: f64, v: f64, c: f64, theta: f64) -> f64 {
225    let beta = v / c;
226    let gamma = lorentz_factor(v, c);
227    f_source / (gamma * (1.0 - beta * theta.cos()))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    const C: f64 = 299_792_458.0;
235
236    #[test]
237    fn test_doppler_blueshift() {
238        let f_obs = relativistic_doppler(1000.0, 0.5 * C, C, true);
239        assert!(f_obs > 1000.0, "Approaching should blueshift: {}", f_obs);
240    }
241
242    #[test]
243    fn test_doppler_redshift() {
244        let f_obs = relativistic_doppler(1000.0, 0.5 * C, C, false);
245        assert!(f_obs < 1000.0, "Receding should redshift: {}", f_obs);
246    }
247
248    #[test]
249    fn test_doppler_symmetry() {
250        let f_blue = relativistic_doppler(1000.0, 0.5 * C, C, true);
251        let f_red = relativistic_doppler(1000.0, 0.5 * C, C, false);
252        // f_blue * f_red = f_source^2
253        assert!(
254            (f_blue * f_red - 1000.0 * 1000.0).abs() < 1.0,
255            "Product should equal f^2: {} * {} = {}",
256            f_blue, f_red, f_blue * f_red
257        );
258    }
259
260    #[test]
261    fn test_transverse_doppler_redshift() {
262        let f_obs = transverse_doppler(1000.0, 0.5 * C, C);
263        assert!(f_obs < 1000.0, "Transverse Doppler should always redshift: {}", f_obs);
264    }
265
266    #[test]
267    fn test_transverse_less_than_longitudinal() {
268        let f_trans = transverse_doppler(1000.0, 0.5 * C, C);
269        let f_long = relativistic_doppler(1000.0, 0.5 * C, C, false);
270        // Transverse redshift is less extreme than longitudinal receding
271        assert!(
272            f_trans > f_long,
273            "Transverse {} should be less shifted than longitudinal receding {}",
274            f_trans, f_long
275        );
276    }
277
278    #[test]
279    fn test_wavelength_shift_receding() {
280        let lambda_obs = wavelength_shift(500.0, 0.5 * C, C);
281        assert!(lambda_obs > 500.0, "Receding should increase wavelength: {}", lambda_obs);
282    }
283
284    #[test]
285    fn test_wavelength_shift_approaching() {
286        let lambda_obs = wavelength_shift(500.0, -0.5 * C, C);
287        assert!(lambda_obs < 500.0, "Approaching should decrease wavelength: {}", lambda_obs);
288    }
289
290    #[test]
291    fn test_redshift_z_zero_at_rest() {
292        let z = redshift_z(0.0, C);
293        assert!(z.abs() < 1e-10);
294    }
295
296    #[test]
297    fn test_redshift_z_approaches_infinity() {
298        let z = redshift_z(0.9999 * C, C);
299        assert!(z > 100.0, "z should be very large near c: {}", z);
300    }
301
302    #[test]
303    fn test_color_from_wavelength_visible() {
304        // Red
305        let red = color_from_wavelength(650.0);
306        assert!(red.x > red.y && red.x > red.z);
307        // Green
308        let green = color_from_wavelength(520.0);
309        assert!(green.y > green.x && green.y > green.z);
310        // Blue
311        let blue = color_from_wavelength(460.0);
312        assert!(blue.z > blue.y);
313    }
314
315    #[test]
316    fn test_cosmic_redshift() {
317        let shifted = cosmic_redshift(1.0, 500.0);
318        assert!((shifted - 1000.0).abs() < 1e-10, "z=1 should double wavelength");
319    }
320
321    #[test]
322    fn test_velocity_from_redshift_roundtrip() {
323        let v = 0.6 * C;
324        let z = redshift_z(v, C);
325        let v_back = velocity_from_redshift(z, C);
326        assert!((v - v_back).abs() / v < 1e-6);
327    }
328
329    #[test]
330    fn test_general_doppler_forward() {
331        // theta=0 (approaching) should match relativistic_doppler approaching
332        let f1 = general_doppler(1000.0, 0.5 * C, C, 0.0);
333        let f2 = relativistic_doppler(1000.0, 0.5 * C, C, true);
334        assert!((f1 - f2).abs() < 1e-6);
335    }
336
337    #[test]
338    fn test_general_doppler_transverse() {
339        // theta = pi/2 should match transverse doppler
340        let f1 = general_doppler(1000.0, 0.5 * C, C, std::f64::consts::FRAC_PI_2);
341        let f2 = transverse_doppler(1000.0, 0.5 * C, C);
342        assert!((f1 - f2).abs() < 1e-6);
343    }
344
345    #[test]
346    fn test_doppler_renderer() {
347        let renderer = DopplerRenderer::new(C, Vec3::new(0.0, 0.0, 0.0));
348        let ratio = renderer.frequency_ratio(
349            Vec3::new(10.0, 0.0, 0.0),
350            Vec3::new(-0.5 * C as f32, 0.0, 0.0), // moving toward observer
351        );
352        assert!(ratio > 1.0, "Should be blueshifted: {}", ratio);
353    }
354}