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