Skip to main content

oxiphysics_gpu/
path_tracer.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! CPU path tracer using GPU-style algorithms.
5//!
6//! Implements a Monte Carlo path tracer with Lambertian, Metal, and Dielectric
7//! materials, sphere and triangle primitives, and a progressive pixel buffer.
8
9use rand::Rng;
10
11use rand::RngExt;
12// ── Vector helpers (f32, 3D) ─────────────────────────────────────────────────
13
14#[inline]
15fn vadd(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
16    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
17}
18
19#[inline]
20fn vsub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
21    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
22}
23
24#[inline]
25fn vmul(a: [f32; 3], s: f32) -> [f32; 3] {
26    [a[0] * s, a[1] * s, a[2] * s]
27}
28
29#[inline]
30fn vmul3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
31    [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
32}
33
34#[inline]
35fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
36    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
37}
38
39#[inline]
40fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
41    [
42        a[1] * b[2] - a[2] * b[1],
43        a[2] * b[0] - a[0] * b[2],
44        a[0] * b[1] - a[1] * b[0],
45    ]
46}
47
48#[inline]
49fn length(v: [f32; 3]) -> f32 {
50    dot(v, v).sqrt()
51}
52
53#[inline]
54fn normalize(v: [f32; 3]) -> [f32; 3] {
55    let l = length(v);
56    if l < 1e-8 {
57        return [0.0; 3];
58    }
59    vmul(v, 1.0 / l)
60}
61
62#[inline]
63fn reflect(d: [f32; 3], n: [f32; 3]) -> [f32; 3] {
64    vsub(d, vmul(n, 2.0 * dot(d, n)))
65}
66
67fn refract(uv: [f32; 3], n: [f32; 3], ni_over_nt: f32) -> Option<[f32; 3]> {
68    let cos_theta = (-dot(uv, n)).min(1.0);
69    let r_out_perp = vmul(vadd(uv, vmul(n, cos_theta)), ni_over_nt);
70    let r_out_parallel_len2 = (1.0 - dot(r_out_perp, r_out_perp)).abs();
71    let r_out_parallel = vmul(n, -(r_out_parallel_len2.sqrt()));
72    Some(vadd(r_out_perp, r_out_parallel))
73}
74
75fn schlick(cosine: f32, ref_idx: f32) -> f32 {
76    let r0 = ((1.0 - ref_idx) / (1.0 + ref_idx)).powi(2);
77    r0 + (1.0 - r0) * (1.0 - cosine).powi(5)
78}
79
80fn random_in_unit_sphere(rng: &mut impl Rng) -> [f32; 3] {
81    loop {
82        let v = [
83            rng.random_range(-1.0f32..1.0),
84            rng.random_range(-1.0f32..1.0),
85            rng.random_range(-1.0f32..1.0),
86        ];
87        if dot(v, v) < 1.0 {
88            return v;
89        }
90    }
91}
92
93fn random_unit_vector(rng: &mut impl Rng) -> [f32; 3] {
94    normalize(random_in_unit_sphere(rng))
95}
96
97// ── Ray ──────────────────────────────────────────────────────────────────────
98
99/// A ray with an origin and a direction.
100#[derive(Debug, Clone, Copy)]
101pub struct Ray {
102    /// Ray origin in world space.
103    pub origin: [f32; 3],
104    /// Ray direction (need not be normalised, but usually is).
105    pub direction: [f32; 3],
106}
107
108impl Ray {
109    /// Create a new ray.
110    pub fn new(origin: [f32; 3], direction: [f32; 3]) -> Self {
111        Self { origin, direction }
112    }
113
114    /// Evaluate the ray at parameter `t`: `origin + t * direction`.
115    pub fn at(&self, t: f32) -> [f32; 3] {
116        vadd(self.origin, vmul(self.direction, t))
117    }
118}
119
120// ── Material ─────────────────────────────────────────────────────────────────
121
122/// The scattering type of a material.
123#[derive(Debug, Clone, Copy)]
124pub enum MaterialType {
125    /// Perfectly diffuse (Lambertian) scattering.
126    Lambertian,
127    /// Metallic reflection with a roughness fuzz factor in \[0,1\].
128    Metal(f32),
129    /// Dielectric (glass-like) material with index of refraction.
130    Dielectric(f32),
131}
132
133/// A surface material with albedo colour and scattering type.
134#[derive(Debug, Clone, Copy)]
135pub struct Material {
136    /// Base colour of the material (RGB in \[0,1\]).
137    pub albedo: [f32; 3],
138    /// Scattering behaviour.
139    pub kind: MaterialType,
140}
141
142impl Material {
143    /// Create a Lambertian (diffuse) material.
144    pub fn lambertian(albedo: [f32; 3]) -> Self {
145        Self {
146            albedo,
147            kind: MaterialType::Lambertian,
148        }
149    }
150
151    /// Create a metallic material.
152    pub fn metal(albedo: [f32; 3], fuzz: f32) -> Self {
153        Self {
154            albedo,
155            kind: MaterialType::Metal(fuzz.clamp(0.0, 1.0)),
156        }
157    }
158
159    /// Create a dielectric (glass) material.
160    pub fn dielectric(ior: f32) -> Self {
161        Self {
162            albedo: [1.0; 3],
163            kind: MaterialType::Dielectric(ior),
164        }
165    }
166
167    /// Scatter a ray off this material surface.
168    ///
169    /// Returns `Some((scattered_ray, attenuation))` or `None` if absorbed.
170    pub fn scatter(
171        &self,
172        ray: &Ray,
173        hit: &HitRecord,
174        rng: &mut impl Rng,
175    ) -> Option<(Ray, [f32; 3])> {
176        match self.kind {
177            MaterialType::Lambertian => {
178                let target = vadd(vadd(hit.point, hit.normal), random_unit_vector(rng));
179                let scattered = Ray::new(hit.point, vsub(target, hit.point));
180                Some((scattered, self.albedo))
181            }
182            MaterialType::Metal(fuzz) => {
183                let reflected = reflect(normalize(ray.direction), hit.normal);
184                let fuzzed = vadd(reflected, vmul(random_in_unit_sphere(rng), fuzz));
185                if dot(fuzzed, hit.normal) > 0.0 {
186                    Some((Ray::new(hit.point, fuzzed), self.albedo))
187                } else {
188                    None
189                }
190            }
191            MaterialType::Dielectric(ior) => {
192                let attenuation = [1.0f32; 3];
193                let refraction_ratio = if hit.front_face { 1.0 / ior } else { ior };
194                let unit_dir = normalize(ray.direction);
195                let cos_theta = (-dot(unit_dir, hit.normal)).min(1.0);
196                let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
197                let cannot_refract = refraction_ratio * sin_theta > 1.0;
198                let scattered_dir = if cannot_refract
199                    || schlick(cos_theta, refraction_ratio) > rng.random::<f32>()
200                {
201                    reflect(unit_dir, hit.normal)
202                } else {
203                    refract(unit_dir, hit.normal, refraction_ratio)
204                        .unwrap_or_else(|| reflect(unit_dir, hit.normal))
205                };
206                Some((Ray::new(hit.point, scattered_dir), attenuation))
207            }
208        }
209    }
210}
211
212// ── HitRecord ────────────────────────────────────────────────────────────────
213
214/// Record of a ray–surface intersection.
215#[derive(Debug, Clone, Copy)]
216pub struct HitRecord {
217    /// Ray parameter at intersection.
218    pub t: f32,
219    /// World-space hit point.
220    pub point: [f32; 3],
221    /// Outward-facing surface normal (normalised).
222    pub normal: [f32; 3],
223    /// Index into the scene's material list.
224    pub material_index: usize,
225    /// True when the ray hits the front face.
226    pub front_face: bool,
227}
228
229impl HitRecord {
230    fn new(t: f32, point: [f32; 3], outward_normal: [f32; 3], ray: &Ray, mat: usize) -> Self {
231        let front_face = dot(ray.direction, outward_normal) < 0.0;
232        let normal = if front_face {
233            outward_normal
234        } else {
235            vmul(outward_normal, -1.0)
236        };
237        Self {
238            t,
239            point,
240            normal,
241            material_index: mat,
242            front_face,
243        }
244    }
245}
246
247// ── Sphere ───────────────────────────────────────────────────────────────────
248
249/// A sphere primitive.
250#[derive(Debug, Clone, Copy)]
251pub struct Sphere {
252    /// Centre of the sphere.
253    pub center: [f32; 3],
254    /// Radius of the sphere.
255    pub radius: f32,
256    /// Index into the scene's material list.
257    pub material_index: usize,
258}
259
260impl Sphere {
261    /// Create a new sphere.
262    pub fn new(center: [f32; 3], radius: f32, material_index: usize) -> Self {
263        Self {
264            center,
265            radius,
266            material_index,
267        }
268    }
269
270    /// Test ray–sphere intersection in the interval `(t_min, t_max)`.
271    pub fn hit(&self, ray: &Ray, t_min: f32, t_max: f32) -> Option<HitRecord> {
272        let oc = vsub(ray.origin, self.center);
273        let a = dot(ray.direction, ray.direction);
274        let half_b = dot(oc, ray.direction);
275        let c = dot(oc, oc) - self.radius * self.radius;
276        let discriminant = half_b * half_b - a * c;
277        if discriminant < 0.0 {
278            return None;
279        }
280        let sqrt_d = discriminant.sqrt();
281        let mut root = (-half_b - sqrt_d) / a;
282        if root < t_min || root > t_max {
283            root = (-half_b + sqrt_d) / a;
284            if root < t_min || root > t_max {
285                return None;
286            }
287        }
288        let point = ray.at(root);
289        let outward_normal = vmul(vsub(point, self.center), 1.0 / self.radius);
290        Some(HitRecord::new(
291            root,
292            point,
293            outward_normal,
294            ray,
295            self.material_index,
296        ))
297    }
298}
299
300// ── Triangle ─────────────────────────────────────────────────────────────────
301
302/// A triangle primitive.
303#[derive(Debug, Clone, Copy)]
304pub struct Triangle {
305    /// Vertex A.
306    pub v0: [f32; 3],
307    /// Vertex B.
308    pub v1: [f32; 3],
309    /// Vertex C.
310    pub v2: [f32; 3],
311    /// Precomputed geometric normal (normalised).
312    pub normal: [f32; 3],
313    /// Index into the scene's material list.
314    pub material_index: usize,
315}
316
317impl Triangle {
318    /// Create a new triangle, computing the normal automatically.
319    pub fn new(v0: [f32; 3], v1: [f32; 3], v2: [f32; 3], material_index: usize) -> Self {
320        let edge1 = vsub(v1, v0);
321        let edge2 = vsub(v2, v0);
322        let normal = normalize(cross(edge1, edge2));
323        Self {
324            v0,
325            v1,
326            v2,
327            normal,
328            material_index,
329        }
330    }
331
332    /// Möller–Trumbore ray–triangle intersection.
333    pub fn hit(&self, ray: &Ray, t_min: f32, t_max: f32) -> Option<HitRecord> {
334        const EPSILON: f32 = 1e-7;
335        let edge1 = vsub(self.v1, self.v0);
336        let edge2 = vsub(self.v2, self.v0);
337        let h = cross(ray.direction, edge2);
338        let a = dot(edge1, h);
339        if a.abs() < EPSILON {
340            return None; // parallel
341        }
342        let f = 1.0 / a;
343        let s = vsub(ray.origin, self.v0);
344        let u = f * dot(s, h);
345        if !(0.0..=1.0).contains(&u) {
346            return None;
347        }
348        let q = cross(s, edge1);
349        let v = f * dot(ray.direction, q);
350        if v < 0.0 || u + v > 1.0 {
351            return None;
352        }
353        let t = f * dot(edge2, q);
354        if t < t_min || t > t_max {
355            return None;
356        }
357        let point = ray.at(t);
358        Some(HitRecord::new(
359            t,
360            point,
361            self.normal,
362            ray,
363            self.material_index,
364        ))
365    }
366}
367
368// ── Light ────────────────────────────────────────────────────────────────────
369
370/// A point light source.
371#[derive(Debug, Clone, Copy)]
372pub struct PointLight {
373    /// Light position.
374    pub position: [f32; 3],
375    /// Light colour/intensity (RGB).
376    pub color: [f32; 3],
377    /// Light intensity multiplier.
378    pub intensity: f32,
379}
380
381impl PointLight {
382    /// Create a new point light.
383    pub fn new(position: [f32; 3], color: [f32; 3], intensity: f32) -> Self {
384        Self {
385            position,
386            color,
387            intensity,
388        }
389    }
390}
391
392// ── PathTracerScene ──────────────────────────────────────────────────────────
393
394/// A scene containing geometry, materials, and lights.
395#[derive(Debug, Clone, Default)]
396pub struct PathTracerScene {
397    /// Sphere primitives in the scene.
398    pub spheres: Vec<Sphere>,
399    /// Triangle primitives in the scene.
400    pub triangles: Vec<Triangle>,
401    /// Point lights in the scene.
402    pub lights: Vec<PointLight>,
403    /// Material list shared by all primitives.
404    pub materials: Vec<Material>,
405    /// Background (sky) gradient top colour.
406    pub sky_top: [f32; 3],
407    /// Background (sky) gradient bottom colour.
408    pub sky_bottom: [f32; 3],
409}
410
411impl PathTracerScene {
412    /// Create a new empty scene.
413    pub fn new() -> Self {
414        Self {
415            sky_top: [0.5, 0.7, 1.0],
416            sky_bottom: [1.0, 1.0, 1.0],
417            ..Default::default()
418        }
419    }
420
421    /// Add a material and return its index.
422    pub fn add_material(&mut self, mat: Material) -> usize {
423        let idx = self.materials.len();
424        self.materials.push(mat);
425        idx
426    }
427
428    /// Add a sphere to the scene.
429    pub fn add_sphere(&mut self, sphere: Sphere) {
430        self.spheres.push(sphere);
431    }
432
433    /// Add a triangle to the scene.
434    pub fn add_triangle(&mut self, triangle: Triangle) {
435        self.triangles.push(triangle);
436    }
437
438    /// Add a point light to the scene.
439    pub fn add_light(&mut self, light: PointLight) {
440        self.lights.push(light);
441    }
442
443    /// Find the closest intersection along a ray.
444    pub fn hit_scene(&self, ray: &Ray, t_min: f32, t_max: f32) -> Option<HitRecord> {
445        let mut closest: Option<HitRecord> = None;
446        let mut t_closest = t_max;
447        for sphere in &self.spheres {
448            if let Some(rec) = sphere.hit(ray, t_min, t_closest) {
449                t_closest = rec.t;
450                closest = Some(rec);
451            }
452        }
453        for tri in &self.triangles {
454            if let Some(rec) = tri.hit(ray, t_min, t_closest) {
455                t_closest = rec.t;
456                closest = Some(rec);
457            }
458        }
459        closest
460    }
461
462    /// Sky background colour for a given ray direction.
463    fn sky_color(&self, ray: &Ray) -> [f32; 3] {
464        let unit = normalize(ray.direction);
465        let t = 0.5 * (unit[1] + 1.0);
466        let a = self.sky_bottom;
467        let b = self.sky_top;
468        [
469            a[0] * (1.0 - t) + b[0] * t,
470            a[1] * (1.0 - t) + b[1] * t,
471            a[2] * (1.0 - t) + b[2] * t,
472        ]
473    }
474
475    /// Trace a ray through the scene with Monte Carlo path tracing.
476    ///
477    /// Returns the estimated radiance (RGB) for the ray.
478    pub fn trace(&self, ray: &Ray, max_depth: usize, rng: &mut impl Rng) -> [f32; 3] {
479        if max_depth == 0 {
480            return [0.0; 3];
481        }
482        if let Some(hit) = self.hit_scene(ray, 1e-4, f32::INFINITY) {
483            let mat = &self.materials[hit.material_index];
484            if let Some((scattered, attenuation)) = mat.scatter(ray, &hit, rng) {
485                let incoming = self.trace(&scattered, max_depth - 1, rng);
486                vmul3(attenuation, incoming)
487            } else {
488                [0.0; 3]
489            }
490        } else {
491            self.sky_color(ray)
492        }
493    }
494}
495
496// ── PathTracerBuffer ─────────────────────────────────────────────────────────
497
498/// A pixel accumulation buffer for progressive rendering.
499#[derive(Debug, Clone)]
500pub struct PathTracerBuffer {
501    /// Buffer width in pixels.
502    pub width: usize,
503    /// Buffer height in pixels.
504    pub height: usize,
505    /// Accumulated colour per pixel (RGB, floating point).
506    pub accumulator: Vec<[f32; 3]>,
507    /// Number of samples accumulated per pixel.
508    pub sample_count: Vec<u32>,
509}
510
511impl PathTracerBuffer {
512    /// Create a new buffer of `width × height` pixels, all zeroed.
513    pub fn new(width: usize, height: usize) -> Self {
514        let n = width * height;
515        Self {
516            width,
517            height,
518            accumulator: vec![[0.0; 3]; n],
519            sample_count: vec![0; n],
520        }
521    }
522
523    /// Add a colour sample to pixel `(x, y)`.
524    pub fn add_sample(&mut self, x: usize, y: usize, color: [f32; 3]) {
525        let idx = y * self.width + x;
526        let acc = &mut self.accumulator[idx];
527        acc[0] += color[0];
528        acc[1] += color[1];
529        acc[2] += color[2];
530        self.sample_count[idx] += 1;
531    }
532
533    /// Get the averaged colour at pixel `(x, y)`.
534    pub fn get_pixel(&self, x: usize, y: usize) -> [f32; 3] {
535        let idx = y * self.width + x;
536        let n = self.sample_count[idx] as f32;
537        if n == 0.0 {
538            return [0.0; 3];
539        }
540        let acc = self.accumulator[idx];
541        [acc[0] / n, acc[1] / n, acc[2] / n]
542    }
543
544    /// Return a gamma-corrected (gamma=2) u8 RGB image row-major.
545    pub fn to_rgb8(&self) -> Vec<u8> {
546        let mut out = Vec::with_capacity(self.width * self.height * 3);
547        for y in 0..self.height {
548            for x in 0..self.width {
549                let c = self.get_pixel(x, y);
550                for ch in c.iter() {
551                    let linear = ch.clamp(0.0, 1.0);
552                    let gamma = linear.sqrt(); // gamma = 2
553                    out.push((gamma * 255.999) as u8);
554                }
555            }
556        }
557        out
558    }
559
560    /// Reset all accumulated samples.
561    pub fn clear(&mut self) {
562        for acc in &mut self.accumulator {
563            *acc = [0.0; 3];
564        }
565        for s in &mut self.sample_count {
566            *s = 0;
567        }
568    }
569
570    /// Total number of samples accumulated across all pixels.
571    pub fn total_samples(&self) -> u64 {
572        self.sample_count.iter().map(|&s| s as u64).sum()
573    }
574}
575
576// ── Camera ───────────────────────────────────────────────────────────────────
577
578/// A simple pinhole camera.
579#[derive(Debug, Clone)]
580pub struct Camera {
581    /// Camera origin.
582    pub origin: [f32; 3],
583    lower_left_corner: [f32; 3],
584    horizontal: [f32; 3],
585    vertical: [f32; 3],
586    lens_radius: f32,
587    u: [f32; 3],
588    v: [f32; 3],
589}
590
591impl Camera {
592    /// Construct a camera.
593    ///
594    /// * `look_from` — eye position
595    /// * `look_at` — target position
596    /// * `vup` — world up vector
597    /// * `vfov` — vertical field of view in degrees
598    /// * `aspect_ratio` — image width / height
599    /// * `aperture` — lens aperture (0 = pinhole)
600    /// * `focus_dist` — focus distance
601    pub fn new(
602        look_from: [f32; 3],
603        look_at: [f32; 3],
604        vup: [f32; 3],
605        vfov: f32,
606        aspect_ratio: f32,
607        aperture: f32,
608        focus_dist: f32,
609    ) -> Self {
610        let theta = vfov.to_radians();
611        let h = (theta / 2.0).tan();
612        let viewport_height = 2.0 * h;
613        let viewport_width = aspect_ratio * viewport_height;
614
615        let w = normalize(vsub(look_from, look_at));
616        let u = normalize(cross(vup, w));
617        let v = cross(w, u);
618
619        let horizontal = vmul(u, viewport_width * focus_dist);
620        let vertical = vmul(v, viewport_height * focus_dist);
621        let lower_left_corner = vsub(
622            vsub(vsub(look_from, vmul(horizontal, 0.5)), vmul(vertical, 0.5)),
623            vmul(w, focus_dist),
624        );
625
626        Self {
627            origin: look_from,
628            lower_left_corner,
629            horizontal,
630            vertical,
631            lens_radius: aperture / 2.0,
632            u,
633            v,
634        }
635    }
636
637    /// Generate a ray for pixel coordinates `(s, t)` in \[0,1\]×\[0,1\].
638    pub fn get_ray(&self, s: f32, t: f32, rng: &mut impl Rng) -> Ray {
639        let rd = vmul(self.random_in_unit_disk(rng), self.lens_radius);
640        let offset = vadd(vmul(self.u, rd[0]), vmul(self.v, rd[1]));
641        let dir = vsub(
642            vadd(
643                vadd(self.lower_left_corner, vmul(self.horizontal, s)),
644                vmul(self.vertical, t),
645            ),
646            vadd(self.origin, offset),
647        );
648        Ray::new(vadd(self.origin, offset), dir)
649    }
650
651    fn random_in_unit_disk(&self, rng: &mut impl Rng) -> [f32; 3] {
652        loop {
653            let p = [
654                rng.random_range(-1.0f32..1.0),
655                rng.random_range(-1.0f32..1.0),
656                0.0,
657            ];
658            if dot(p, p) < 1.0 {
659                return p;
660            }
661        }
662    }
663}
664
665// ── PathTracerRenderer ───────────────────────────────────────────────────────
666
667/// Renderer that progressively renders a scene into a `PathTracerBuffer`.
668#[derive(Debug, Clone)]
669pub struct PathTracerRenderer {
670    /// Scene to render.
671    pub scene: PathTracerScene,
672    /// Camera.
673    pub camera: Camera,
674    /// Maximum ray bounce depth.
675    pub max_depth: usize,
676    /// Samples per pixel per call to `render_pass`.
677    pub samples_per_pass: usize,
678}
679
680impl PathTracerRenderer {
681    /// Create a new renderer.
682    pub fn new(
683        scene: PathTracerScene,
684        camera: Camera,
685        max_depth: usize,
686        samples_per_pass: usize,
687    ) -> Self {
688        Self {
689            scene,
690            camera,
691            max_depth,
692            samples_per_pass,
693        }
694    }
695
696    /// Render one progressive pass into `buffer`.
697    ///
698    /// Each pixel receives `self.samples_per_pass` new samples.
699    pub fn render_pass(&self, buffer: &mut PathTracerBuffer) {
700        let w = buffer.width;
701        let h = buffer.height;
702        let mut rng = rand::rng();
703        for y in 0..h {
704            for x in 0..w {
705                let mut color = [0.0f32; 3];
706                for _ in 0..self.samples_per_pass {
707                    let u = (x as f32 + rng.random::<f32>()) / (w - 1) as f32;
708                    let v = (y as f32 + rng.random::<f32>()) / (h - 1) as f32;
709                    let ray = self.camera.get_ray(u, v, &mut rng);
710                    let c = self.scene.trace(&ray, self.max_depth, &mut rng);
711                    color[0] += c[0];
712                    color[1] += c[1];
713                    color[2] += c[2];
714                }
715                let inv = 1.0 / self.samples_per_pass as f32;
716                buffer.add_sample(x, y, vmul(color, inv));
717            }
718        }
719    }
720}
721
722// ── Tests ─────────────────────────────────────────────────────────────────────
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    fn make_rng() -> impl Rng {
729        rand::rng()
730    }
731
732    // ── Ray tests ────────────────────────────────────────────────────────
733
734    #[test]
735    fn test_ray_at_origin() {
736        let r = Ray::new([0.0; 3], [1.0, 0.0, 0.0]);
737        let p = r.at(0.0);
738        assert_eq!(p, [0.0; 3]);
739    }
740
741    #[test]
742    fn test_ray_at_t() {
743        let r = Ray::new([1.0, 2.0, 3.0], [1.0, 0.0, 0.0]);
744        let p = r.at(3.0);
745        assert!((p[0] - 4.0).abs() < 1e-6);
746        assert!((p[1] - 2.0).abs() < 1e-6);
747        assert!((p[2] - 3.0).abs() < 1e-6);
748    }
749
750    #[test]
751    fn test_ray_at_negative_t() {
752        let r = Ray::new([0.0; 3], [0.0, 1.0, 0.0]);
753        let p = r.at(-2.0);
754        assert!((p[1] - (-2.0)).abs() < 1e-6);
755    }
756
757    // ── Sphere tests ─────────────────────────────────────────────────────
758
759    #[test]
760    fn test_sphere_hit_center() {
761        let s = Sphere::new([0.0, 0.0, -1.0], 0.5, 0);
762        let r = Ray::new([0.0; 3], [0.0, 0.0, -1.0]);
763        let hit = s.hit(&r, 0.001, f32::INFINITY);
764        assert!(hit.is_some());
765        let rec = hit.unwrap();
766        assert!(rec.t > 0.4 && rec.t < 0.6);
767    }
768
769    #[test]
770    fn test_sphere_miss() {
771        let s = Sphere::new([0.0, 0.0, -1.0], 0.5, 0);
772        let r = Ray::new([0.0; 3], [0.0, 1.0, 0.0]);
773        assert!(s.hit(&r, 0.001, f32::INFINITY).is_none());
774    }
775
776    #[test]
777    fn test_sphere_hit_from_inside() {
778        let s = Sphere::new([0.0; 3], 1.0, 0);
779        let r = Ray::new([0.0; 3], [1.0, 0.0, 0.0]);
780        let hit = s.hit(&r, 0.001, f32::INFINITY);
781        assert!(hit.is_some());
782        let rec = hit.unwrap();
783        assert!(!rec.front_face);
784    }
785
786    #[test]
787    fn test_sphere_normal_outward() {
788        let s = Sphere::new([0.0; 3], 1.0, 0);
789        let r = Ray::new([0.0, 0.0, 5.0], [0.0, 0.0, -1.0]);
790        let hit = s.hit(&r, 0.001, f32::INFINITY).unwrap();
791        assert!(hit.front_face);
792        assert!((hit.normal[2] - 1.0).abs() < 1e-5);
793    }
794
795    #[test]
796    fn test_sphere_t_range_cull() {
797        let s = Sphere::new([0.0, 0.0, -1.0], 0.5, 0);
798        let r = Ray::new([0.0; 3], [0.0, 0.0, -1.0]);
799        // t_max < actual hit
800        assert!(s.hit(&r, 0.001, 0.1).is_none());
801    }
802
803    // ── Triangle tests ───────────────────────────────────────────────────
804
805    #[test]
806    fn test_triangle_hit() {
807        let tri = Triangle::new([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0);
808        let r = Ray::new([0.0, 0.3, 1.0], [0.0, 0.0, -1.0]);
809        let hit = tri.hit(&r, 0.001, f32::INFINITY);
810        assert!(hit.is_some());
811    }
812
813    #[test]
814    fn test_triangle_miss_outside() {
815        let tri = Triangle::new([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0);
816        let r = Ray::new([5.0, 5.0, 1.0], [0.0, 0.0, -1.0]);
817        assert!(tri.hit(&r, 0.001, f32::INFINITY).is_none());
818    }
819
820    #[test]
821    fn test_triangle_miss_parallel() {
822        let tri = Triangle::new([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0);
823        // Ray parallel to triangle plane
824        let r = Ray::new([0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
825        assert!(tri.hit(&r, 0.001, f32::INFINITY).is_none());
826    }
827
828    #[test]
829    fn test_triangle_normal_direction() {
830        let tri = Triangle::new([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0);
831        // Normal should point in +Z for counter-clockwise winding
832        assert!(tri.normal[2].abs() > 0.9);
833    }
834
835    #[test]
836    fn test_triangle_hit_at_vertex() {
837        let tri = Triangle::new([0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0], 0);
838        // Ray aimed near edge midpoint
839        let r = Ray::new([0.5, 0.5, 1.0], [0.0, 0.0, -1.0]);
840        assert!(tri.hit(&r, 0.001, f32::INFINITY).is_some());
841    }
842
843    // ── Material tests ───────────────────────────────────────────────────
844
845    #[test]
846    fn test_lambertian_scatter() {
847        let mat = Material::lambertian([0.8, 0.3, 0.3]);
848        let ray = Ray::new([0.0; 3], [0.0, 0.0, -1.0]);
849        let hit = HitRecord {
850            t: 1.0,
851            point: [0.0, 0.0, -1.0],
852            normal: [0.0, 0.0, 1.0],
853            material_index: 0,
854            front_face: true,
855        };
856        let mut rng = make_rng();
857        let result = mat.scatter(&ray, &hit, &mut rng);
858        assert!(result.is_some());
859        let (_scattered, attenuation) = result.unwrap();
860        assert!((attenuation[0] - 0.8).abs() < 1e-6);
861    }
862
863    #[test]
864    fn test_metal_scatter() {
865        let mat = Material::metal([0.8, 0.8, 0.8], 0.0);
866        let ray = Ray::new([0.0; 3], normalize([1.0, -1.0, 0.0]));
867        let hit = HitRecord {
868            t: 1.0,
869            point: [0.0; 3],
870            normal: [0.0, 1.0, 0.0],
871            material_index: 0,
872            front_face: true,
873        };
874        let mut rng = make_rng();
875        let result = mat.scatter(&ray, &hit, &mut rng);
876        assert!(result.is_some());
877        let (scattered, _attenuation) = result.unwrap();
878        // Reflected ray should go up (positive y)
879        assert!(scattered.direction[1] > 0.0);
880    }
881
882    #[test]
883    fn test_dielectric_scatter() {
884        let mat = Material::dielectric(1.5);
885        let ray = Ray::new([0.0, 0.0, 1.0], normalize([0.0, 0.0, -1.0]));
886        let hit = HitRecord {
887            t: 1.0,
888            point: [0.0; 3],
889            normal: [0.0, 0.0, 1.0],
890            material_index: 0,
891            front_face: true,
892        };
893        let mut rng = make_rng();
894        let result = mat.scatter(&ray, &hit, &mut rng);
895        assert!(result.is_some());
896        let (_s, attn) = result.unwrap();
897        assert!((attn[0] - 1.0).abs() < 1e-6);
898    }
899
900    #[test]
901    fn test_metal_fuzz_clamped() {
902        let mat = Material::metal([1.0; 3], 5.0);
903        if let MaterialType::Metal(f) = mat.kind {
904            assert!(f <= 1.0);
905        } else {
906            panic!("expected Metal");
907        }
908    }
909
910    // ── Scene tests ──────────────────────────────────────────────────────
911
912    #[test]
913    fn test_scene_add_material() {
914        let mut scene = PathTracerScene::new();
915        let idx = scene.add_material(Material::lambertian([1.0; 3]));
916        assert_eq!(idx, 0);
917        let idx2 = scene.add_material(Material::lambertian([0.5; 3]));
918        assert_eq!(idx2, 1);
919    }
920
921    #[test]
922    fn test_scene_hit_sphere() {
923        let mut scene = PathTracerScene::new();
924        let m = scene.add_material(Material::lambertian([0.5; 3]));
925        scene.add_sphere(Sphere::new([0.0, 0.0, -1.0], 0.5, m));
926        let r = Ray::new([0.0; 3], [0.0, 0.0, -1.0]);
927        assert!(scene.hit_scene(&r, 0.001, f32::INFINITY).is_some());
928    }
929
930    #[test]
931    fn test_scene_miss() {
932        let scene = PathTracerScene::new();
933        let r = Ray::new([0.0; 3], [0.0, 0.0, -1.0]);
934        assert!(scene.hit_scene(&r, 0.001, f32::INFINITY).is_none());
935    }
936
937    #[test]
938    fn test_scene_sky_color_up() {
939        let scene = PathTracerScene::new();
940        let r = Ray::new([0.0; 3], [0.0, 1.0, 0.0]);
941        let c = scene.sky_color(&r);
942        // Should be close to sky_top
943        assert!(c[2] > 0.9);
944    }
945
946    #[test]
947    fn test_scene_trace_no_hit() {
948        let scene = PathTracerScene::new();
949        let r = Ray::new([0.0; 3], [0.0, 1.0, 0.0]);
950        let mut rng = make_rng();
951        let c = scene.trace(&r, 5, &mut rng);
952        // Should be sky colour
953        assert!(c[2] > 0.0);
954    }
955
956    #[test]
957    fn test_scene_trace_depth_zero() {
958        let mut scene = PathTracerScene::new();
959        let m = scene.add_material(Material::lambertian([0.5; 3]));
960        scene.add_sphere(Sphere::new([0.0, 0.0, -1.0], 0.5, m));
961        let r = Ray::new([0.0; 3], [0.0, 0.0, -1.0]);
962        let mut rng = make_rng();
963        let c = scene.trace(&r, 0, &mut rng);
964        assert_eq!(c, [0.0; 3]);
965    }
966
967    #[test]
968    fn test_scene_closest_hit() {
969        let mut scene = PathTracerScene::new();
970        let m = scene.add_material(Material::lambertian([0.5; 3]));
971        scene.add_sphere(Sphere::new([0.0, 0.0, -2.0], 0.5, m));
972        scene.add_sphere(Sphere::new([0.0, 0.0, -1.0], 0.5, m));
973        let r = Ray::new([0.0; 3], [0.0, 0.0, -1.0]);
974        let hit = scene.hit_scene(&r, 0.001, f32::INFINITY).unwrap();
975        // Closer sphere is at z=-1, so t ~ 0.5
976        assert!(hit.t < 1.0);
977    }
978
979    // ── PathTracerBuffer tests ───────────────────────────────────────────
980
981    #[test]
982    fn test_buffer_new() {
983        let buf = PathTracerBuffer::new(4, 4);
984        assert_eq!(buf.width, 4);
985        assert_eq!(buf.height, 4);
986        assert_eq!(buf.total_samples(), 0);
987    }
988
989    #[test]
990    fn test_buffer_add_and_get() {
991        let mut buf = PathTracerBuffer::new(4, 4);
992        buf.add_sample(1, 2, [0.6, 0.4, 0.2]);
993        buf.add_sample(1, 2, [0.4, 0.6, 0.8]);
994        let p = buf.get_pixel(1, 2);
995        assert!((p[0] - 0.5).abs() < 1e-5);
996        assert!((p[1] - 0.5).abs() < 1e-5);
997        assert!((p[2] - 0.5).abs() < 1e-5);
998    }
999
1000    #[test]
1001    fn test_buffer_zero_samples() {
1002        let buf = PathTracerBuffer::new(4, 4);
1003        let p = buf.get_pixel(0, 0);
1004        assert_eq!(p, [0.0; 3]);
1005    }
1006
1007    #[test]
1008    fn test_buffer_to_rgb8_white() {
1009        let mut buf = PathTracerBuffer::new(1, 1);
1010        buf.add_sample(0, 0, [1.0; 3]);
1011        let rgb = buf.to_rgb8();
1012        assert_eq!(rgb.len(), 3);
1013        assert_eq!(rgb[0], 255);
1014    }
1015
1016    #[test]
1017    fn test_buffer_to_rgb8_black() {
1018        let mut buf = PathTracerBuffer::new(1, 1);
1019        buf.add_sample(0, 0, [0.0; 3]);
1020        let rgb = buf.to_rgb8();
1021        assert_eq!(rgb[0], 0);
1022    }
1023
1024    #[test]
1025    fn test_buffer_total_samples() {
1026        let mut buf = PathTracerBuffer::new(2, 2);
1027        buf.add_sample(0, 0, [1.0; 3]);
1028        buf.add_sample(0, 0, [1.0; 3]);
1029        buf.add_sample(1, 1, [0.5; 3]);
1030        assert_eq!(buf.total_samples(), 3);
1031    }
1032
1033    #[test]
1034    fn test_buffer_clear() {
1035        let mut buf = PathTracerBuffer::new(2, 2);
1036        buf.add_sample(0, 0, [1.0; 3]);
1037        buf.clear();
1038        assert_eq!(buf.total_samples(), 0);
1039        assert_eq!(buf.get_pixel(0, 0), [0.0; 3]);
1040    }
1041
1042    #[test]
1043    fn test_buffer_size() {
1044        let buf = PathTracerBuffer::new(8, 6);
1045        assert_eq!(buf.accumulator.len(), 48);
1046        assert_eq!(buf.sample_count.len(), 48);
1047    }
1048
1049    // ── Camera tests ─────────────────────────────────────────────────────
1050
1051    #[test]
1052    fn test_camera_get_ray_center() {
1053        let cam = Camera::new(
1054            [0.0, 0.0, 0.0],
1055            [0.0, 0.0, -1.0],
1056            [0.0, 1.0, 0.0],
1057            90.0,
1058            1.0,
1059            0.0,
1060            1.0,
1061        );
1062        let mut rng = make_rng();
1063        let ray = cam.get_ray(0.5, 0.5, &mut rng);
1064        // Center ray should point roughly in -Z
1065        let d = normalize(ray.direction);
1066        assert!(d[2] < -0.9);
1067    }
1068
1069    #[test]
1070    fn test_camera_origin() {
1071        let cam = Camera::new(
1072            [1.0, 2.0, 3.0],
1073            [0.0, 0.0, 0.0],
1074            [0.0, 1.0, 0.0],
1075            60.0,
1076            1.5,
1077            0.0,
1078            1.0,
1079        );
1080        assert!((cam.origin[0] - 1.0).abs() < 1e-5);
1081    }
1082
1083    // ── Renderer integration test ────────────────────────────────────────
1084
1085    #[test]
1086    fn test_renderer_render_pass_small() {
1087        let mut scene = PathTracerScene::new();
1088        let m = scene.add_material(Material::lambertian([0.7, 0.3, 0.5]));
1089        scene.add_sphere(Sphere::new([0.0, 0.0, -1.0], 0.5, m));
1090        let cam = Camera::new(
1091            [0.0, 0.0, 0.0],
1092            [0.0, 0.0, -1.0],
1093            [0.0, 1.0, 0.0],
1094            90.0,
1095            1.0,
1096            0.0,
1097            1.0,
1098        );
1099        let renderer = PathTracerRenderer::new(scene, cam, 3, 2);
1100        let mut buf = PathTracerBuffer::new(4, 4);
1101        renderer.render_pass(&mut buf);
1102        assert!(buf.total_samples() > 0);
1103        // Each pixel gets exactly one accumulated sample entry (averaged internally)
1104        assert_eq!(buf.total_samples(), (4 * 4) as u64);
1105    }
1106
1107    #[test]
1108    fn test_renderer_rgb8_output_valid() {
1109        let mut scene = PathTracerScene::new();
1110        let m = scene.add_material(Material::lambertian([0.5; 3]));
1111        scene.add_sphere(Sphere::new([0.0, 0.0, -1.0], 0.5, m));
1112        let cam = Camera::new(
1113            [0.0, 0.0, 0.0],
1114            [0.0, 0.0, -1.0],
1115            [0.0, 1.0, 0.0],
1116            90.0,
1117            1.0,
1118            0.0,
1119            1.0,
1120        );
1121        let renderer = PathTracerRenderer::new(scene, cam, 2, 1);
1122        let mut buf = PathTracerBuffer::new(8, 8);
1123        renderer.render_pass(&mut buf);
1124        let rgb = buf.to_rgb8();
1125        assert_eq!(rgb.len(), 8 * 8 * 3);
1126        // All values valid u8
1127        for &v in &rgb {
1128            let _ = v; // just confirm they exist
1129        }
1130    }
1131
1132    // ── Vector helper tests ──────────────────────────────────────────────
1133
1134    #[test]
1135    fn test_vadd() {
1136        let a = [1.0, 2.0, 3.0];
1137        let b = [4.0, 5.0, 6.0];
1138        let c = vadd(a, b);
1139        assert_eq!(c, [5.0, 7.0, 9.0]);
1140    }
1141
1142    #[test]
1143    fn test_vsub() {
1144        let a = [3.0, 2.0, 1.0];
1145        let b = [1.0, 1.0, 1.0];
1146        assert_eq!(vsub(a, b), [2.0, 1.0, 0.0]);
1147    }
1148
1149    #[test]
1150    fn test_dot_orthogonal() {
1151        assert!((dot([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])).abs() < 1e-7);
1152    }
1153
1154    #[test]
1155    fn test_cross_unit_vectors() {
1156        let k = cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1157        assert!((k[2] - 1.0).abs() < 1e-7);
1158    }
1159
1160    #[test]
1161    fn test_normalize_length() {
1162        let v = [3.0, 4.0, 0.0];
1163        let n = normalize(v);
1164        let l = length(n);
1165        assert!((l - 1.0).abs() < 1e-6);
1166    }
1167
1168    #[test]
1169    fn test_reflect_normal_incidence() {
1170        let d = [0.0, -1.0, 0.0];
1171        let n = [0.0, 1.0, 0.0];
1172        let r = reflect(d, n);
1173        assert!((r[1] - 1.0).abs() < 1e-6);
1174    }
1175
1176    #[test]
1177    fn test_schlick_zero_angle() {
1178        // cosine=0 → schlick = r0 + (1-r0)*(1-0)^5 = 1.0
1179        let s = schlick(0.0, 1.5);
1180        assert!((s - 1.0).abs() < 1e-5);
1181    }
1182
1183    #[test]
1184    fn test_schlick_grazing() {
1185        // cosine=1 → schlick = r0 + (1-r0)*(1-1)^5 = r0
1186        let s = schlick(1.0, 1.5);
1187        let r0 = ((1.0 - 1.5f32) / (1.0 + 1.5)).powi(2);
1188        assert!((s - r0).abs() < 1e-5);
1189    }
1190}