1use crate::ray::Ray;
4use eulumdat::Eulumdat;
5use nalgebra::{Point3, Rotation3, Unit, Vector3};
6use rand::Rng;
7use std::f64::consts::PI;
8
9#[derive(Debug, Clone)]
11pub enum Source {
12 Isotropic { position: Point3<f64>, flux_lm: f64 },
14
15 Lambertian {
17 position: Point3<f64>,
18 normal: Unit<Vector3<f64>>,
19 flux_lm: f64,
20 },
21
22 Led {
24 position: Point3<f64>,
25 direction: Unit<Vector3<f64>>,
26 half_angle_deg: f64,
27 flux_lm: f64,
28 },
29
30 LineSource {
32 start: Point3<f64>,
33 end: Point3<f64>,
34 normal: Unit<Vector3<f64>>,
35 half_angle_deg: f64,
36 flux_lm: f64,
37 },
38
39 AreaSource {
43 center: Point3<f64>,
44 normal: Unit<Vector3<f64>>,
45 u_axis: Unit<Vector3<f64>>,
46 half_width: f64,
47 half_height: f64,
48 flux_lm: f64,
49 },
50
51 FromLvk {
53 position: Point3<f64>,
54 orientation: Rotation3<f64>,
55 eulumdat: Box<Eulumdat>,
56 flux_lm: f64,
57 cdf: LvkCdf,
59 },
60}
61
62impl Source {
63 pub fn from_lvk(
65 position: Point3<f64>,
66 orientation: Rotation3<f64>,
67 eulumdat: Eulumdat,
68 flux_lm: f64,
69 ) -> Self {
70 let cdf = LvkCdf::build(&eulumdat);
71 Source::FromLvk {
72 position,
73 orientation,
74 eulumdat: Box::new(eulumdat),
75 flux_lm,
76 cdf,
77 }
78 }
79
80 pub fn flux_lm(&self) -> f64 {
82 match self {
83 Source::Isotropic { flux_lm, .. } => *flux_lm,
84 Source::Lambertian { flux_lm, .. } => *flux_lm,
85 Source::Led { flux_lm, .. } => *flux_lm,
86 Source::LineSource { flux_lm, .. } => *flux_lm,
87 Source::AreaSource { flux_lm, .. } => *flux_lm,
88 Source::FromLvk { flux_lm, .. } => *flux_lm,
89 }
90 }
91
92 pub fn sample<R: Rng>(&self, rng: &mut R) -> Ray {
94 match self {
95 Source::Isotropic { position, .. } => {
96 let dir = random_sphere(rng);
97 Ray::new(*position, dir)
98 }
99
100 Source::Lambertian {
101 position, normal, ..
102 } => {
103 let dir = random_cosine_hemisphere(normal, rng);
104 Ray::new(*position, dir)
105 }
106
107 Source::Led {
108 position,
109 direction,
110 half_angle_deg,
111 ..
112 } => {
113 let dir = random_cone(direction, *half_angle_deg, rng);
114 Ray::new(*position, dir)
115 }
116
117 Source::LineSource {
118 start,
119 end,
120 normal,
121 half_angle_deg,
122 ..
123 } => {
124 let t: f64 = rng.random();
126 let origin = start + t * (end - start);
127 let dir = random_cone(normal, *half_angle_deg, rng);
128 Ray::new(origin, dir)
129 }
130
131 Source::AreaSource {
132 center,
133 normal,
134 u_axis,
135 half_width,
136 half_height,
137 ..
138 } => {
139 let u: f64 = (rng.random::<f64>() * 2.0 - 1.0) * half_width;
141 let v_axis = Unit::new_normalize(normal.cross(u_axis.as_ref()));
142 let v: f64 = (rng.random::<f64>() * 2.0 - 1.0) * half_height;
143 let origin = center + u * u_axis.as_ref() + v * v_axis.as_ref();
144 let dir = random_cosine_hemisphere(normal, rng);
145 Ray::new(origin, dir)
146 }
147
148 Source::FromLvk {
149 position,
150 orientation,
151 cdf,
152 ..
153 } => {
154 let (c_angle, g_angle) = cdf.sample(rng);
156
157 let g_rad = g_angle.to_radians();
158 let c_rad = c_angle.to_radians();
159
160 let dir_local = Vector3::new(
162 g_rad.sin() * c_rad.cos(),
163 g_rad.sin() * c_rad.sin(),
164 -g_rad.cos(),
165 );
166
167 let dir_world = orientation * dir_local;
168 Ray::new(*position, Unit::new_normalize(dir_world))
169 }
170 }
171 }
172}
173
174fn random_sphere<R: Rng>(rng: &mut R) -> Unit<Vector3<f64>> {
180 let z: f64 = 2.0 * rng.random::<f64>() - 1.0;
181 let r = (1.0 - z * z).max(0.0).sqrt();
182 let phi = 2.0 * PI * rng.random::<f64>();
183 Unit::new_normalize(Vector3::new(r * phi.cos(), r * phi.sin(), z))
184}
185
186fn random_cosine_hemisphere<R: Rng>(
188 normal: &Unit<Vector3<f64>>,
189 rng: &mut R,
190) -> Unit<Vector3<f64>> {
191 let u1: f64 = rng.random();
192 let u2: f64 = rng.random();
193 let r = u1.sqrt();
194 let theta = 2.0 * PI * u2;
195 let x = r * theta.cos();
196 let y = r * theta.sin();
197 let z = (1.0 - u1).sqrt();
198
199 let (tangent, bitangent) = build_onb(normal);
200 let dir = x * tangent.as_ref() + y * bitangent.as_ref() + z * normal.as_ref();
201 Unit::new_normalize(dir)
202}
203
204fn random_cone<R: Rng>(
206 axis: &Unit<Vector3<f64>>,
207 half_angle_deg: f64,
208 rng: &mut R,
209) -> Unit<Vector3<f64>> {
210 let cos_max = (half_angle_deg.to_radians()).cos();
211 let u: f64 = rng.random();
212 let cos_theta = 1.0 - u * (1.0 - cos_max);
213 let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
214 let phi = 2.0 * PI * rng.random::<f64>();
215
216 let (tangent, bitangent) = build_onb(axis);
217 let dir = sin_theta * phi.cos() * tangent.as_ref()
218 + sin_theta * phi.sin() * bitangent.as_ref()
219 + cos_theta * axis.as_ref();
220 Unit::new_normalize(dir)
221}
222
223fn build_onb(n: &Unit<Vector3<f64>>) -> (Unit<Vector3<f64>>, Unit<Vector3<f64>>) {
225 let a = if n.x.abs() > 0.9 {
226 Vector3::y_axis()
227 } else {
228 Vector3::x_axis()
229 };
230 let t = Unit::new_normalize(n.cross(a.as_ref()));
231 let b = Unit::new_normalize(n.cross(t.as_ref()));
232 (t, b)
233}
234
235#[derive(Debug, Clone)]
243pub struct LvkCdf {
244 pub g_steps: usize,
246 pub c_steps: usize,
248 pub g_max: f64,
250 pub marginal_g: Vec<f64>,
252 pub conditional_c: Vec<Vec<f64>>,
254}
255
256impl LvkCdf {
257 #[allow(clippy::needless_range_loop)]
259 pub fn build(ldt: &Eulumdat) -> Self {
260 let g_max = ldt.g_angles.last().copied().unwrap_or(180.0);
261 let g_step: f64 = 1.0; let c_step: f64 = 5.0; let g_steps = (g_max / g_step).ceil() as usize + 1;
264 let c_steps = (360.0 / c_step).ceil() as usize;
265
266 let mut pdf = vec![vec![0.0f64; c_steps]; g_steps];
268 for gi in 0..g_steps {
269 let g = (gi as f64 * g_step).min(g_max);
270 let sin_g = g.to_radians().sin();
271 for ci in 0..c_steps {
272 let c = ci as f64 * c_step;
273 pdf[gi][ci] = ldt.sample(c, g) * sin_g;
274 }
275 }
276
277 let marginal_unnorm: Vec<f64> = pdf.iter().map(|row| row.iter().sum::<f64>()).collect();
279
280 let mut marginal_g = vec![0.0; g_steps];
282 let mut cum = 0.0;
283 for gi in 0..g_steps {
284 cum += marginal_unnorm[gi];
285 marginal_g[gi] = cum;
286 }
287 if cum > 0.0 {
289 for v in &mut marginal_g {
290 *v /= cum;
291 }
292 }
293
294 let mut conditional_c = vec![vec![0.0; c_steps]; g_steps];
296 for gi in 0..g_steps {
297 let row_sum: f64 = pdf[gi].iter().sum();
298 let mut ccum = 0.0;
299 for ci in 0..c_steps {
300 ccum += pdf[gi][ci];
301 conditional_c[gi][ci] = if row_sum > 0.0 {
302 ccum / row_sum
303 } else {
304 (ci + 1) as f64 / c_steps as f64
305 };
306 }
307 }
308
309 Self {
310 g_steps,
311 c_steps,
312 g_max,
313 marginal_g,
314 conditional_c,
315 }
316 }
317
318 fn sample<R: Rng>(&self, rng: &mut R) -> (f64, f64) {
319 let u_g: f64 = rng.random();
321 let gi = match self
322 .marginal_g
323 .binary_search_by(|v| v.partial_cmp(&u_g).unwrap())
324 {
325 Ok(i) => i,
326 Err(i) => i.min(self.g_steps - 1),
327 };
328 let g = (gi as f64 / (self.g_steps - 1).max(1) as f64) * self.g_max;
329
330 let u_c: f64 = rng.random();
332 let ci = match self.conditional_c[gi].binary_search_by(|v| v.partial_cmp(&u_c).unwrap()) {
333 Ok(i) => i,
334 Err(i) => i.min(self.c_steps - 1),
335 };
336 let c = ci as f64 * (360.0 / self.c_steps as f64);
337
338 (c, g)
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use rand::SeedableRng;
346 use rand_xoshiro::Xoshiro256PlusPlus;
347
348 #[test]
349 fn isotropic_samples_all_directions() {
350 let source = Source::Isotropic {
351 position: Point3::origin(),
352 flux_lm: 1000.0,
353 };
354 let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
355 let mut sum = Vector3::zeros();
356 let n = 10000;
357 for _ in 0..n {
358 let ray = source.sample(&mut rng);
359 sum += ray.direction.as_ref();
360 }
361 let mean = sum / n as f64;
363 assert!(mean.norm() < 0.05, "Mean direction should be near zero");
364 }
365
366 #[test]
367 fn lambertian_samples_hemisphere() {
368 let source = Source::Lambertian {
369 position: Point3::origin(),
370 normal: Vector3::z_axis(),
371 flux_lm: 1000.0,
372 };
373 let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
374 for _ in 0..1000 {
375 let ray = source.sample(&mut rng);
376 assert!(
377 ray.direction.z >= -1e-6,
378 "Lambertian should only emit into positive hemisphere"
379 );
380 }
381 }
382
383 #[test]
384 fn led_samples_within_cone() {
385 let source = Source::Led {
386 position: Point3::origin(),
387 direction: Vector3::z_axis(),
388 half_angle_deg: 30.0,
389 flux_lm: 1000.0,
390 };
391 let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
392 let cos_limit = 30.0f64.to_radians().cos();
393 for _ in 0..1000 {
394 let ray = source.sample(&mut rng);
395 let cos_angle = ray.direction.dot(Vector3::z_axis().as_ref());
396 assert!(
397 cos_angle >= cos_limit - 1e-6,
398 "LED should sample within cone"
399 );
400 }
401 }
402}