Skip to main content

eulumdat_goniosim/
tracer.rs

1//! Monte Carlo photon tracer.
2
3use crate::detector::Detector;
4use crate::material::Interaction;
5use crate::ray::Photon;
6use crate::scene::Scene;
7use nalgebra::Point3;
8use rand::SeedableRng;
9use rand_xoshiro::Xoshiro256PlusPlus;
10use std::time::{Duration, Instant};
11
12/// Configuration for a trace run.
13#[derive(Debug, Clone)]
14pub struct TracerConfig {
15    /// Total photons to trace.
16    pub num_photons: u64,
17    /// Maximum interactions per photon before termination.
18    pub max_bounces: u32,
19    /// Energy threshold below which Russian roulette kicks in.
20    pub russian_roulette_threshold: f64,
21    /// RNG seed for reproducibility.
22    pub seed: u64,
23    /// Detector C-angle resolution in degrees.
24    pub detector_c_resolution: f64,
25    /// Detector gamma resolution in degrees.
26    pub detector_g_resolution: f64,
27    /// Number of photon trails to record for visualization (0 to disable).
28    pub max_trails: usize,
29}
30
31impl Default for TracerConfig {
32    fn default() -> Self {
33        Self {
34            num_photons: 1_000_000,
35            max_bounces: 50,
36            russian_roulette_threshold: 0.01,
37            seed: 42,
38            detector_c_resolution: 1.0,
39            detector_g_resolution: 1.0,
40            max_trails: 0,
41        }
42    }
43}
44
45/// Result of a completed trace.
46#[derive(Debug, Clone)]
47pub struct TracerResult {
48    /// Filled detector with accumulated photon data.
49    pub detector: Detector,
50    /// Statistics about the trace run.
51    pub stats: TracerStats,
52    /// Recorded photon trails for visualization.
53    pub trails: Vec<PhotonTrail>,
54}
55
56/// Statistics about a completed trace.
57#[derive(Debug, Clone, Default)]
58pub struct TracerStats {
59    pub photons_traced: u64,
60    pub photons_detected: u64,
61    pub photons_absorbed: u64,
62    pub photons_max_bounces: u64,
63    pub photons_russian_roulette: u64,
64    pub total_energy_emitted: f64,
65    pub total_energy_detected: f64,
66    pub elapsed: Duration,
67}
68
69/// Progress information for the progress callback.
70#[derive(Debug, Clone)]
71pub struct ProgressInfo {
72    pub photons_done: u64,
73    pub photons_total: u64,
74    pub photons_per_second: f64,
75    pub current_stats: TracerStats,
76}
77
78/// A recorded photon path for visualization.
79#[derive(Debug, Clone)]
80pub struct PhotonTrail {
81    pub points: Vec<TrailPoint>,
82}
83
84/// A point along a photon's path.
85#[derive(Debug, Clone)]
86pub struct TrailPoint {
87    pub position: Point3<f64>,
88    pub event: TrailEvent,
89}
90
91/// Type of event at a trail point.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum TrailEvent {
94    Emitted,
95    Reflected,
96    Transmitted,
97    Scattered,
98    Absorbed,
99    Detected,
100}
101
102/// The Monte Carlo photon tracer.
103pub struct Tracer;
104
105impl Tracer {
106    /// Trace all photons through the scene.
107    pub fn trace(scene: &Scene, config: &TracerConfig) -> TracerResult {
108        Self::trace_with_progress(scene, config, |_| {})
109    }
110
111    /// Trace with a progress callback (called periodically).
112    pub fn trace_with_progress(
113        scene: &Scene,
114        config: &TracerConfig,
115        callback: impl Fn(ProgressInfo) + Send + Sync,
116    ) -> TracerResult {
117        let start = Instant::now();
118
119        #[cfg(feature = "parallel")]
120        let result = trace_parallel(scene, config, &callback, start);
121
122        #[cfg(not(feature = "parallel"))]
123        let result = trace_sequential(scene, config, &callback, start);
124
125        result
126    }
127}
128
129/// Trace photons sequentially (single-threaded).
130#[cfg(not(feature = "parallel"))]
131fn trace_sequential(
132    scene: &Scene,
133    config: &TracerConfig,
134    callback: &(impl Fn(ProgressInfo) + Send + Sync),
135    start: Instant,
136) -> TracerResult {
137    let mut rng = Xoshiro256PlusPlus::seed_from_u64(config.seed);
138    let mut detector = Detector::new(config.detector_c_resolution, config.detector_g_resolution);
139    let mut stats = TracerStats::default();
140    let mut trails = Vec::new();
141
142    let batch_size = 10_000u64;
143    let num_sources = scene.sources.len();
144    assert!(num_sources > 0, "Scene must have at least one source");
145
146    for i in 0..config.num_photons {
147        // Round-robin across sources
148        let source = &scene.sources[(i as usize) % num_sources];
149        let ray = source.sample(&mut rng);
150        let record_trail = trails.len() < config.max_trails;
151
152        let result = trace_one_photon(scene, config, ray, &mut rng, record_trail);
153
154        stats.total_energy_emitted += 1.0;
155        stats.photons_traced += 1;
156
157        match result.outcome {
158            PhotonOutcome::Detected { energy } => {
159                detector.record(result.final_direction.as_ref().unwrap(), energy);
160                stats.photons_detected += 1;
161                stats.total_energy_detected += energy;
162            }
163            PhotonOutcome::Absorbed => {
164                stats.photons_absorbed += 1;
165            }
166            PhotonOutcome::MaxBounces => {
167                stats.photons_max_bounces += 1;
168            }
169            PhotonOutcome::RussianRoulette => {
170                stats.photons_russian_roulette += 1;
171            }
172        }
173
174        if let Some(trail) = result.trail {
175            trails.push(trail);
176        }
177
178        // Progress callback
179        if (i + 1) % batch_size == 0 || i + 1 == config.num_photons {
180            let elapsed = start.elapsed();
181            stats.elapsed = elapsed;
182            callback(ProgressInfo {
183                photons_done: i + 1,
184                photons_total: config.num_photons,
185                photons_per_second: (i + 1) as f64 / elapsed.as_secs_f64(),
186                current_stats: stats.clone(),
187            });
188        }
189    }
190
191    stats.elapsed = start.elapsed();
192
193    TracerResult {
194        detector,
195        stats,
196        trails,
197    }
198}
199
200/// Trace photons in parallel using Rayon.
201#[cfg(feature = "parallel")]
202fn trace_parallel(
203    scene: &Scene,
204    config: &TracerConfig,
205    callback: &(impl Fn(ProgressInfo) + Send + Sync),
206    start: Instant,
207) -> TracerResult {
208    use rayon::prelude::*;
209    use std::sync::atomic::{AtomicU64, Ordering};
210
211    let num_threads = rayon::current_num_threads();
212    let photons_per_thread = config.num_photons / num_threads as u64;
213    let progress_counter = AtomicU64::new(0);
214
215    let thread_results: Vec<(Detector, TracerStats, Vec<PhotonTrail>)> = (0..num_threads)
216        .into_par_iter()
217        .map(|thread_idx| {
218            let mut rng =
219                Xoshiro256PlusPlus::seed_from_u64(config.seed.wrapping_add(thread_idx as u64));
220            let mut detector =
221                Detector::new(config.detector_c_resolution, config.detector_g_resolution);
222            let mut stats = TracerStats::default();
223            let mut trails = Vec::new();
224            let num_sources = scene.sources.len();
225
226            // First thread gets any remainder photons
227            let n = if thread_idx == 0 {
228                photons_per_thread + (config.num_photons % num_threads as u64)
229            } else {
230                photons_per_thread
231            };
232
233            for i in 0..n {
234                let source_idx =
235                    ((thread_idx as u64 * photons_per_thread + i) as usize) % num_sources;
236                let source = &scene.sources[source_idx];
237                let ray = source.sample(&mut rng);
238                let record_trail = thread_idx == 0 && trails.len() < config.max_trails;
239
240                let result = trace_one_photon(scene, config, ray, &mut rng, record_trail);
241
242                stats.total_energy_emitted += 1.0;
243                stats.photons_traced += 1;
244
245                match result.outcome {
246                    PhotonOutcome::Detected { energy } => {
247                        detector.record(result.final_direction.as_ref().unwrap(), energy);
248                        stats.photons_detected += 1;
249                        stats.total_energy_detected += energy;
250                    }
251                    PhotonOutcome::Absorbed => stats.photons_absorbed += 1,
252                    PhotonOutcome::MaxBounces => stats.photons_max_bounces += 1,
253                    PhotonOutcome::RussianRoulette => stats.photons_russian_roulette += 1,
254                }
255
256                if let Some(trail) = result.trail {
257                    trails.push(trail);
258                }
259
260                // Progress (only thread 0 reports)
261                if thread_idx == 0 && (i + 1) % 10_000 == 0 {
262                    let total_done = progress_counter.load(Ordering::Relaxed) + i + 1;
263                    let elapsed = start.elapsed();
264                    callback(ProgressInfo {
265                        photons_done: total_done,
266                        photons_total: config.num_photons,
267                        photons_per_second: total_done as f64 / elapsed.as_secs_f64(),
268                        current_stats: stats.clone(),
269                    });
270                }
271            }
272
273            progress_counter.fetch_add(n, Ordering::Relaxed);
274            (detector, stats, trails)
275        })
276        .collect();
277
278    // Merge results
279    let mut detector = Detector::new(config.detector_c_resolution, config.detector_g_resolution);
280    let mut stats = TracerStats::default();
281    let mut trails = Vec::new();
282
283    for (d, s, t) in thread_results {
284        detector.merge(&d);
285        stats.photons_traced += s.photons_traced;
286        stats.photons_detected += s.photons_detected;
287        stats.photons_absorbed += s.photons_absorbed;
288        stats.photons_max_bounces += s.photons_max_bounces;
289        stats.photons_russian_roulette += s.photons_russian_roulette;
290        stats.total_energy_emitted += s.total_energy_emitted;
291        stats.total_energy_detected += s.total_energy_detected;
292        trails.extend(t);
293    }
294
295    stats.elapsed = start.elapsed();
296
297    TracerResult {
298        detector,
299        stats,
300        trails,
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Single photon tracing
306// ---------------------------------------------------------------------------
307
308enum PhotonOutcome {
309    Detected { energy: f64 },
310    Absorbed,
311    MaxBounces,
312    RussianRoulette,
313}
314
315struct SinglePhotonResult {
316    outcome: PhotonOutcome,
317    final_direction: Option<nalgebra::Vector3<f64>>,
318    trail: Option<PhotonTrail>,
319}
320
321fn trace_one_photon(
322    scene: &Scene,
323    config: &TracerConfig,
324    initial_ray: crate::ray::Ray,
325    rng: &mut Xoshiro256PlusPlus,
326    record_trail: bool,
327) -> SinglePhotonResult {
328    use rand::Rng;
329
330    let mut photon = Photon::new(initial_ray);
331    let mut trail_points = if record_trail {
332        vec![TrailPoint {
333            position: photon.ray.origin,
334            event: TrailEvent::Emitted,
335        }]
336    } else {
337        Vec::new()
338    };
339
340    loop {
341        // Find nearest intersection
342        let hit = scene.intersect(&photon.ray, 1e-6, 1e10);
343
344        match hit {
345            None => {
346                // Photon escaped — record on detector
347                if record_trail {
348                    let far_point = photon.ray.at(1.0);
349                    trail_points.push(TrailPoint {
350                        position: far_point,
351                        event: TrailEvent::Detected,
352                    });
353                }
354                return SinglePhotonResult {
355                    outcome: PhotonOutcome::Detected {
356                        energy: photon.energy,
357                    },
358                    final_direction: Some(*photon.ray.direction.as_ref()),
359                    trail: if record_trail {
360                        Some(PhotonTrail {
361                            points: trail_points,
362                        })
363                    } else {
364                        None
365                    },
366                };
367            }
368
369            Some(hit) => {
370                let material = scene.material(hit.material);
371                let interaction = material.interact(&photon, &hit, rng);
372
373                match interaction {
374                    Interaction::Absorbed => {
375                        if record_trail {
376                            trail_points.push(TrailPoint {
377                                position: hit.point,
378                                event: TrailEvent::Absorbed,
379                            });
380                        }
381                        return SinglePhotonResult {
382                            outcome: PhotonOutcome::Absorbed,
383                            final_direction: None,
384                            trail: if record_trail {
385                                Some(PhotonTrail {
386                                    points: trail_points,
387                                })
388                            } else {
389                                None
390                            },
391                        };
392                    }
393
394                    Interaction::Reflected {
395                        new_ray,
396                        attenuation,
397                    } => {
398                        if record_trail {
399                            trail_points.push(TrailPoint {
400                                position: hit.point,
401                                event: TrailEvent::Reflected,
402                            });
403                        }
404                        photon.ray = new_ray;
405                        photon.energy *= attenuation;
406                    }
407
408                    Interaction::Transmitted {
409                        new_ray,
410                        attenuation,
411                    } => {
412                        if record_trail {
413                            trail_points.push(TrailPoint {
414                                position: hit.point,
415                                event: TrailEvent::Transmitted,
416                            });
417                        }
418                        photon.ray = new_ray;
419                        photon.energy *= attenuation;
420                    }
421                }
422
423                photon.bounces += 1;
424
425                // Max bounces check
426                if photon.bounces >= config.max_bounces {
427                    return SinglePhotonResult {
428                        outcome: PhotonOutcome::MaxBounces,
429                        final_direction: None,
430                        trail: if record_trail {
431                            Some(PhotonTrail {
432                                points: trail_points,
433                            })
434                        } else {
435                            None
436                        },
437                    };
438                }
439
440                // Russian roulette
441                if photon.energy < config.russian_roulette_threshold {
442                    let survive_prob = photon.energy / config.russian_roulette_threshold;
443                    if rng.random::<f64>() > survive_prob {
444                        return SinglePhotonResult {
445                            outcome: PhotonOutcome::RussianRoulette,
446                            final_direction: None,
447                            trail: if record_trail {
448                                Some(PhotonTrail {
449                                    points: trail_points,
450                                })
451                            } else {
452                                None
453                            },
454                        };
455                    }
456                    // Survived — boost energy to compensate
457                    photon.energy = config.russian_roulette_threshold;
458                }
459            }
460        }
461    }
462}