Skip to main content

oxiphysics_gpu/raytracing/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use std::f64::consts::PI;
6
7use super::types::{
8    AreaLight, Bvh, Camera, HitRecord, Material, MaterialType, PathState, PointLight, Ray,
9    RenderConfig, Triangle,
10};
11
12/// Add two 3-D vectors.
13pub fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
14    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
15}
16/// Subtract two 3-D vectors.
17pub fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
18    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
19}
20/// Scale a 3-D vector by a scalar.
21pub fn scale3(v: [f64; 3], s: f64) -> [f64; 3] {
22    [v[0] * s, v[1] * s, v[2] * s]
23}
24/// Dot product of two 3-D vectors.
25pub fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
26    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
27}
28/// Cross product of two 3-D vectors.
29pub fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
30    [
31        a[1] * b[2] - a[2] * b[1],
32        a[2] * b[0] - a[0] * b[2],
33        a[0] * b[1] - a[1] * b[0],
34    ]
35}
36/// Length of a 3-D vector.
37pub fn length3(v: [f64; 3]) -> f64 {
38    dot3(v, v).sqrt()
39}
40/// Normalize a 3-D vector (returns zero vector if near zero).
41pub fn normalize3(v: [f64; 3]) -> [f64; 3] {
42    let len = length3(v);
43    if len < 1e-15 {
44        return [0.0; 3];
45    }
46    scale3(v, 1.0 / len)
47}
48/// Reflect direction `d` about normal `n` (both normalized).
49pub fn reflect3(d: [f64; 3], n: [f64; 3]) -> [f64; 3] {
50    let dn2 = 2.0 * dot3(d, n);
51    sub3(d, scale3(n, dn2))
52}
53/// Component-wise multiply two RGB colors.
54pub fn mul_color(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
55    [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
56}
57/// Clamp each component of a color to \[0,1\].
58pub fn clamp_color(c: [f64; 3]) -> [f64; 3] {
59    [
60        c[0].clamp(0.0, 1.0),
61        c[1].clamp(0.0, 1.0),
62        c[2].clamp(0.0, 1.0),
63    ]
64}
65/// Phong shading model.
66///
67/// Computes diffuse + specular contribution from one point light.
68pub fn phong_shading(
69    hit: &HitRecord,
70    light: &PointLight,
71    view_dir: [f64; 3],
72    mat: &Material,
73    shadow: bool,
74) -> [f64; 3] {
75    if shadow {
76        return [0.0; 3];
77    }
78    let light_vec = sub3(light.position, hit.position);
79    let dist = length3(light_vec);
80    let light_dir = normalize3(light_vec);
81    let n_dot_l = dot3(hit.normal, light_dir).max(0.0);
82    let diffuse = scale3(
83        mul_color(mat.albedo, light.color),
84        n_dot_l * light.intensity * light.attenuate(dist),
85    );
86    let reflect_dir = reflect3(scale3(light_dir, -1.0), hit.normal);
87    let r_dot_v = dot3(reflect_dir, view_dir).max(0.0);
88    let spec_factor = r_dot_v.powf(mat.shininess.max(1.0));
89    let specular = scale3(
90        mul_color(light.color, [1.0; 3]),
91        spec_factor * light.intensity * light.attenuate(dist),
92    );
93    add3(diffuse, specular)
94}
95/// Schlick Fresnel approximation.
96pub fn fresnel_schlick(cos_theta: f64, f0: [f64; 3]) -> [f64; 3] {
97    let c = (1.0 - cos_theta).clamp(0.0, 1.0);
98    let c5 = c * c * c * c * c;
99    [
100        f0[0] + (1.0 - f0[0]) * c5,
101        f0[1] + (1.0 - f0[1]) * c5,
102        f0[2] + (1.0 - f0[2]) * c5,
103    ]
104}
105/// GGX normal distribution function.
106pub fn distribution_ggx(n_dot_h: f64, roughness: f64) -> f64 {
107    let a = roughness * roughness;
108    let a2 = a * a;
109    let n_dot_h2 = n_dot_h * n_dot_h;
110    let denom = n_dot_h2 * (a2 - 1.0) + 1.0;
111    if denom.abs() < 1e-15 {
112        return 0.0;
113    }
114    a2 / (PI * denom * denom)
115}
116/// Smith's geometry function (GGX).
117pub fn geometry_smith(n_dot_v: f64, n_dot_l: f64, roughness: f64) -> f64 {
118    let r = roughness + 1.0;
119    let k = r * r / 8.0;
120    let ggx1 = n_dot_v / (n_dot_v * (1.0 - k) + k);
121    let ggx2 = n_dot_l / (n_dot_l * (1.0 - k) + k);
122    ggx1 * ggx2
123}
124/// Cook-Torrance PBR BRDF shading.
125pub fn pbr_shading(
126    hit: &HitRecord,
127    light: &PointLight,
128    view_dir: [f64; 3],
129    mat: &Material,
130    shadow: bool,
131) -> [f64; 3] {
132    if shadow {
133        return [0.0; 3];
134    }
135    let light_vec = sub3(light.position, hit.position);
136    let dist = length3(light_vec);
137    let l = normalize3(light_vec);
138    let n = hit.normal;
139    let v = view_dir;
140    let h = normalize3(add3(v, l));
141    let n_dot_l = dot3(n, l).max(0.0);
142    if n_dot_l < 1e-10 {
143        return [0.0; 3];
144    }
145    let n_dot_v = dot3(n, v).max(1e-4);
146    let n_dot_h = dot3(n, h).max(0.0);
147    let h_dot_v = dot3(h, v).max(0.0);
148    let f0_dielectric = [0.04; 3];
149    let f0 = [
150        f0_dielectric[0] * (1.0 - mat.metallic) + mat.albedo[0] * mat.metallic,
151        f0_dielectric[1] * (1.0 - mat.metallic) + mat.albedo[1] * mat.metallic,
152        f0_dielectric[2] * (1.0 - mat.metallic) + mat.albedo[2] * mat.metallic,
153    ];
154    let f = fresnel_schlick(h_dot_v, f0);
155    let d = distribution_ggx(n_dot_h, mat.roughness);
156    let g = geometry_smith(n_dot_v, n_dot_l, mat.roughness);
157    let denom = 4.0 * n_dot_v * n_dot_l;
158    let specular = if denom > 1e-10 {
159        scale3(f, d * g / denom)
160    } else {
161        [0.0; 3]
162    };
163    let kd = [
164        (1.0 - f[0]) * (1.0 - mat.metallic),
165        (1.0 - f[1]) * (1.0 - mat.metallic),
166        (1.0 - f[2]) * (1.0 - mat.metallic),
167    ];
168    let diffuse = [
169        kd[0] * mat.albedo[0] / PI,
170        kd[1] * mat.albedo[1] / PI,
171        kd[2] * mat.albedo[2] / PI,
172    ];
173    let radiance = scale3(light.color, light.intensity * light.attenuate(dist));
174    let result = add3(diffuse, specular);
175    [
176        result[0] * radiance[0] * n_dot_l,
177        result[1] * radiance[1] * n_dot_l,
178        result[2] * radiance[2] * n_dot_l,
179    ]
180}
181/// Compute soft shadow factor (0=fully shadowed, 1=fully lit) by sampling
182/// an area light with `num_samples` jittered samples.
183pub fn soft_shadow_factor(
184    hit_pos: [f64; 3],
185    hit_normal: [f64; 3],
186    light: &AreaLight,
187    bvh: &Bvh,
188    triangles: &[Triangle],
189    num_samples: usize,
190    samples: &[[f64; 2]],
191) -> f64 {
192    if num_samples == 0 || samples.is_empty() {
193        return 1.0;
194    }
195    let actual_samples = num_samples.min(samples.len());
196    let mut unblocked = 0u32;
197    for sample in &samples[..actual_samples] {
198        let su = sample[0] * 2.0 - 1.0;
199        let sv = sample[1] * 2.0 - 1.0;
200        let light_point = light.sample_point(su, sv);
201        let to_light = sub3(light_point, hit_pos);
202        let dist = length3(to_light);
203        if dist < 1e-10 {
204            unblocked += 1;
205            continue;
206        }
207        let dir = scale3(to_light, 1.0 / dist);
208        if dot3(hit_normal, dir) <= 0.0 {
209            continue;
210        }
211        let mut shadow_ray = Ray::new(add3(hit_pos, scale3(hit_normal, 1e-4)), dir);
212        shadow_ray.t_max = dist - 1e-4;
213        if !bvh.intersect_any(&shadow_ray, triangles) {
214            unblocked += 1;
215        }
216    }
217    unblocked as f64 / actual_samples as f64
218}
219/// Compute ambient occlusion by casting rays in the hemisphere.
220///
221/// Returns a value in \[0, 1\] where 0 = fully occluded, 1 = fully open.
222pub fn ambient_occlusion(
223    hit_pos: [f64; 3],
224    hit_normal: [f64; 3],
225    bvh: &Bvh,
226    triangles: &[Triangle],
227    hemisphere_samples: &[[f64; 3]],
228    max_dist: f64,
229) -> f64 {
230    if hemisphere_samples.is_empty() {
231        return 1.0;
232    }
233    let tangent = if hit_normal[0].abs() < 0.9 {
234        normalize3(cross3(hit_normal, [1.0, 0.0, 0.0]))
235    } else {
236        normalize3(cross3(hit_normal, [0.0, 1.0, 0.0]))
237    };
238    let bitangent = cross3(hit_normal, tangent);
239    let mut unoccluded = 0u32;
240    let n = hemisphere_samples.len();
241    for s in hemisphere_samples {
242        let world_dir = normalize3(add3(
243            add3(scale3(tangent, s[0]), scale3(bitangent, s[1])),
244            scale3(hit_normal, s[2].abs()),
245        ));
246        if dot3(world_dir, hit_normal) <= 0.0 {
247            unoccluded += 1;
248            continue;
249        }
250        let origin = add3(hit_pos, scale3(hit_normal, 1e-4));
251        let mut ao_ray = Ray::new(origin, world_dir);
252        ao_ray.t_max = max_dist;
253        if !bvh.intersect_any(&ao_ray, triangles) {
254            unoccluded += 1;
255        }
256    }
257    unoccluded as f64 / n as f64
258}
259/// Schlick approximation for reflectance at a dielectric interface.
260pub fn schlick_reflectance(cos_theta: f64, ior_ratio: f64) -> f64 {
261    let r0 = ((1.0 - ior_ratio) / (1.0 + ior_ratio)).powi(2);
262    r0 + (1.0 - r0) * (1.0 - cos_theta).powi(5)
263}
264/// Compute refraction direction using Snell's law.
265///
266/// Returns `None` if total internal reflection occurs.
267pub fn refract(d: [f64; 3], n: [f64; 3], ior_ratio: f64) -> Option<[f64; 3]> {
268    let cos_theta = dot3(scale3(d, -1.0), n).min(1.0);
269    let sin_theta_sq = 1.0 - cos_theta * cos_theta;
270    if sin_theta_sq * ior_ratio * ior_ratio > 1.0 {
271        return None;
272    }
273    let r_out_perp = scale3(add3(d, scale3(n, cos_theta)), ior_ratio);
274    let r_out_parallel = scale3(n, -(1.0 - dot3(r_out_perp, r_out_perp)).abs().sqrt());
275    Some(normalize3(add3(r_out_perp, r_out_parallel)))
276}
277/// Simple hemisphere sampler using cosine-weighted distribution.
278///
279/// Takes two uniform samples u1, u2 in \[0,1) and returns a direction
280/// in the upper hemisphere (y > 0) in local space.
281pub fn cosine_sample_hemisphere(u1: f64, u2: f64) -> [f64; 3] {
282    let r = u1.sqrt();
283    let theta = 2.0 * PI * u2;
284    let x = r * theta.cos();
285    let z = r * theta.sin();
286    let y = (1.0 - u1).max(0.0).sqrt();
287    [x, y, z]
288}
289/// Uniform hemisphere sampler.
290pub fn uniform_sample_hemisphere(u1: f64, u2: f64) -> [f64; 3] {
291    let cos_theta = u1;
292    let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
293    let phi = 2.0 * PI * u2;
294    [sin_theta * phi.cos(), cos_theta, sin_theta * phi.sin()]
295}
296/// Trace a single path and accumulate radiance.
297///
298/// This is a simplified CPU-side path trace step (one bounce).
299pub fn path_trace_step(
300    state: &mut PathState,
301    bvh: &Bvh,
302    triangles: &[Triangle],
303    materials: &[Material],
304    background: [f64; 3],
305    u1: f64,
306    u2: f64,
307) -> bool {
308    if !state.should_continue() {
309        if state.depth == 0 {
310            state.radiance = background;
311        }
312        return false;
313    }
314    match bvh.intersect(&state.ray, triangles) {
315        None => {
316            let contrib = [
317                state.throughput[0] * background[0],
318                state.throughput[1] * background[1],
319                state.throughput[2] * background[2],
320            ];
321            state.radiance = add3(state.radiance, contrib);
322            false
323        }
324        Some((hit, _tri)) => {
325            let mat = if (hit.material_id as usize) < materials.len() {
326                &materials[hit.material_id as usize]
327            } else {
328                &materials[0]
329            };
330            let emission_contrib = [
331                state.throughput[0] * mat.emission[0],
332                state.throughput[1] * mat.emission[1],
333                state.throughput[2] * mat.emission[2],
334            ];
335            state.radiance = add3(state.radiance, emission_contrib);
336            let local_dir = cosine_sample_hemisphere(u1, u2);
337            let tangent = if hit.normal[0].abs() < 0.9 {
338                normalize3(cross3(hit.normal, [1.0, 0.0, 0.0]))
339            } else {
340                normalize3(cross3(hit.normal, [0.0, 1.0, 0.0]))
341            };
342            let bitangent = cross3(hit.normal, tangent);
343            let world_dir = normalize3(add3(
344                add3(
345                    scale3(tangent, local_dir[0]),
346                    scale3(bitangent, local_dir[2]),
347                ),
348                scale3(hit.normal, local_dir[1]),
349            ));
350            let cos_theta = dot3(hit.normal, world_dir).max(0.0);
351            state.throughput = [
352                state.throughput[0] * mat.albedo[0] * cos_theta * 2.0,
353                state.throughput[1] * mat.albedo[1] * cos_theta * 2.0,
354                state.throughput[2] * mat.albedo[2] * cos_theta * 2.0,
355            ];
356            state.ray = Ray::new(add3(hit.position, scale3(hit.normal, 1e-4)), world_dir);
357            state.depth += 1;
358            true
359        }
360    }
361}
362/// A-trous wavelet denoising pass for progressive path traced images.
363///
364/// `color` is a flat buffer of (r, g, b) triples in row-major order.
365/// `width` and `height` are the image dimensions.
366/// `step_width` is the kernel step (power of two: 1, 2, 4, 8, ...).
367pub fn atrous_denoise(
368    color: &[[f64; 3]],
369    normal: &[[f64; 3]],
370    position: &[[f64; 3]],
371    width: usize,
372    height: usize,
373    step_width: usize,
374    sigma_color: f64,
375    sigma_normal: f64,
376    sigma_position: f64,
377) -> Vec<[f64; 3]> {
378    let kernel = [
379        [
380            1.0f64 / 256.0,
381            1.0 / 64.0,
382            3.0 / 128.0,
383            1.0 / 64.0,
384            1.0 / 256.0,
385        ],
386        [1.0 / 64.0, 1.0 / 16.0, 3.0 / 32.0, 1.0 / 16.0, 1.0 / 64.0],
387        [3.0 / 128.0, 3.0 / 32.0, 9.0 / 64.0, 3.0 / 32.0, 3.0 / 128.0],
388        [1.0 / 64.0, 1.0 / 16.0, 3.0 / 32.0, 1.0 / 16.0, 1.0 / 64.0],
389        [
390            1.0 / 256.0,
391            1.0 / 64.0,
392            3.0 / 128.0,
393            1.0 / 64.0,
394            1.0 / 256.0,
395        ],
396    ];
397    let n = width * height;
398    let mut output = vec![[0.0f64; 3]; n];
399    for py in 0..height {
400        for px in 0..width {
401            let idx = py * width + px;
402            let c_center = color[idx];
403            let n_center = normal[idx];
404            let p_center = position[idx];
405            let mut accum = [0.0f64; 3];
406            let mut weight_sum = 0.0f64;
407            for ky in 0..5i32 {
408                for kx in 0..5i32 {
409                    let oy = ky - 2;
410                    let ox = kx - 2;
411                    let nx = px as i32 + ox * step_width as i32;
412                    let ny = py as i32 + oy * step_width as i32;
413                    if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
414                        continue;
415                    }
416                    let sidx = ny as usize * width + nx as usize;
417                    let c_s = color[sidx];
418                    let n_s = normal[sidx];
419                    let p_s = position[sidx];
420                    let dc = [
421                        c_center[0] - c_s[0],
422                        c_center[1] - c_s[1],
423                        c_center[2] - c_s[2],
424                    ];
425                    let dist_c = dc[0] * dc[0] + dc[1] * dc[1] + dc[2] * dc[2];
426                    let w_c = (-dist_c / (sigma_color * sigma_color)).exp();
427                    let dn_x = n_center[0] - n_s[0];
428                    let dn_y = n_center[1] - n_s[1];
429                    let dn_z = n_center[2] - n_s[2];
430                    let dist_n = dn_x * dn_x + dn_y * dn_y + dn_z * dn_z;
431                    let w_n = (-dist_n / (sigma_normal * sigma_normal)).exp();
432                    let dp_x = p_center[0] - p_s[0];
433                    let dp_y = p_center[1] - p_s[1];
434                    let dp_z = p_center[2] - p_s[2];
435                    let dist_p = dp_x * dp_x + dp_y * dp_y + dp_z * dp_z;
436                    let w_p = (-dist_p / (sigma_position * sigma_position)).exp();
437                    let h_weight = kernel[ky as usize][kx as usize];
438                    let w = h_weight * w_c * w_n * w_p;
439                    accum[0] += w * c_s[0];
440                    accum[1] += w * c_s[1];
441                    accum[2] += w * c_s[2];
442                    weight_sum += w;
443                }
444            }
445            if weight_sum > 1e-10 {
446                output[idx] = [
447                    accum[0] / weight_sum,
448                    accum[1] / weight_sum,
449                    accum[2] / weight_sum,
450                ];
451            } else {
452                output[idx] = c_center;
453            }
454        }
455    }
456    output
457}
458/// Temporal accumulation (exponential moving average) denoising.
459///
460/// Blends current frame with previous frame using history buffer.
461pub fn temporal_accumulate(
462    current: &[[f64; 3]],
463    history: &[[f64; 3]],
464    alpha: f64,
465) -> Vec<[f64; 3]> {
466    let n = current.len().min(history.len());
467    let mut result = Vec::with_capacity(n);
468    for (&c, &h) in current[..n].iter().zip(&history[..n]) {
469        result.push([
470            alpha * c[0] + (1.0 - alpha) * h[0],
471            alpha * c[1] + (1.0 - alpha) * h[1],
472            alpha * c[2] + (1.0 - alpha) * h[2],
473        ]);
474    }
475    result
476}
477/// Box filter denoising pass (simple spatial blur).
478pub fn box_filter(color: &[[f64; 3]], width: usize, height: usize, radius: usize) -> Vec<[f64; 3]> {
479    let n = width * height;
480    let mut output = vec![[0.0f64; 3]; n];
481    for py in 0..height {
482        for px in 0..width {
483            let mut accum = [0.0f64; 3];
484            let mut count = 0u32;
485            let y0 = py.saturating_sub(radius);
486            let y1 = (py + radius + 1).min(height);
487            let x0 = px.saturating_sub(radius);
488            let x1 = (px + radius + 1).min(width);
489            for sy in y0..y1 {
490                for sx in x0..x1 {
491                    let sidx = sy * width + sx;
492                    accum[0] += color[sidx][0];
493                    accum[1] += color[sidx][1];
494                    accum[2] += color[sidx][2];
495                    count += 1;
496                }
497            }
498            let inv = 1.0 / count as f64;
499            output[py * width + px] = [accum[0] * inv, accum[1] * inv, accum[2] * inv];
500        }
501    }
502    output
503}
504/// Reinhard tone mapping operator.
505pub fn tonemap_reinhard(color: [f64; 3]) -> [f64; 3] {
506    [
507        color[0] / (1.0 + color[0]),
508        color[1] / (1.0 + color[1]),
509        color[2] / (1.0 + color[2]),
510    ]
511}
512/// Filmic tone mapping (Hejl and Burgess-Dawson approximation).
513pub fn tonemap_filmic(color: [f64; 3]) -> [f64; 3] {
514    let f = |x: f64| {
515        let x = (x - 0.004).max(0.0);
516        (x * (6.2 * x + 0.5)) / (x * (6.2 * x + 1.7) + 0.06)
517    };
518    [f(color[0]), f(color[1]), f(color[2])]
519}
520/// ACES filmic tone mapping.
521pub fn tonemap_aces(color: [f64; 3]) -> [f64; 3] {
522    let aces = |x: f64| {
523        let a = 2.51;
524        let b = 0.03;
525        let c = 2.43;
526        let d = 0.59;
527        let e = 0.14;
528        ((x * (a * x + b)) / (x * (c * x + d) + e)).clamp(0.0, 1.0)
529    };
530    [aces(color[0]), aces(color[1]), aces(color[2])]
531}
532/// Linear to sRGB gamma correction.
533pub fn linear_to_srgb(c: f64) -> f64 {
534    if c <= 0.0031308 {
535        c * 12.92
536    } else {
537        1.055 * c.powf(1.0 / 2.4) - 0.055
538    }
539}
540/// Apply sRGB gamma to a color.
541pub fn gamma_correct(color: [f64; 3]) -> [f64; 3] {
542    [
543        linear_to_srgb(color[0].clamp(0.0, 1.0)),
544        linear_to_srgb(color[1].clamp(0.0, 1.0)),
545        linear_to_srgb(color[2].clamp(0.0, 1.0)),
546    ]
547}
548/// Ray trace a simple scene (direct illumination only) on the CPU.
549///
550/// Returns a flat buffer of (r, g, b) per pixel in row-major order.
551pub fn render_direct(
552    config: &RenderConfig,
553    camera: &Camera,
554    bvh: &Bvh,
555    triangles: &[Triangle],
556    materials: &[Material],
557    lights: &[PointLight],
558) -> Vec<[f64; 3]> {
559    let n = config.width * config.height;
560    let mut image = vec![[0.0f64; 3]; n];
561    let w = config.width as f64;
562    let h = config.height as f64;
563    for py in 0..config.height {
564        for px in 0..config.width {
565            let ray = camera.generate_ray(px as f64, py as f64, w, h);
566            let color = trace_direct(&ray, bvh, triangles, materials, lights, config);
567            let idx = py * config.width + px;
568            image[idx] = match config.tonemap {
569                1 => gamma_correct(tonemap_reinhard(color)),
570                2 => gamma_correct(tonemap_filmic(color)),
571                3 => gamma_correct(tonemap_aces(color)),
572                _ => gamma_correct(color),
573            };
574        }
575    }
576    image
577}
578/// Trace a single ray for direct illumination.
579pub(super) fn trace_direct(
580    ray: &Ray,
581    bvh: &Bvh,
582    triangles: &[Triangle],
583    materials: &[Material],
584    lights: &[PointLight],
585    config: &RenderConfig,
586) -> [f64; 3] {
587    match bvh.intersect(ray, triangles) {
588        None => config.background,
589        Some((hit, _tri)) => {
590            let mat = if (hit.material_id as usize) < materials.len() {
591                &materials[hit.material_id as usize]
592            } else {
593                return config.background;
594            };
595            if mat.mat_type == MaterialType::Emissive {
596                return mat.emission;
597            }
598            let view_dir = normalize3(scale3(ray.direction, -1.0));
599            let mut color = config.ambient;
600            for light in lights {
601                let to_light = sub3(light.position, hit.position);
602                let dist = length3(to_light);
603                let light_dir = normalize3(to_light);
604                let shadow_origin = add3(hit.position, scale3(hit.normal, 1e-4));
605                let mut shadow_ray = Ray::new(shadow_origin, light_dir);
606                shadow_ray.t_max = dist - 1e-4;
607                let in_shadow = bvh.intersect_any(&shadow_ray, triangles);
608                let contrib = if mat.mat_type == MaterialType::Pbr {
609                    pbr_shading(&hit, light, view_dir, mat, in_shadow)
610                } else {
611                    phong_shading(&hit, light, view_dir, mat, in_shadow)
612                };
613                color = add3(color, contrib);
614            }
615            clamp_color(color)
616        }
617    }
618}
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use crate::raytracing::Aabb;
623    use crate::raytracing::Scene;
624    fn make_floor_triangle() -> Triangle {
625        Triangle::new(
626            [-5.0, 0.0, -5.0],
627            [5.0, 0.0, -5.0],
628            [0.0, 0.0, 5.0],
629            [0.0, 1.0, 0.0],
630            [0.0, 1.0, 0.0],
631            [0.0, 1.0, 0.0],
632            [0.0, 0.0],
633            [1.0, 0.0],
634            [0.5, 1.0],
635            0,
636        )
637    }
638    #[test]
639    fn test_ray_at() {
640        let ray = Ray::new([0.0; 3], [0.0, 0.0, 1.0]);
641        let p = ray.at(3.0);
642        assert!((p[2] - 3.0).abs() < 1e-12);
643    }
644    #[test]
645    fn test_triangle_intersect_hit() {
646        let tri = make_floor_triangle();
647        let ray = Ray::new([0.0, 5.0, 0.0], [0.0, -1.0, 0.0]);
648        assert!(tri.intersect(&ray).is_some());
649    }
650    #[test]
651    fn test_triangle_intersect_miss_parallel() {
652        let tri = make_floor_triangle();
653        let ray = Ray::new([0.0, 5.0, 0.0], [1.0, 0.0, 0.0]);
654        assert!(tri.intersect(&ray).is_none());
655    }
656    #[test]
657    fn test_triangle_intersect_miss_outside() {
658        let tri = make_floor_triangle();
659        let ray = Ray::new([100.0, 5.0, 0.0], [0.0, -1.0, 0.0]);
660        assert!(tri.intersect(&ray).is_none());
661    }
662    #[test]
663    fn test_triangle_geometric_normal() {
664        let tri = Triangle::new(
665            [0.0, 0.0, 0.0],
666            [1.0, 0.0, 0.0],
667            [0.0, 1.0, 0.0],
668            [0.0, 0.0, 1.0],
669            [0.0, 0.0, 1.0],
670            [0.0, 0.0, 1.0],
671            [0.0, 0.0],
672            [1.0, 0.0],
673            [0.0, 1.0],
674            0,
675        );
676        let n = tri.geometric_normal();
677        assert!((n[2] - 1.0).abs() < 1e-10 || (n[2] + 1.0).abs() < 1e-10);
678    }
679    #[test]
680    fn test_triangle_area() {
681        let tri = Triangle::new(
682            [0.0, 0.0, 0.0],
683            [2.0, 0.0, 0.0],
684            [0.0, 2.0, 0.0],
685            [0.0, 0.0, 1.0],
686            [0.0, 0.0, 1.0],
687            [0.0, 0.0, 1.0],
688            [0.0, 0.0],
689            [1.0, 0.0],
690            [0.0, 1.0],
691            0,
692        );
693        assert!((tri.area() - 2.0).abs() < 1e-10);
694    }
695    #[test]
696    fn test_aabb_merge() {
697        let a = Aabb::new([0.0; 3], [1.0; 3]);
698        let b = Aabb::new([-1.0; 3], [2.0; 3]);
699        let merged = a.merge(&b);
700        assert!((merged.min[0] + 1.0).abs() < 1e-12);
701        assert!((merged.max[0] - 2.0).abs() < 1e-12);
702    }
703    #[test]
704    fn test_aabb_ray_hit() {
705        let aabb = Aabb::new([-1.0; 3], [1.0; 3]);
706        let ray = Ray::new([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
707        assert!(aabb.intersect_ray(&ray).is_some());
708    }
709    #[test]
710    fn test_aabb_ray_miss() {
711        let aabb = Aabb::new([-1.0; 3], [1.0; 3]);
712        let ray = Ray::new([5.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
713        assert!(aabb.intersect_ray(&ray).is_none());
714    }
715    #[test]
716    fn test_aabb_longest_axis() {
717        let aabb = Aabb::new([0.0, 0.0, 0.0], [3.0, 1.0, 2.0]);
718        assert_eq!(aabb.longest_axis(), 0);
719    }
720    #[test]
721    fn test_bvh_build_empty() {
722        let bvh = Bvh::build(&[]);
723        assert_eq!(bvh.prim_count, 0);
724    }
725    #[test]
726    fn test_bvh_build_single_triangle() {
727        let tri = make_floor_triangle();
728        let bvh = Bvh::build(&[tri]);
729        assert_eq!(bvh.prim_count, 1);
730    }
731    #[test]
732    fn test_bvh_intersect_hit() {
733        let tri = make_floor_triangle();
734        let triangles = vec![tri];
735        let bvh = Bvh::build(&triangles);
736        let ray = Ray::new([0.0, 5.0, 0.0], [0.0, -1.0, 0.0]);
737        assert!(bvh.intersect(&ray, &triangles).is_some());
738    }
739    #[test]
740    fn test_bvh_intersect_miss() {
741        let tri = make_floor_triangle();
742        let triangles = vec![tri];
743        let bvh = Bvh::build(&triangles);
744        let ray = Ray::new([0.0, 5.0, 0.0], [0.0, 1.0, 0.0]);
745        assert!(bvh.intersect(&ray, &triangles).is_none());
746    }
747    #[test]
748    fn test_bvh_intersect_any_shadow() {
749        let tri = make_floor_triangle();
750        let triangles = vec![tri];
751        let bvh = Bvh::build(&triangles);
752        let ray = Ray::new([0.0, 5.0, 0.0], [0.0, -1.0, 0.0]);
753        assert!(bvh.intersect_any(&ray, &triangles));
754    }
755    #[test]
756    fn test_camera_generate_ray() {
757        let cam = Camera::look_at(
758            [0.0, 0.0, 5.0],
759            [0.0, 0.0, 0.0],
760            [0.0, 1.0, 0.0],
761            60.0,
762            16.0 / 9.0,
763            0.0,
764            5.0,
765        );
766        let ray = cam.generate_ray(400.0, 300.0, 800.0, 600.0);
767        assert!(ray.direction[2] < 0.0);
768    }
769    #[test]
770    fn test_fresnel_schlick_zero_angle() {
771        let f0 = [0.04; 3];
772        let f = fresnel_schlick(0.0, f0);
773        assert!((f[0] - 1.0).abs() < 1e-10);
774    }
775    #[test]
776    fn test_fresnel_schlick_one_angle() {
777        let f0 = [0.04; 3];
778        let f = fresnel_schlick(1.0, f0);
779        assert!((f[0] - 0.04).abs() < 1e-10);
780    }
781    #[test]
782    fn test_distribution_ggx_smooth() {
783        let d = distribution_ggx(1.0, 0.01);
784        assert!(d > 100.0);
785    }
786    #[test]
787    fn test_refract_no_tir() {
788        let d = normalize3([0.0, -1.0, 0.0]);
789        let n = [0.0, 1.0, 0.0];
790        let result = refract(d, n, 1.0 / 1.5);
791        assert!(result.is_some());
792    }
793    #[test]
794    fn test_refract_tir() {
795        let d = normalize3([0.9, -0.1, 0.0]);
796        let n = [0.0, 1.0, 0.0];
797        let result = refract(d, n, 1.5);
798        assert!(result.is_none());
799    }
800    #[test]
801    fn test_cosine_sample_hemisphere() {
802        let s = cosine_sample_hemisphere(0.5, 0.5);
803        let len = (s[0] * s[0] + s[1] * s[1] + s[2] * s[2]).sqrt();
804        assert!((len - 1.0).abs() < 1e-10);
805        assert!(s[1] >= 0.0);
806    }
807    #[test]
808    fn test_tonemap_reinhard() {
809        let c = tonemap_reinhard([2.0, 1.0, 0.5]);
810        assert!((c[0] - 2.0 / 3.0).abs() < 1e-10);
811    }
812    #[test]
813    fn test_tonemap_aces_clamp() {
814        let c = tonemap_aces([100.0, 100.0, 100.0]);
815        assert!(c[0] <= 1.0);
816        assert!(c[0] >= 0.0);
817    }
818    #[test]
819    fn test_linear_to_srgb_zero() {
820        assert!((linear_to_srgb(0.0)).abs() < 1e-10);
821    }
822    #[test]
823    fn test_linear_to_srgb_one() {
824        assert!((linear_to_srgb(1.0) - 1.0).abs() < 1e-6);
825    }
826    #[test]
827    fn test_box_filter_single_pixel() {
828        let image = vec![[1.0f64, 0.0, 0.0]];
829        let result = box_filter(&image, 1, 1, 1);
830        assert_eq!(result.len(), 1);
831        assert!((result[0][0] - 1.0).abs() < 1e-10);
832    }
833    #[test]
834    fn test_temporal_accumulate() {
835        let current = vec![[1.0f64; 3]];
836        let history = vec![[0.0f64; 3]];
837        let result = temporal_accumulate(&current, &history, 0.5);
838        assert!((result[0][0] - 0.5).abs() < 1e-10);
839    }
840    #[test]
841    fn test_scene_add_box() {
842        let mut scene = Scene::new();
843        let mid = scene.add_material(Material::diffuse([0.8, 0.2, 0.2]));
844        scene.add_box([0.0; 3], [1.0; 3], mid);
845        assert_eq!(scene.triangles.len(), 12);
846    }
847    #[test]
848    fn test_scene_build_and_intersect() {
849        let mut scene = Scene::new();
850        let mid = scene.add_material(Material::diffuse([0.8, 0.8, 0.8]));
851        scene.add_box([0.0; 3], [1.0; 3], mid);
852        scene.add_light(PointLight::new([5.0, 5.0, 5.0], [1.0; 3], 1.0));
853        scene.build_bvh();
854        let ray = Ray::new([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
855        assert!(scene.intersect(&ray).is_some());
856    }
857    #[test]
858    fn test_render_direct_small() {
859        let mut scene = Scene::new();
860        let mid = scene.add_material(Material::diffuse([0.8, 0.8, 0.8]));
861        scene.add_box([0.0; 3], [1.0; 3], mid);
862        scene.add_light(PointLight::new([5.0, 5.0, 5.0], [1.0; 3], 1.0));
863        scene.build_bvh();
864        let cam = Camera::look_at(
865            [0.0, 0.0, 5.0],
866            [0.0, 0.0, 0.0],
867            [0.0, 1.0, 0.0],
868            60.0,
869            4.0 / 3.0,
870            0.0,
871            5.0,
872        );
873        let config = RenderConfig {
874            width: 4,
875            height: 3,
876            ..Default::default()
877        };
878        let image = render_direct(
879            &config,
880            &cam,
881            scene.bvh.as_ref().unwrap(),
882            &scene.triangles,
883            &scene.materials,
884            &scene.lights,
885        );
886        assert_eq!(image.len(), 12);
887        for pixel in &image {
888            for c in pixel {
889                assert!(*c >= 0.0 && *c <= 1.0);
890            }
891        }
892    }
893    #[test]
894    fn test_path_state_should_continue() {
895        let ray = Ray::new([0.0; 3], [0.0, 0.0, 1.0]);
896        let mut state = PathState::new(ray, 4);
897        assert!(state.should_continue());
898        state.depth = 4;
899        assert!(!state.should_continue());
900    }
901    #[test]
902    fn test_reflect3_down_up_normal() {
903        let d = [0.0, -1.0, 0.0];
904        let n = [0.0, 1.0, 0.0];
905        let r = reflect3(d, n);
906        assert!((r[1] - 1.0).abs() < 1e-10);
907    }
908    #[test]
909    fn test_point_light_attenuation() {
910        let light = PointLight::new([0.0; 3], [1.0; 3], 1.0);
911        let att = light.attenuate(0.0);
912        assert!((att - 1.0).abs() < 1e-10);
913    }
914    #[test]
915    fn test_pbr_material_creation() {
916        let mat = Material::pbr([0.5; 3], 0.0, 0.5, 1.0);
917        assert_eq!(mat.mat_type, MaterialType::Pbr);
918        assert!((mat.roughness - 0.5).abs() < 1e-10);
919    }
920    #[test]
921    fn test_atrous_denoise_flat_image() {
922        let n = 4 * 4;
923        let color = vec![[0.5f64, 0.5, 0.5]; n];
924        let normal = vec![[0.0f64, 1.0, 0.0]; n];
925        let position = vec![[0.0f64; 3]; n];
926        let result = atrous_denoise(&color, &normal, &position, 4, 4, 1, 0.1, 0.1, 0.1);
927        assert_eq!(result.len(), n);
928        for p in &result {
929            assert!((p[0] - 0.5).abs() < 0.01);
930        }
931    }
932}