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