Skip to main content

oxiphysics_gpu/raytracing/
types.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)]
6#[allow(unused_imports)]
7use super::functions::*;
8use std::f64::consts::PI;
9
10/// A material definition for ray tracing.
11#[derive(Debug, Clone)]
12pub struct Material {
13    /// Material type.
14    pub mat_type: MaterialType,
15    /// Albedo / base color.
16    pub albedo: [f64; 3],
17    /// Roughness (0=smooth, 1=rough) for metal/PBR.
18    pub roughness: f64,
19    /// Metallic factor (0=dielectric, 1=metal) for PBR.
20    pub metallic: f64,
21    /// Index of refraction for dielectric.
22    pub ior: f64,
23    /// Emission color and strength.
24    pub emission: [f64; 3],
25    /// Specular exponent for Phong.
26    pub shininess: f64,
27    /// Ambient occlusion factor.
28    pub ao: f64,
29}
30impl Material {
31    /// Create a Lambertian diffuse material.
32    pub fn diffuse(albedo: [f64; 3]) -> Self {
33        Self {
34            mat_type: MaterialType::Diffuse,
35            albedo,
36            roughness: 1.0,
37            metallic: 0.0,
38            ior: 1.0,
39            emission: [0.0; 3],
40            shininess: 0.0,
41            ao: 1.0,
42        }
43    }
44    /// Create a metal material.
45    pub fn metal(albedo: [f64; 3], roughness: f64) -> Self {
46        Self {
47            mat_type: MaterialType::Metal,
48            albedo,
49            roughness: roughness.clamp(0.0, 1.0),
50            metallic: 1.0,
51            ior: 1.0,
52            emission: [0.0; 3],
53            shininess: 0.0,
54            ao: 1.0,
55        }
56    }
57    /// Create a glass (dielectric) material.
58    pub fn glass(ior: f64) -> Self {
59        Self {
60            mat_type: MaterialType::Dielectric,
61            albedo: [1.0; 3],
62            roughness: 0.0,
63            metallic: 0.0,
64            ior,
65            emission: [0.0; 3],
66            shininess: 0.0,
67            ao: 1.0,
68        }
69    }
70    /// Create an emissive material.
71    pub fn emissive(color: [f64; 3], strength: f64) -> Self {
72        Self {
73            mat_type: MaterialType::Emissive,
74            albedo: color,
75            roughness: 1.0,
76            metallic: 0.0,
77            ior: 1.0,
78            emission: scale3(color, strength),
79            shininess: 0.0,
80            ao: 1.0,
81        }
82    }
83    /// Create a PBR material.
84    pub fn pbr(albedo: [f64; 3], metallic: f64, roughness: f64, ao: f64) -> Self {
85        Self {
86            mat_type: MaterialType::Pbr,
87            albedo,
88            roughness: roughness.clamp(0.0, 1.0),
89            metallic: metallic.clamp(0.0, 1.0),
90            ior: 1.5,
91            emission: [0.0; 3],
92            shininess: 0.0,
93            ao,
94        }
95    }
96}
97/// Path tracer state for a single sample path.
98#[derive(Debug, Clone)]
99pub struct PathState {
100    /// Current ray.
101    pub ray: Ray,
102    /// Accumulated throughput (product of BRDFs).
103    pub throughput: [f64; 3],
104    /// Accumulated radiance.
105    pub radiance: [f64; 3],
106    /// Current bounce depth.
107    pub depth: u32,
108    /// Maximum bounce depth.
109    pub max_depth: u32,
110}
111impl PathState {
112    /// Create a new path state.
113    pub fn new(ray: Ray, max_depth: u32) -> Self {
114        Self {
115            ray,
116            throughput: [1.0; 3],
117            radiance: [0.0; 3],
118            depth: 0,
119            max_depth,
120        }
121    }
122    /// Returns true if path tracing should continue.
123    pub fn should_continue(&self) -> bool {
124        self.depth < self.max_depth
125            && (self.throughput[0] + self.throughput[1] + self.throughput[2]) > 1e-6
126    }
127    /// Apply Russian roulette termination. Returns false if path is terminated.
128    pub fn russian_roulette(&mut self, survival_prob: f64) -> bool {
129        if survival_prob >= 1.0 {
130            return true;
131        }
132        let luminance =
133            0.2126 * self.throughput[0] + 0.7152 * self.throughput[1] + 0.0722 * self.throughput[2];
134        if luminance < survival_prob {
135            return false;
136        }
137        self.throughput = scale3(self.throughput, 1.0 / survival_prob);
138        true
139    }
140}
141/// Result of a ray-scene intersection.
142#[derive(Debug, Clone, Copy)]
143pub struct HitRecord {
144    /// Hit distance along the ray.
145    pub t: f64,
146    /// World-space hit position.
147    pub position: [f64; 3],
148    /// Outward-facing surface normal (normalized).
149    pub normal: [f64; 3],
150    /// UV texture coordinates.
151    pub uv: [f64; 2],
152    /// Index of the intersected triangle/primitive.
153    pub prim_id: u32,
154    /// Whether the ray hit the front face.
155    pub front_face: bool,
156    /// Index of the intersected material.
157    pub material_id: u32,
158}
159impl HitRecord {
160    /// Create a new hit record and set the face normal based on ray direction.
161    pub fn new(
162        t: f64,
163        position: [f64; 3],
164        outward_normal: [f64; 3],
165        ray_dir: [f64; 3],
166        uv: [f64; 2],
167        prim_id: u32,
168        material_id: u32,
169    ) -> Self {
170        let front_face = dot3(ray_dir, outward_normal) < 0.0;
171        let normal = if front_face {
172            outward_normal
173        } else {
174            scale3(outward_normal, -1.0)
175        };
176        Self {
177            t,
178            position,
179            normal,
180            uv,
181            prim_id,
182            front_face,
183            material_id,
184        }
185    }
186}
187/// A node in a Bounding Volume Hierarchy.
188#[derive(Debug, Clone)]
189pub struct BvhNode {
190    /// Bounding box of this node.
191    pub bounds: Aabb,
192    /// For leaf: index of first primitive. For internal: left child index.
193    pub left_or_first: u32,
194    /// For leaf: primitive count (>0). For internal: 0.
195    pub prim_count: u32,
196}
197impl BvhNode {
198    /// Is this node a leaf?
199    pub fn is_leaf(&self) -> bool {
200        self.prim_count > 0
201    }
202}
203/// A simple ray-traced scene.
204#[derive(Debug, Clone, Default)]
205pub struct Scene {
206    /// Scene triangles.
207    pub triangles: Vec<Triangle>,
208    /// Scene materials.
209    pub materials: Vec<Material>,
210    /// Point lights.
211    pub lights: Vec<PointLight>,
212    /// Area lights.
213    pub area_lights: Vec<AreaLight>,
214    /// Prebuilt BVH (None until built).
215    pub bvh: Option<Bvh>,
216}
217impl Scene {
218    /// Create a new empty scene.
219    pub fn new() -> Self {
220        Self::default()
221    }
222    /// Add a material and return its index.
223    pub fn add_material(&mut self, mat: Material) -> u32 {
224        let idx = self.materials.len() as u32;
225        self.materials.push(mat);
226        idx
227    }
228    /// Add a triangle.
229    pub fn add_triangle(&mut self, tri: Triangle) {
230        self.triangles.push(tri);
231    }
232    /// Add a point light.
233    pub fn add_light(&mut self, light: PointLight) {
234        self.lights.push(light);
235    }
236    /// Build the BVH over the current triangles.
237    pub fn build_bvh(&mut self) {
238        self.bvh = Some(Bvh::build(&self.triangles));
239    }
240    /// Add a unit box (12 triangles) centered at `center` with half-size `hs`.
241    pub fn add_box(&mut self, center: [f64; 3], hs: [f64; 3], material_id: u32) {
242        let [cx, cy, cz] = center;
243        let [hx, hy, hz] = hs;
244        let v = [
245            [cx - hx, cy - hy, cz - hz],
246            [cx + hx, cy - hy, cz - hz],
247            [cx + hx, cy + hy, cz - hz],
248            [cx - hx, cy + hy, cz - hz],
249            [cx - hx, cy - hy, cz + hz],
250            [cx + hx, cy - hy, cz + hz],
251            [cx + hx, cy + hy, cz + hz],
252            [cx - hx, cy + hy, cz + hz],
253        ];
254        let normals = [
255            [0.0f64, 0.0, -1.0],
256            [0.0, 0.0, 1.0],
257            [-1.0, 0.0, 0.0],
258            [1.0, 0.0, 0.0],
259            [0.0, -1.0, 0.0],
260            [0.0, 1.0, 0.0],
261        ];
262        let faces: [[usize; 4]; 6] = [
263            [0, 1, 2, 3],
264            [5, 4, 7, 6],
265            [4, 0, 3, 7],
266            [1, 5, 6, 2],
267            [4, 5, 1, 0],
268            [3, 2, 6, 7],
269        ];
270        let uv_quad = [[0.0f64; 2], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
271        for (fi, face) in faces.iter().enumerate() {
272            let n = normals[fi];
273            let t0 = Triangle::new(
274                v[face[0]],
275                v[face[1]],
276                v[face[2]],
277                n,
278                n,
279                n,
280                uv_quad[0],
281                uv_quad[1],
282                uv_quad[2],
283                material_id,
284            );
285            let t1 = Triangle::new(
286                v[face[0]],
287                v[face[2]],
288                v[face[3]],
289                n,
290                n,
291                n,
292                uv_quad[0],
293                uv_quad[2],
294                uv_quad[3],
295                material_id,
296            );
297            self.add_triangle(t0);
298            self.add_triangle(t1);
299        }
300    }
301    /// Add a planar quad (2 triangles).
302    pub fn add_quad(
303        &mut self,
304        v0: [f64; 3],
305        v1: [f64; 3],
306        v2: [f64; 3],
307        v3: [f64; 3],
308        material_id: u32,
309    ) {
310        let n = normalize3(cross3(sub3(v1, v0), sub3(v2, v0)));
311        let t0 = Triangle::new(
312            v0,
313            v1,
314            v2,
315            n,
316            n,
317            n,
318            [0.0, 0.0],
319            [1.0, 0.0],
320            [1.0, 1.0],
321            material_id,
322        );
323        let t1 = Triangle::new(
324            v0,
325            v2,
326            v3,
327            n,
328            n,
329            n,
330            [0.0, 0.0],
331            [1.0, 1.0],
332            [0.0, 1.0],
333            material_id,
334        );
335        self.add_triangle(t0);
336        self.add_triangle(t1);
337    }
338    /// Intersect the scene with a ray using the prebuilt BVH.
339    pub fn intersect<'a>(&'a self, ray: &Ray) -> Option<(HitRecord, &'a Triangle)> {
340        match &self.bvh {
341            Some(bvh) => bvh.intersect(ray, &self.triangles),
342            None => {
343                let mut best_t = ray.t_max;
344                let mut best: Option<(HitRecord, usize)> = None;
345                for (i, tri) in self.triangles.iter().enumerate() {
346                    if let Some((t, u, v)) = tri.intersect_full(ray)
347                        && t < best_t
348                    {
349                        best_t = t;
350                        let pos = ray.at(t);
351                        let norm = tri.interpolate_normal(u, v);
352                        let uv = tri.interpolate_uv(u, v);
353                        let hit = HitRecord::new(
354                            t,
355                            pos,
356                            norm,
357                            ray.direction,
358                            uv,
359                            i as u32,
360                            tri.material_id,
361                        );
362                        best = Some((hit, i));
363                    }
364                }
365                best.map(|(hit, i)| (hit, &self.triangles[i]))
366            }
367        }
368    }
369}
370/// A triangle primitive for ray tracing.
371#[derive(Debug, Clone, Copy)]
372pub struct Triangle {
373    /// Vertex positions.
374    pub v: [[f64; 3]; 3],
375    /// Per-vertex normals.
376    pub n: [[f64; 3]; 3],
377    /// Per-vertex UV coordinates.
378    pub uv: [[f64; 2]; 3],
379    /// Material index.
380    pub material_id: u32,
381}
382impl Triangle {
383    /// Create a new triangle.
384    pub fn new(
385        v0: [f64; 3],
386        v1: [f64; 3],
387        v2: [f64; 3],
388        n0: [f64; 3],
389        n1: [f64; 3],
390        n2: [f64; 3],
391        uv0: [f64; 2],
392        uv1: [f64; 2],
393        uv2: [f64; 2],
394        material_id: u32,
395    ) -> Self {
396        Self {
397            v: [v0, v1, v2],
398            n: [n0, n1, n2],
399            uv: [uv0, uv1, uv2],
400            material_id,
401        }
402    }
403    /// Compute the geometric normal from vertex positions.
404    pub fn geometric_normal(&self) -> [f64; 3] {
405        let e1 = sub3(self.v[1], self.v[0]);
406        let e2 = sub3(self.v[2], self.v[0]);
407        normalize3(cross3(e1, e2))
408    }
409    /// Möller–Trumbore ray-triangle intersection.
410    ///
411    /// Returns `Some(t)` if the ray intersects the triangle, within `[t_min, t_max]`.
412    pub fn intersect(&self, ray: &Ray) -> Option<f64> {
413        let e1 = sub3(self.v[1], self.v[0]);
414        let e2 = sub3(self.v[2], self.v[0]);
415        let h = cross3(ray.direction, e2);
416        let a = dot3(e1, h);
417        if a.abs() < 1e-15 {
418            return None;
419        }
420        let f = 1.0 / a;
421        let s = sub3(ray.origin, self.v[0]);
422        let u = f * dot3(s, h);
423        if !(0.0..=1.0).contains(&u) {
424            return None;
425        }
426        let q = cross3(s, e1);
427        let v = f * dot3(ray.direction, q);
428        if v < 0.0 || u + v > 1.0 {
429            return None;
430        }
431        let t = f * dot3(e2, q);
432        if t >= ray.t_min && t <= ray.t_max {
433            Some(t)
434        } else {
435            None
436        }
437    }
438    /// Full intersection returning barycentric coordinates.
439    pub fn intersect_full(&self, ray: &Ray) -> Option<(f64, f64, f64)> {
440        let e1 = sub3(self.v[1], self.v[0]);
441        let e2 = sub3(self.v[2], self.v[0]);
442        let h = cross3(ray.direction, e2);
443        let a = dot3(e1, h);
444        if a.abs() < 1e-15 {
445            return None;
446        }
447        let f = 1.0 / a;
448        let s = sub3(ray.origin, self.v[0]);
449        let u = f * dot3(s, h);
450        if !(0.0..=1.0).contains(&u) {
451            return None;
452        }
453        let q = cross3(s, e1);
454        let v = f * dot3(ray.direction, q);
455        if v < 0.0 || u + v > 1.0 {
456            return None;
457        }
458        let t = f * dot3(e2, q);
459        if t >= ray.t_min && t <= ray.t_max {
460            Some((t, u, v))
461        } else {
462            None
463        }
464    }
465    /// Interpolate the shading normal at barycentric (u,v).
466    pub fn interpolate_normal(&self, u: f64, v: f64) -> [f64; 3] {
467        let w = 1.0 - u - v;
468        let n = [
469            w * self.n[0][0] + u * self.n[1][0] + v * self.n[2][0],
470            w * self.n[0][1] + u * self.n[1][1] + v * self.n[2][1],
471            w * self.n[0][2] + u * self.n[1][2] + v * self.n[2][2],
472        ];
473        normalize3(n)
474    }
475    /// Interpolate UV coordinates at barycentric (u,v).
476    pub fn interpolate_uv(&self, u: f64, v: f64) -> [f64; 2] {
477        let w = 1.0 - u - v;
478        [
479            w * self.uv[0][0] + u * self.uv[1][0] + v * self.uv[2][0],
480            w * self.uv[0][1] + u * self.uv[1][1] + v * self.uv[2][1],
481        ]
482    }
483    /// Axis-aligned bounding box of the triangle.
484    pub fn aabb(&self) -> Aabb {
485        let min_x = self.v[0][0].min(self.v[1][0]).min(self.v[2][0]);
486        let min_y = self.v[0][1].min(self.v[1][1]).min(self.v[2][1]);
487        let min_z = self.v[0][2].min(self.v[1][2]).min(self.v[2][2]);
488        let max_x = self.v[0][0].max(self.v[1][0]).max(self.v[2][0]);
489        let max_y = self.v[0][1].max(self.v[1][1]).max(self.v[2][1]);
490        let max_z = self.v[0][2].max(self.v[1][2]).max(self.v[2][2]);
491        Aabb {
492            min: [min_x, min_y, min_z],
493            max: [max_x, max_y, max_z],
494        }
495    }
496    /// Centroid of the triangle.
497    pub fn centroid(&self) -> [f64; 3] {
498        [
499            (self.v[0][0] + self.v[1][0] + self.v[2][0]) / 3.0,
500            (self.v[0][1] + self.v[1][1] + self.v[2][1]) / 3.0,
501            (self.v[0][2] + self.v[1][2] + self.v[2][2]) / 3.0,
502        ]
503    }
504    /// Surface area of the triangle.
505    pub fn area(&self) -> f64 {
506        let e1 = sub3(self.v[1], self.v[0]);
507        let e2 = sub3(self.v[2], self.v[0]);
508        length3(cross3(e1, e2)) * 0.5
509    }
510}
511/// Axis-aligned bounding box.
512#[derive(Debug, Clone, Copy)]
513pub struct Aabb {
514    /// Minimum corner.
515    pub min: [f64; 3],
516    /// Maximum corner.
517    pub max: [f64; 3],
518}
519impl Aabb {
520    /// Create a new AABB.
521    pub fn new(min: [f64; 3], max: [f64; 3]) -> Self {
522        Self { min, max }
523    }
524    /// Create an empty AABB (inverted extents).
525    pub fn empty() -> Self {
526        Self {
527            min: [f64::INFINITY; 3],
528            max: [f64::NEG_INFINITY; 3],
529        }
530    }
531    /// Expand AABB to include a point.
532    pub fn expand_point(&self, p: [f64; 3]) -> Self {
533        Self {
534            min: [
535                self.min[0].min(p[0]),
536                self.min[1].min(p[1]),
537                self.min[2].min(p[2]),
538            ],
539            max: [
540                self.max[0].max(p[0]),
541                self.max[1].max(p[1]),
542                self.max[2].max(p[2]),
543            ],
544        }
545    }
546    /// Merge two AABBs.
547    pub fn merge(&self, other: &Self) -> Self {
548        Self {
549            min: [
550                self.min[0].min(other.min[0]),
551                self.min[1].min(other.min[1]),
552                self.min[2].min(other.min[2]),
553            ],
554            max: [
555                self.max[0].max(other.max[0]),
556                self.max[1].max(other.max[1]),
557                self.max[2].max(other.max[2]),
558            ],
559        }
560    }
561    /// Centroid of the AABB.
562    pub fn centroid(&self) -> [f64; 3] {
563        [
564            (self.min[0] + self.max[0]) * 0.5,
565            (self.min[1] + self.max[1]) * 0.5,
566            (self.min[2] + self.max[2]) * 0.5,
567        ]
568    }
569    /// Surface area of the AABB.
570    pub fn surface_area(&self) -> f64 {
571        let d = [
572            self.max[0] - self.min[0],
573            self.max[1] - self.min[1],
574            self.max[2] - self.min[2],
575        ];
576        2.0 * (d[0] * d[1] + d[1] * d[2] + d[2] * d[0])
577    }
578    /// Longest axis (0=X, 1=Y, 2=Z).
579    pub fn longest_axis(&self) -> usize {
580        let d = [
581            self.max[0] - self.min[0],
582            self.max[1] - self.min[1],
583            self.max[2] - self.min[2],
584        ];
585        if d[0] >= d[1] && d[0] >= d[2] {
586            0
587        } else if d[1] >= d[2] {
588            1
589        } else {
590            2
591        }
592    }
593    /// Ray-AABB intersection using slab method. Returns (t_near, t_far).
594    pub fn intersect_ray(&self, ray: &Ray) -> Option<(f64, f64)> {
595        let mut t_near = ray.t_min;
596        let mut t_far = ray.t_max;
597        for i in 0..3 {
598            let inv_d = if ray.direction[i].abs() < 1e-15 {
599                f64::INFINITY
600            } else {
601                1.0 / ray.direction[i]
602            };
603            let t0 = (self.min[i] - ray.origin[i]) * inv_d;
604            let t1 = (self.max[i] - ray.origin[i]) * inv_d;
605            let (t0, t1) = if inv_d < 0.0 { (t1, t0) } else { (t0, t1) };
606            t_near = t_near.max(t0);
607            t_far = t_far.min(t1);
608            if t_far < t_near {
609                return None;
610            }
611        }
612        Some((t_near, t_far))
613    }
614}
615/// Surface Area Heuristic (SAH) BVH over triangles.
616#[derive(Debug, Clone)]
617pub struct Bvh {
618    /// All BVH nodes.
619    pub nodes: Vec<BvhNode>,
620    /// Primitive indices (reordered during build).
621    pub prim_indices: Vec<u32>,
622    /// Number of primitives.
623    pub prim_count: usize,
624}
625impl Bvh {
626    /// Build a BVH from a list of triangles using SAH.
627    pub fn build(triangles: &[Triangle]) -> Self {
628        let n = triangles.len();
629        if n == 0 {
630            return Self {
631                nodes: Vec::new(),
632                prim_indices: Vec::new(),
633                prim_count: 0,
634            };
635        }
636        let mut prim_indices: Vec<u32> = (0..n as u32).collect();
637        let centroids: Vec<[f64; 3]> = triangles.iter().map(|t| t.centroid()).collect();
638        let aabbs: Vec<Aabb> = triangles.iter().map(|t| t.aabb()).collect();
639        let mut nodes = Vec::with_capacity(2 * n);
640        let root_bounds = aabbs.iter().fold(Aabb::empty(), |acc, b| acc.merge(b));
641        nodes.push(BvhNode {
642            bounds: root_bounds,
643            left_or_first: 0,
644            prim_count: n as u32,
645        });
646        let mut stack = vec![0usize];
647        while let Some(node_idx) = stack.pop() {
648            let first = nodes[node_idx].left_or_first as usize;
649            let count = nodes[node_idx].prim_count as usize;
650            if count <= 4 {
651                continue;
652            }
653            let parent_sa = nodes[node_idx].bounds.surface_area();
654            let mut best_cost = f64::INFINITY;
655            let mut best_axis = 0usize;
656            let mut best_split = 0.0f64;
657            for axis in 0..3 {
658                let slice = &mut prim_indices[first..first + count];
659                slice.sort_unstable_by(|&a, &b| {
660                    centroids[a as usize][axis]
661                        .partial_cmp(&centroids[b as usize][axis])
662                        .expect("operation should succeed")
663                });
664                let mut left_bounds = Aabb::empty();
665                let mut left_areas = Vec::with_capacity(count);
666                for i in 0..count - 1 {
667                    left_bounds = left_bounds.merge(&aabbs[slice[i] as usize]);
668                    left_areas.push(left_bounds.surface_area());
669                }
670                let mut right_bounds = Aabb::empty();
671                for i in (1..count).rev() {
672                    right_bounds = right_bounds.merge(&aabbs[slice[i] as usize]);
673                    let left_count = i;
674                    let right_count = count - i;
675                    let cost = (left_areas[i - 1] * left_count as f64
676                        + right_bounds.surface_area() * right_count as f64)
677                        / parent_sa;
678                    if cost < best_cost {
679                        best_cost = cost;
680                        best_axis = axis;
681                        best_split = centroids[slice[i] as usize][axis];
682                    }
683                }
684            }
685            let slice = &mut prim_indices[first..first + count];
686            slice.sort_unstable_by(|&a, &b| {
687                centroids[a as usize][best_axis]
688                    .partial_cmp(&centroids[b as usize][best_axis])
689                    .expect("operation should succeed")
690            });
691            let split_pos =
692                slice.partition_point(|&idx| centroids[idx as usize][best_axis] < best_split);
693            let split_pos = split_pos.clamp(1, count - 1);
694            let left_count = split_pos;
695            let right_count = count - split_pos;
696            let left_bounds = prim_indices[first..first + left_count]
697                .iter()
698                .fold(Aabb::empty(), |acc, &i| acc.merge(&aabbs[i as usize]));
699            let right_bounds = prim_indices[first + left_count..first + count]
700                .iter()
701                .fold(Aabb::empty(), |acc, &i| acc.merge(&aabbs[i as usize]));
702            let left_child_idx = nodes.len();
703            nodes.push(BvhNode {
704                bounds: left_bounds,
705                left_or_first: first as u32,
706                prim_count: left_count as u32,
707            });
708            let right_child_idx = nodes.len();
709            nodes.push(BvhNode {
710                bounds: right_bounds,
711                left_or_first: (first + left_count) as u32,
712                prim_count: right_count as u32,
713            });
714            nodes[node_idx].left_or_first = left_child_idx as u32;
715            nodes[node_idx].prim_count = 0;
716            stack.push(left_child_idx);
717            stack.push(right_child_idx);
718        }
719        Self {
720            nodes,
721            prim_indices,
722            prim_count: n,
723        }
724    }
725    /// Traverse the BVH and find the nearest intersection.
726    pub fn intersect<'a>(
727        &self,
728        ray: &Ray,
729        triangles: &'a [Triangle],
730    ) -> Option<(HitRecord, &'a Triangle)> {
731        if self.nodes.is_empty() {
732            return None;
733        }
734        let mut stack = Vec::with_capacity(64);
735        stack.push(0usize);
736        let mut best_t = ray.t_max;
737        let mut best_hit: Option<(HitRecord, usize)> = None;
738        while let Some(node_idx) = stack.pop() {
739            let node = &self.nodes[node_idx];
740            let mut test_ray = *ray;
741            test_ray.t_max = best_t;
742            if node.bounds.intersect_ray(&test_ray).is_none() {
743                continue;
744            }
745            if node.is_leaf() {
746                let first = node.left_or_first as usize;
747                let count = node.prim_count as usize;
748                for i in first..first + count {
749                    let tri_idx = self.prim_indices[i] as usize;
750                    let tri = &triangles[tri_idx];
751                    if let Some((t, u, v)) = tri.intersect_full(ray)
752                        && t < best_t
753                    {
754                        best_t = t;
755                        let pos = ray.at(t);
756                        let norm = tri.interpolate_normal(u, v);
757                        let uv = tri.interpolate_uv(u, v);
758                        let hit = HitRecord::new(
759                            t,
760                            pos,
761                            norm,
762                            ray.direction,
763                            uv,
764                            tri_idx as u32,
765                            tri.material_id,
766                        );
767                        best_hit = Some((hit, tri_idx));
768                    }
769                }
770            } else {
771                let left = node.left_or_first as usize;
772                let right = left + 1;
773                stack.push(left);
774                stack.push(right);
775            }
776        }
777        best_hit.map(|(hit, tri_idx)| (hit, &triangles[tri_idx]))
778    }
779    /// Test if any intersection exists (shadow ray query).
780    pub fn intersect_any(&self, ray: &Ray, triangles: &[Triangle]) -> bool {
781        if self.nodes.is_empty() {
782            return false;
783        }
784        let mut stack = Vec::with_capacity(64);
785        stack.push(0usize);
786        while let Some(node_idx) = stack.pop() {
787            let node = &self.nodes[node_idx];
788            if node.bounds.intersect_ray(ray).is_none() {
789                continue;
790            }
791            if node.is_leaf() {
792                let first = node.left_or_first as usize;
793                let count = node.prim_count as usize;
794                for i in first..first + count {
795                    let tri_idx = self.prim_indices[i] as usize;
796                    if triangles[tri_idx].intersect(ray).is_some() {
797                        return true;
798                    }
799                }
800            } else {
801                let left = node.left_or_first as usize;
802                let right = left + 1;
803                stack.push(left);
804                stack.push(right);
805            }
806        }
807        false
808    }
809}
810/// Area light for soft shadow computation.
811#[derive(Debug, Clone, Copy)]
812pub struct AreaLight {
813    /// Center position in world space.
814    pub position: [f64; 3],
815    /// Light color.
816    pub color: [f64; 3],
817    /// Intensity.
818    pub intensity: f64,
819    /// Light tangent (half-size along u axis).
820    pub u_axis: [f64; 3],
821    /// Light bitangent (half-size along v axis).
822    pub v_axis: [f64; 3],
823}
824impl AreaLight {
825    /// Create a new area light.
826    pub fn new(
827        position: [f64; 3],
828        color: [f64; 3],
829        intensity: f64,
830        u_axis: [f64; 3],
831        v_axis: [f64; 3],
832    ) -> Self {
833        Self {
834            position,
835            color,
836            intensity,
837            u_axis,
838            v_axis,
839        }
840    }
841    /// Sample a point on the light given stratified (su, sv) in \[-1,1\].
842    pub fn sample_point(&self, su: f64, sv: f64) -> [f64; 3] {
843        add3(
844            add3(self.position, scale3(self.u_axis, su)),
845            scale3(self.v_axis, sv),
846        )
847    }
848}
849/// A ray defined by an origin and normalized direction.
850#[derive(Debug, Clone, Copy)]
851pub struct Ray {
852    /// Ray origin in world space.
853    pub origin: [f64; 3],
854    /// Normalized ray direction.
855    pub direction: [f64; 3],
856    /// Minimum valid hit distance.
857    pub t_min: f64,
858    /// Maximum valid hit distance.
859    pub t_max: f64,
860}
861impl Ray {
862    /// Create a new ray.
863    pub fn new(origin: [f64; 3], direction: [f64; 3]) -> Self {
864        Self {
865            origin,
866            direction: normalize3(direction),
867            t_min: 1e-4,
868            t_max: f64::INFINITY,
869        }
870    }
871    /// Evaluate the ray at parameter t: origin + t * direction.
872    pub fn at(&self, t: f64) -> [f64; 3] {
873        add3(self.origin, scale3(self.direction, t))
874    }
875}
876/// Perspective camera model for primary ray generation.
877#[derive(Debug, Clone)]
878pub struct Camera {
879    /// Camera position in world space.
880    pub position: [f64; 3],
881    /// Normalized forward direction.
882    pub forward: [f64; 3],
883    /// Normalized right direction.
884    pub right: [f64; 3],
885    /// Normalized up direction.
886    pub up: [f64; 3],
887    /// Vertical field of view in radians.
888    pub fov_y: f64,
889    /// Image aspect ratio (width/height).
890    pub aspect: f64,
891    /// Distance to the near plane.
892    pub near: f64,
893    /// Lens aperture radius (0 = pinhole).
894    pub aperture: f64,
895    /// Focus distance.
896    pub focus_dist: f64,
897}
898impl Camera {
899    /// Create a new perspective camera with look-at setup.
900    pub fn look_at(
901        eye: [f64; 3],
902        target: [f64; 3],
903        world_up: [f64; 3],
904        fov_y_deg: f64,
905        aspect: f64,
906        aperture: f64,
907        focus_dist: f64,
908    ) -> Self {
909        let forward = normalize3(sub3(target, eye));
910        let right = normalize3(cross3(forward, world_up));
911        let up = cross3(right, forward);
912        Self {
913            position: eye,
914            forward,
915            right,
916            up,
917            fov_y: fov_y_deg * PI / 180.0,
918            aspect,
919            near: 0.001,
920            aperture,
921            focus_dist,
922        }
923    }
924    /// Generate a primary ray for pixel (px, py) in \[0,W) x \[0,H).
925    pub fn generate_ray(&self, px: f64, py: f64, width: f64, height: f64) -> Ray {
926        let half_h = (self.fov_y * 0.5).tan();
927        let half_w = self.aspect * half_h;
928        let ndc_x = (2.0 * (px + 0.5) / width - 1.0) * half_w;
929        let ndc_y = (1.0 - 2.0 * (py + 0.5) / height) * half_h;
930        let dir = normalize3(add3(
931            add3(self.forward, scale3(self.right, ndc_x)),
932            scale3(self.up, ndc_y),
933        ));
934        Ray::new(self.position, dir)
935    }
936    /// Generate a depth-of-field ray with lens sampling.
937    pub fn generate_dof_ray(
938        &self,
939        px: f64,
940        py: f64,
941        width: f64,
942        height: f64,
943        lens_u: f64,
944        lens_v: f64,
945    ) -> Ray {
946        let half_h = (self.fov_y * 0.5).tan();
947        let half_w = self.aspect * half_h;
948        let ndc_x = (2.0 * (px + 0.5) / width - 1.0) * half_w;
949        let ndc_y = (1.0 - 2.0 * (py + 0.5) / height) * half_h;
950        let focus_dir = normalize3(add3(
951            add3(self.forward, scale3(self.right, ndc_x)),
952            scale3(self.up, ndc_y),
953        ));
954        let focus_point = add3(self.position, scale3(focus_dir, self.focus_dist));
955        let lens_offset = add3(
956            scale3(self.right, lens_u * self.aperture),
957            scale3(self.up, lens_v * self.aperture),
958        );
959        let origin = add3(self.position, lens_offset);
960        let direction = normalize3(sub3(focus_point, origin));
961        Ray::new(origin, direction)
962    }
963}
964/// Material type enumeration.
965#[derive(Debug, Clone, Copy, PartialEq)]
966pub enum MaterialType {
967    /// Lambertian diffuse.
968    Diffuse,
969    /// Metal with roughness.
970    Metal,
971    /// Dielectric (glass/water).
972    Dielectric,
973    /// Emissive light source.
974    Emissive,
975    /// PBR material.
976    Pbr,
977}
978/// A point light source.
979#[derive(Debug, Clone, Copy)]
980pub struct PointLight {
981    /// Light position in world space.
982    pub position: [f64; 3],
983    /// Light color (linear RGB).
984    pub color: [f64; 3],
985    /// Light intensity.
986    pub intensity: f64,
987    /// Attenuation coefficients (constant, linear, quadratic).
988    pub attenuation: [f64; 3],
989}
990impl PointLight {
991    /// Create a new point light.
992    pub fn new(position: [f64; 3], color: [f64; 3], intensity: f64) -> Self {
993        Self {
994            position,
995            color,
996            intensity,
997            attenuation: [1.0, 0.0, 0.1],
998        }
999    }
1000    /// Compute attenuation at distance d.
1001    pub fn attenuate(&self, d: f64) -> f64 {
1002        let [c, l, q] = self.attenuation;
1003        1.0 / (c + l * d + q * d * d)
1004    }
1005}
1006/// Configuration for a full ray-trace render pass.
1007#[derive(Debug, Clone)]
1008pub struct RenderConfig {
1009    /// Image width in pixels.
1010    pub width: usize,
1011    /// Image height in pixels.
1012    pub height: usize,
1013    /// Number of samples per pixel.
1014    pub spp: u32,
1015    /// Maximum path depth.
1016    pub max_depth: u32,
1017    /// Enable soft shadows.
1018    pub soft_shadows: bool,
1019    /// Enable ambient occlusion.
1020    pub ambient_occlusion: bool,
1021    /// Enable depth of field.
1022    pub depth_of_field: bool,
1023    /// Number of AO samples.
1024    pub ao_samples: u32,
1025    /// Number of shadow samples.
1026    pub shadow_samples: u32,
1027    /// Background color.
1028    pub background: [f64; 3],
1029    /// Ambient light color.
1030    pub ambient: [f64; 3],
1031    /// Tone mapping mode (0=none, 1=reinhard, 2=filmic, 3=ACES).
1032    pub tonemap: u32,
1033}