Skip to main content

oxiphysics_gpu/
ray_marching.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Ray marching and Signed Distance Field (SDF) rendering utilities.
5//!
6//! Provides a CPU-side implementation of common ray marching primitives:
7//! SDFs for spheres, boxes, capsules, and tori; boolean CSG operators;
8//! smooth blending; ambient occlusion; soft shadows; and a simple renderer
9//! that produces depth and normal buffers.
10
11#![allow(dead_code)]
12#![allow(clippy::too_many_arguments)]
13
14// ── Vector helpers ────────────────────────────────────────────────────────────
15
16/// Add two 3-D vectors component-wise.
17#[inline]
18fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
19    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
20}
21
22/// Subtract two 3-D vectors component-wise.
23#[inline]
24fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
25    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
26}
27
28/// Scale a 3-D vector by a scalar.
29#[inline]
30fn scale3(v: [f64; 3], s: f64) -> [f64; 3] {
31    [v[0] * s, v[1] * s, v[2] * s]
32}
33
34/// Dot product of two 3-D vectors.
35#[inline]
36fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
37    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
38}
39
40/// Length of a 3-D vector.
41#[inline]
42fn length3(v: [f64; 3]) -> f64 {
43    dot3(v, v).sqrt()
44}
45
46/// Normalize a 3-D vector; returns zero vector when length < 1e-15.
47#[inline]
48fn normalize3(v: [f64; 3]) -> [f64; 3] {
49    let len = length3(v);
50    if len < 1e-15 {
51        return [0.0; 3];
52    }
53    scale3(v, 1.0 / len)
54}
55
56/// Clamp a value to \[lo, hi\].
57#[inline]
58fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
59    v.max(lo).min(hi)
60}
61
62/// Component-wise max of two 3-D vectors.
63#[inline]
64fn max3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
65    [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])]
66}
67
68/// Component-wise absolute value of a 3-D vector.
69#[inline]
70fn abs3(v: [f64; 3]) -> [f64; 3] {
71    [v[0].abs(), v[1].abs(), v[2].abs()]
72}
73
74// ── Ray ───────────────────────────────────────────────────────────────────────
75
76/// A ray defined by an origin point and a (normalized) direction vector.
77#[derive(Debug, Clone, Copy)]
78pub struct Ray {
79    /// Ray origin in world space.
80    pub origin: [f64; 3],
81    /// Ray direction (should be normalized).
82    pub direction: [f64; 3],
83}
84
85impl Ray {
86    /// Construct a new ray from origin and direction.
87    ///
88    /// The direction is normalized automatically.
89    pub fn new(origin: [f64; 3], direction: [f64; 3]) -> Self {
90        Self {
91            origin,
92            direction: normalize3(direction),
93        }
94    }
95
96    /// Evaluate the ray at parameter `t`: `origin + t * direction`.
97    pub fn at(&self, t: f64) -> [f64; 3] {
98        [
99            self.origin[0] + t * self.direction[0],
100            self.origin[1] + t * self.direction[1],
101            self.origin[2] + t * self.direction[2],
102        ]
103    }
104}
105
106// ── SDF trait ─────────────────────────────────────────────────────────────────
107
108/// A Signed Distance Field: maps a 3-D point to the signed distance to the
109/// nearest surface (negative inside, positive outside).
110pub trait Sdf {
111    /// Return the signed distance from point `p` to the nearest surface.
112    fn distance(&self, p: [f64; 3]) -> f64;
113
114    /// Return the surface normal at point `p` via central-difference gradient.
115    ///
116    /// The default implementation uses a small finite-difference epsilon of
117    /// `1e-5`.
118    fn normal(&self, p: [f64; 3]) -> [f64; 3] {
119        let e = 1e-5;
120        let dx = self.distance([p[0] + e, p[1], p[2]]) - self.distance([p[0] - e, p[1], p[2]]);
121        let dy = self.distance([p[0], p[1] + e, p[2]]) - self.distance([p[0], p[1] - e, p[2]]);
122        let dz = self.distance([p[0], p[1], p[2] + e]) - self.distance([p[0], p[1], p[2] - e]);
123        normalize3([dx, dy, dz])
124    }
125}
126
127// ── SphereSdf ─────────────────────────────────────────────────────────────────
128
129/// SDF for a sphere.
130#[derive(Debug, Clone, Copy)]
131pub struct SphereSdf {
132    /// Center of the sphere in world space.
133    pub center: [f64; 3],
134    /// Radius of the sphere.
135    pub radius: f64,
136}
137
138impl SphereSdf {
139    /// Construct a new `SphereSdf`.
140    pub fn new(center: [f64; 3], radius: f64) -> Self {
141        Self { center, radius }
142    }
143}
144
145impl Sdf for SphereSdf {
146    fn distance(&self, p: [f64; 3]) -> f64 {
147        let d = sub3(p, self.center);
148        length3(d) - self.radius
149    }
150}
151
152// ── BoxSdf ────────────────────────────────────────────────────────────────────
153
154/// SDF for an axis-aligned box centred at the origin.
155#[derive(Debug, Clone, Copy)]
156pub struct BoxSdf {
157    /// Half-extents along each axis.
158    pub half_extents: [f64; 3],
159}
160
161impl BoxSdf {
162    /// Construct a new `BoxSdf`.
163    pub fn new(half_extents: [f64; 3]) -> Self {
164        Self { half_extents }
165    }
166}
167
168impl Sdf for BoxSdf {
169    fn distance(&self, p: [f64; 3]) -> f64 {
170        // q = abs(p) - half_extents
171        let q = [
172            p[0].abs() - self.half_extents[0],
173            p[1].abs() - self.half_extents[1],
174            p[2].abs() - self.half_extents[2],
175        ];
176        let outside = length3(max3(q, [0.0; 3]));
177        let inside = q[0].max(q[1]).max(q[2]).min(0.0);
178        outside + inside
179    }
180}
181
182// ── CapsuleSdf ────────────────────────────────────────────────────────────────
183
184/// SDF for a capsule (cylinder with hemispherical caps).
185#[derive(Debug, Clone, Copy)]
186pub struct CapsuleSdf {
187    /// One endpoint of the capsule axis.
188    pub a: [f64; 3],
189    /// Other endpoint of the capsule axis.
190    pub b: [f64; 3],
191    /// Radius of the capsule.
192    pub r: f64,
193}
194
195impl CapsuleSdf {
196    /// Construct a new `CapsuleSdf`.
197    pub fn new(a: [f64; 3], b: [f64; 3], r: f64) -> Self {
198        Self { a, b, r }
199    }
200}
201
202impl Sdf for CapsuleSdf {
203    fn distance(&self, p: [f64; 3]) -> f64 {
204        let pa = sub3(p, self.a);
205        let ba = sub3(self.b, self.a);
206        let h = clamp(dot3(pa, ba) / dot3(ba, ba), 0.0, 1.0);
207        let closest = sub3(pa, scale3(ba, h));
208        length3(closest) - self.r
209    }
210}
211
212// ── TorusSdf ──────────────────────────────────────────────────────────────────
213
214/// SDF for a torus lying in the XZ plane, centred at the origin.
215#[derive(Debug, Clone, Copy)]
216pub struct TorusSdf {
217    /// Major radius (from origin to centre of tube).
218    pub r_major: f64,
219    /// Minor radius (radius of the tube).
220    pub r_minor: f64,
221}
222
223impl TorusSdf {
224    /// Construct a new `TorusSdf`.
225    pub fn new(r_major: f64, r_minor: f64) -> Self {
226        Self { r_major, r_minor }
227    }
228}
229
230impl Sdf for TorusSdf {
231    fn distance(&self, p: [f64; 3]) -> f64 {
232        // Distance to ring in XZ plane
233        let q_x = (p[0] * p[0] + p[2] * p[2]).sqrt() - self.r_major;
234        let q_y = p[1];
235        (q_x * q_x + q_y * q_y).sqrt() - self.r_minor
236    }
237}
238
239// ── CSG operators ─────────────────────────────────────────────────────────────
240
241/// SDF union: take the minimum of two distances.
242///
243/// The result is the SDF of the shape that is the union of the two objects.
244pub fn sdf_union(a: f64, b: f64) -> f64 {
245    a.min(b)
246}
247
248/// SDF intersection: take the maximum of two distances.
249///
250/// The result is the SDF of the shape that is the intersection of the two
251/// objects.
252pub fn sdf_intersection(a: f64, b: f64) -> f64 {
253    a.max(b)
254}
255
256/// SDF subtraction: subtract shape B from shape A.
257///
258/// Returns the SDF of `A \ B`.
259pub fn sdf_subtraction(a: f64, b: f64) -> f64 {
260    a.max(-b)
261}
262
263/// Polynomial smooth union of two SDF values with blend radius `k`.
264///
265/// When `k == 0` this degenerates to a hard union.  Larger `k` values produce
266/// a smoother blend at the boundary between the two shapes.
267pub fn sdf_smooth_union(a: f64, b: f64, k: f64) -> f64 {
268    if k < 1e-15 {
269        return sdf_union(a, b);
270    }
271    let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
272    let mix = b + h * (a - b);
273    mix - k * h * (1.0 - h)
274}
275
276// ── RayMarchResult ────────────────────────────────────────────────────────────
277
278/// Result of a single ray march step.
279#[derive(Debug, Clone, Copy)]
280pub struct RayMarchResult {
281    /// Whether the ray hit a surface.
282    pub hit: bool,
283    /// Ray parameter `t` at the hit point (or `max_dist` on miss).
284    pub t: f64,
285    /// Number of sphere-marching steps taken.
286    pub steps: usize,
287    /// Surface normal at the hit point (zero vector on miss).
288    pub normal: [f64; 3],
289}
290
291impl RayMarchResult {
292    /// Convenience constructor for a miss result.
293    pub fn miss(t: f64, steps: usize) -> Self {
294        Self {
295            hit: false,
296            t,
297            steps,
298            normal: [0.0; 3],
299        }
300    }
301
302    /// Convenience constructor for a hit result.
303    pub fn hit(t: f64, steps: usize, normal: [f64; 3]) -> Self {
304        Self {
305            hit: true,
306            t,
307            steps,
308            normal,
309        }
310    }
311}
312
313// ── ray_march ─────────────────────────────────────────────────────────────────
314
315/// Perform sphere-marching along `ray` against the provided SDF.
316///
317/// # Parameters
318/// - `ray` – the ray to march.
319/// - `sdf` – the scene SDF.
320/// - `max_steps` – maximum iteration count (e.g. 128).
321/// - `max_dist` – maximum travel distance before declaring a miss.
322/// - `eps` – surface threshold: when `|d| < eps` a hit is declared.
323pub fn ray_march(
324    ray: &Ray,
325    sdf: &dyn Sdf,
326    max_steps: usize,
327    max_dist: f64,
328    eps: f64,
329) -> RayMarchResult {
330    let mut t = 0.0_f64;
331    for step in 0..max_steps {
332        let p = ray.at(t);
333        let d = sdf.distance(p);
334        if d.abs() < eps {
335            let normal = sdf.normal(p);
336            return RayMarchResult::hit(t, step + 1, normal);
337        }
338        t += d;
339        if t >= max_dist {
340            return RayMarchResult::miss(max_dist, step + 1);
341        }
342    }
343    RayMarchResult::miss(t, max_steps)
344}
345
346// ── AmbientOcclusion ──────────────────────────────────────────────────────────
347
348/// Ambient occlusion estimator using SDF step-based sampling.
349#[derive(Debug, Clone, Copy)]
350pub struct AmbientOcclusion {
351    /// Number of sampling steps along the normal.
352    pub samples: usize,
353    /// Maximum AO sampling radius.
354    pub radius: f64,
355    /// Falloff exponent (higher = quicker falloff).
356    pub falloff: f64,
357}
358
359impl AmbientOcclusion {
360    /// Construct a new `AmbientOcclusion` estimator.
361    pub fn new(samples: usize, radius: f64, falloff: f64) -> Self {
362        Self {
363            samples,
364            radius,
365            falloff,
366        }
367    }
368
369    /// Estimate ambient occlusion at position `pos` with surface normal
370    /// `normal` against the given SDF.
371    ///
372    /// Returns a value in `[0, 1]` where 1.0 means fully unoccluded.
373    pub fn compute(&self, pos: [f64; 3], normal: [f64; 3], sdf: &dyn Sdf) -> f64 {
374        if self.samples == 0 {
375            return 1.0;
376        }
377        let mut occ = 0.0_f64;
378        for i in 1..=self.samples {
379            let t = self.radius * (i as f64) / (self.samples as f64);
380            let sample_pos = add3(pos, scale3(normal, t));
381            let d = sdf.distance(sample_pos);
382            occ += (t - d).max(0.0) / t.powf(self.falloff);
383        }
384        (1.0 - occ / (self.samples as f64)).clamp(0.0, 1.0)
385    }
386}
387
388// ── SoftShadow ────────────────────────────────────────────────────────────────
389
390/// Penumbra-aware soft shadow estimator.
391#[derive(Debug, Clone, Copy)]
392pub struct SoftShadow {
393    /// Direction toward the light source (will be normalized).
394    pub light_dir: [f64; 3],
395    /// Sharpness of the shadow penumbra; higher `k` → harder shadow.
396    pub k: f64,
397}
398
399impl SoftShadow {
400    /// Construct a new `SoftShadow`.
401    pub fn new(light_dir: [f64; 3], k: f64) -> Self {
402        Self {
403            light_dir: normalize3(light_dir),
404            k,
405        }
406    }
407
408    /// Compute soft shadow factor at surface point `pos`.
409    ///
410    /// Returns a value in `[0, 1]`: 0.0 = fully in shadow, 1.0 = fully lit.
411    pub fn compute(&self, pos: [f64; 3], sdf: &dyn Sdf, max_dist: f64) -> f64 {
412        let eps = 1e-4;
413        let mut res = 1.0_f64;
414        let mut t = eps;
415        while t < max_dist {
416            let p = add3(pos, scale3(self.light_dir, t));
417            let d = sdf.distance(p);
418            if d < eps {
419                return 0.0;
420            }
421            res = res.min(self.k * d / t);
422            t += d;
423        }
424        res.clamp(0.0, 1.0)
425    }
426}
427
428// ── RayMarchRenderer ──────────────────────────────────────────────────────────
429
430/// A simple camera/renderer that generates rays and produces image buffers via
431/// ray marching.
432#[derive(Debug, Clone)]
433pub struct RayMarchRenderer {
434    /// Image width in pixels.
435    pub width: usize,
436    /// Image height in pixels.
437    pub height: usize,
438    /// Vertical field of view in radians.
439    pub fov: f64,
440    /// Camera position in world space.
441    pub camera_pos: [f64; 3],
442    /// Point the camera looks at.
443    pub camera_target: [f64; 3],
444}
445
446impl RayMarchRenderer {
447    /// Construct a new renderer.
448    pub fn new(
449        width: usize,
450        height: usize,
451        fov: f64,
452        camera_pos: [f64; 3],
453        camera_target: [f64; 3],
454    ) -> Self {
455        Self {
456            width,
457            height,
458            fov,
459            camera_pos,
460            camera_target,
461        }
462    }
463
464    /// Build the camera coordinate frame (right, up, forward).
465    fn camera_basis(&self) -> ([f64; 3], [f64; 3], [f64; 3]) {
466        let world_up = [0.0_f64, 1.0, 0.0];
467        let forward = normalize3(sub3(self.camera_target, self.camera_pos));
468        let right = normalize3(cross3_fn(forward, world_up));
469        let up = cross3_fn(right, forward);
470        (right, up, forward)
471    }
472
473    /// Generate the primary ray for pixel `(px, py)`.
474    ///
475    /// Pixel coordinates are measured from the top-left corner.
476    pub fn generate_ray(&self, px: usize, py: usize) -> Ray {
477        let (right, up, forward) = self.camera_basis();
478        let aspect = self.width as f64 / self.height.max(1) as f64;
479        let half_h = (self.fov * 0.5).tan();
480        let half_w = half_h * aspect;
481
482        // NDC → [-1, 1], y flipped
483        let u = (2.0 * (px as f64 + 0.5) / self.width as f64 - 1.0) * half_w;
484        let v = (1.0 - 2.0 * (py as f64 + 0.5) / self.height as f64) * half_h;
485
486        let dir = add3(add3(forward, scale3(right, u)), scale3(up, v));
487        Ray::new(self.camera_pos, dir)
488    }
489
490    /// Render the scene as a depth buffer.
491    ///
492    /// Returns a flat `width × height` `Vec`f64` where each element is the
493    /// `t` value at the nearest surface hit, or `f64::INFINITY` on a miss.
494    pub fn render_depth(&self, sdf: &dyn Sdf) -> Vec<f64> {
495        let n = self.width * self.height;
496        let mut depth = Vec::with_capacity(n);
497        for py in 0..self.height {
498            for px in 0..self.width {
499                let ray = self.generate_ray(px, py);
500                let result = ray_march(&ray, sdf, 256, 1000.0, 1e-5);
501                depth.push(if result.hit { result.t } else { f64::INFINITY });
502            }
503        }
504        depth
505    }
506
507    /// Render the scene as a normal buffer.
508    ///
509    /// Returns a flat `width × height` `Vec<\[f64; 3\]>` where each element is
510    /// the surface normal at the hit point, or `\[0,0,0\]` on a miss.
511    pub fn render_normals(&self, sdf: &dyn Sdf) -> Vec<[f64; 3]> {
512        let n = self.width * self.height;
513        let mut normals = Vec::with_capacity(n);
514        for py in 0..self.height {
515            for px in 0..self.width {
516                let ray = self.generate_ray(px, py);
517                let result = ray_march(&ray, sdf, 256, 1000.0, 1e-5);
518                normals.push(result.normal);
519            }
520        }
521        normals
522    }
523}
524
525/// Internal cross product helper (avoids collision with public function name).
526#[inline]
527fn cross3_fn(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
528    [
529        a[1] * b[2] - a[2] * b[1],
530        a[2] * b[0] - a[0] * b[2],
531        a[0] * b[1] - a[1] * b[0],
532    ]
533}
534
535// ── Subsurface scattering ─────────────────────────────────────────────────────
536
537/// Approximate subsurface scattering transmittance.
538///
539/// Models the fraction of light that penetrates to `depth` below the surface,
540/// using a simple exponential Beer-Lambert model modified by the absorption
541/// coefficient.
542///
543/// # Parameters
544/// - `depth` – penetration depth (metres, or consistent units).
545/// - `scatter_coeff` – scattering coefficient (1/m).
546/// - `absorption` – absorption coefficient (1/m).
547///
548/// Returns a value in `\[0, 1\]`.
549pub fn subsurface_scattering_approx(depth: f64, scatter_coeff: f64, absorption: f64) -> f64 {
550    let extinction = scatter_coeff + absorption;
551    (-extinction * depth.max(0.0)).exp()
552}
553
554// ── Tests ─────────────────────────────────────────────────────────────────────
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use std::f64::consts::PI;
560
561    // ── Ray tests ────────────────────────────────────────────────────────
562
563    #[test]
564    fn ray_at_origin() {
565        let ray = Ray::new([0.0; 3], [0.0, 0.0, 1.0]);
566        let p = ray.at(0.0);
567        assert_eq!(p, [0.0; 3]);
568    }
569
570    #[test]
571    fn ray_at_t_positive() {
572        let ray = Ray::new([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
573        let p = ray.at(5.0);
574        assert!((p[0] - 1.0).abs() < 1e-12);
575        assert!((p[2] - 5.0).abs() < 1e-12);
576    }
577
578    #[test]
579    fn ray_direction_normalized() {
580        let ray = Ray::new([0.0; 3], [3.0, 4.0, 0.0]);
581        let len = length3(ray.direction);
582        assert!((len - 1.0).abs() < 1e-12);
583    }
584
585    #[test]
586    fn ray_at_negative_t() {
587        let ray = Ray::new([0.0; 3], [1.0, 0.0, 0.0]);
588        let p = ray.at(-2.0);
589        assert!((p[0] + 2.0).abs() < 1e-12);
590    }
591
592    // ── SphereSdf tests ──────────────────────────────────────────────────
593
594    #[test]
595    fn sphere_distance_outside() {
596        let s = SphereSdf::new([0.0; 3], 1.0);
597        let d = s.distance([2.0, 0.0, 0.0]);
598        assert!((d - 1.0).abs() < 1e-10);
599    }
600
601    #[test]
602    fn sphere_distance_inside() {
603        let s = SphereSdf::new([0.0; 3], 1.0);
604        let d = s.distance([0.0; 3]);
605        assert!((d + 1.0).abs() < 1e-10);
606    }
607
608    #[test]
609    fn sphere_distance_surface() {
610        let s = SphereSdf::new([0.0; 3], 1.0);
611        let d = s.distance([1.0, 0.0, 0.0]);
612        assert!(d.abs() < 1e-12);
613    }
614
615    #[test]
616    fn sphere_normal_at_x_axis() {
617        let s = SphereSdf::new([0.0; 3], 1.0);
618        let n = s.normal([1.0, 0.0, 0.0]);
619        assert!((n[0] - 1.0).abs() < 1e-4);
620        assert!(n[1].abs() < 1e-4);
621        assert!(n[2].abs() < 1e-4);
622    }
623
624    #[test]
625    fn sphere_translated_center() {
626        let s = SphereSdf::new([5.0, 0.0, 0.0], 2.0);
627        let d = s.distance([7.0, 0.0, 0.0]);
628        assert!(d.abs() < 1e-12);
629    }
630
631    // ── BoxSdf tests ─────────────────────────────────────────────────────
632
633    #[test]
634    fn box_distance_outside_along_x() {
635        let b = BoxSdf::new([1.0; 3]);
636        let d = b.distance([3.0, 0.0, 0.0]);
637        assert!((d - 2.0).abs() < 1e-12);
638    }
639
640    #[test]
641    fn box_distance_inside() {
642        let b = BoxSdf::new([1.0; 3]);
643        let d = b.distance([0.0; 3]);
644        assert!(d < 0.0);
645    }
646
647    #[test]
648    fn box_distance_on_face() {
649        let b = BoxSdf::new([1.0, 1.0, 1.0]);
650        let d = b.distance([1.0, 0.0, 0.0]);
651        assert!(d.abs() < 1e-12);
652    }
653
654    #[test]
655    fn box_normal_x_face() {
656        let b = BoxSdf::new([1.0; 3]);
657        let n = b.normal([1.0, 0.0, 0.0]);
658        assert!((n[0] - 1.0).abs() < 1e-3);
659    }
660
661    // ── CapsuleSdf tests ─────────────────────────────────────────────────
662
663    #[test]
664    fn capsule_distance_at_midpoint() {
665        let c = CapsuleSdf::new([0.0, -1.0, 0.0], [0.0, 1.0, 0.0], 0.5);
666        let d = c.distance([0.5, 0.0, 0.0]);
667        assert!(d.abs() < 1e-10);
668    }
669
670    #[test]
671    fn capsule_distance_outside() {
672        let c = CapsuleSdf::new([0.0, -1.0, 0.0], [0.0, 1.0, 0.0], 0.5);
673        let d = c.distance([2.0, 0.0, 0.0]);
674        assert!((d - 1.5).abs() < 1e-10);
675    }
676
677    #[test]
678    fn capsule_distance_at_cap() {
679        let c = CapsuleSdf::new([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.5);
680        // Point directly above top cap
681        let d = c.distance([0.0, 2.0, 0.0]);
682        assert!((d - 0.5).abs() < 1e-10);
683    }
684
685    // ── TorusSdf tests ───────────────────────────────────────────────────
686
687    #[test]
688    fn torus_distance_on_ring() {
689        // Point on the major ring at (r_major, 0, 0) displaced by r_minor in y
690        let t = TorusSdf::new(2.0, 0.5);
691        let d = t.distance([2.0, 0.5, 0.0]);
692        assert!(d.abs() < 1e-10);
693    }
694
695    #[test]
696    fn torus_distance_centre_negative() {
697        let t = TorusSdf::new(2.0, 0.5);
698        // Origin is inside if r_major < r_minor, otherwise outside
699        let d = t.distance([0.0; 3]);
700        // distance = sqrt((0 - r_major)^2 + 0^2) - r_minor = r_major - r_minor
701        assert!((d - 1.5).abs() < 1e-10);
702    }
703
704    #[test]
705    fn torus_distance_outside_far() {
706        let t = TorusSdf::new(1.0, 0.2);
707        let d = t.distance([10.0, 0.0, 0.0]);
708        assert!(d > 0.0);
709    }
710
711    // ── CSG operator tests ───────────────────────────────────────────────
712
713    #[test]
714    fn sdf_union_picks_min() {
715        assert_eq!(sdf_union(3.0, 1.0), 1.0);
716        assert_eq!(sdf_union(-1.0, 2.0), -1.0);
717    }
718
719    #[test]
720    fn sdf_intersection_picks_max() {
721        assert_eq!(sdf_intersection(3.0, 1.0), 3.0);
722    }
723
724    #[test]
725    fn sdf_subtraction_correctness() {
726        // subtract b from a: max(a, -b)
727        assert_eq!(sdf_subtraction(1.0, -2.0), 2.0);
728        assert_eq!(sdf_subtraction(1.0, 3.0), 1.0);
729    }
730
731    #[test]
732    fn sdf_smooth_union_degenerate() {
733        // k=0 → hard union
734        let su = sdf_smooth_union(3.0, 1.0, 0.0);
735        assert!((su - sdf_union(3.0, 1.0)).abs() < 1e-10);
736    }
737
738    #[test]
739    fn sdf_smooth_union_blends() {
740        let su = sdf_smooth_union(0.0, 0.0, 1.0);
741        // At equal distances with large k, should be <= 0
742        assert!(su <= 0.0 + 1e-10);
743    }
744
745    #[test]
746    fn sdf_smooth_union_between_values() {
747        // result should be <= min(a, b)
748        let a = 2.0_f64;
749        let b = 3.0_f64;
750        let su = sdf_smooth_union(a, b, 0.5);
751        assert!(su <= a + 1e-10);
752    }
753
754    // ── RayMarchResult tests ─────────────────────────────────────────────
755
756    #[test]
757    fn ray_march_result_miss() {
758        let r = RayMarchResult::miss(100.0, 10);
759        assert!(!r.hit);
760        assert!((r.t - 100.0).abs() < 1e-12);
761    }
762
763    #[test]
764    fn ray_march_result_hit() {
765        let r = RayMarchResult::hit(5.0, 20, [1.0, 0.0, 0.0]);
766        assert!(r.hit);
767        assert!((r.normal[0] - 1.0).abs() < 1e-12);
768    }
769
770    // ── ray_march function tests ─────────────────────────────────────────
771
772    #[test]
773    fn ray_march_hits_sphere() {
774        let sphere = SphereSdf::new([0.0, 0.0, 5.0], 1.0);
775        let ray = Ray::new([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
776        let result = ray_march(&ray, &sphere, 256, 100.0, 1e-5);
777        assert!(result.hit);
778        assert!((result.t - 4.0).abs() < 1e-3);
779    }
780
781    #[test]
782    fn ray_march_misses_sphere() {
783        let sphere = SphereSdf::new([0.0, 0.0, 5.0], 1.0);
784        let ray = Ray::new([10.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
785        let result = ray_march(&ray, &sphere, 256, 100.0, 1e-5);
786        assert!(!result.hit);
787    }
788
789    #[test]
790    fn ray_march_steps_bounded() {
791        let sphere = SphereSdf::new([0.0, 0.0, 5.0], 1.0);
792        let ray = Ray::new([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
793        let result = ray_march(&ray, &sphere, 128, 100.0, 1e-5);
794        assert!(result.steps <= 128);
795    }
796
797    #[test]
798    fn ray_march_hit_normal_approx_minus_z() {
799        let sphere = SphereSdf::new([0.0, 0.0, 5.0], 1.0);
800        let ray = Ray::new([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
801        let result = ray_march(&ray, &sphere, 256, 100.0, 1e-5);
802        // Normal at front face should point toward camera (-z direction)
803        assert!(result.hit);
804        assert!(result.normal[2] < 0.0);
805    }
806
807    // ── AmbientOcclusion tests ───────────────────────────────────────────
808
809    #[test]
810    fn ao_open_space_returns_one() {
811        // A point far from any surface should be fully unoccluded
812        let sphere = SphereSdf::new([0.0; 3], 1.0);
813        let ao = AmbientOcclusion::new(8, 1.0, 1.0);
814        let pos = [100.0, 0.0, 0.0];
815        let normal = [1.0, 0.0, 0.0];
816        let v = ao.compute(pos, normal, &sphere);
817        assert!(v > 0.5);
818    }
819
820    #[test]
821    fn ao_samples_zero_returns_one() {
822        let sphere = SphereSdf::new([0.0; 3], 1.0);
823        let ao = AmbientOcclusion::new(0, 1.0, 1.0);
824        let v = ao.compute([0.0; 3], [0.0, 1.0, 0.0], &sphere);
825        assert!((v - 1.0).abs() < 1e-12);
826    }
827
828    #[test]
829    fn ao_result_clamped() {
830        let sphere = SphereSdf::new([0.0; 3], 1.0);
831        let ao = AmbientOcclusion::new(4, 0.5, 1.0);
832        let pos = [1.0, 0.0, 0.0];
833        let normal = [1.0, 0.0, 0.0];
834        let v = ao.compute(pos, normal, &sphere);
835        assert!((0.0..=1.0).contains(&v));
836    }
837
838    // ── SoftShadow tests ─────────────────────────────────────────────────
839
840    #[test]
841    fn soft_shadow_lit_no_occluder() {
842        // Point with no occluder between it and the light direction
843        let sphere = SphereSdf::new([0.0, 0.0, -100.0], 0.1);
844        let ss = SoftShadow::new([0.0, 1.0, 0.0], 8.0);
845        let pos = [0.0, 0.0, 0.0];
846        let v = ss.compute(pos, &sphere, 50.0);
847        assert!(v > 0.9);
848    }
849
850    #[test]
851    fn soft_shadow_shadow_with_occluder() {
852        // Position the sphere directly in the shadow ray path
853        let sphere = SphereSdf::new([0.0, 5.0, 0.0], 1.0);
854        let ss = SoftShadow::new([0.0, 1.0, 0.0], 8.0);
855        let pos = [0.0, 0.0, 0.0];
856        let v = ss.compute(pos, &sphere, 20.0);
857        assert!(v < 0.5);
858    }
859
860    #[test]
861    fn soft_shadow_result_in_range() {
862        let sphere = SphereSdf::new([0.0; 3], 1.0);
863        let ss = SoftShadow::new([1.0, 1.0, 1.0], 4.0);
864        let v = ss.compute([3.0, 0.0, 0.0], &sphere, 20.0);
865        assert!((0.0..=1.0).contains(&v));
866    }
867
868    // ── RayMarchRenderer tests ───────────────────────────────────────────
869
870    #[test]
871    fn renderer_depth_buffer_size() {
872        let renderer = RayMarchRenderer::new(4, 4, PI / 3.0, [0.0, 0.0, -5.0], [0.0, 0.0, 0.0]);
873        let sphere = SphereSdf::new([0.0; 3], 1.0);
874        let depth = renderer.render_depth(&sphere);
875        assert_eq!(depth.len(), 16);
876    }
877
878    #[test]
879    fn renderer_normals_buffer_size() {
880        let renderer = RayMarchRenderer::new(2, 2, PI / 3.0, [0.0, 0.0, -5.0], [0.0, 0.0, 0.0]);
881        let sphere = SphereSdf::new([0.0; 3], 1.0);
882        let normals = renderer.render_normals(&sphere);
883        assert_eq!(normals.len(), 4);
884    }
885
886    #[test]
887    fn renderer_center_ray_hits_sphere() {
888        let renderer = RayMarchRenderer::new(11, 11, PI / 3.0, [0.0, 0.0, -5.0], [0.0, 0.0, 0.0]);
889        let sphere = SphereSdf::new([0.0; 3], 1.0);
890        let depth = renderer.render_depth(&sphere);
891        // Center pixel should hit the sphere
892        let center_t = depth[5 * 11 + 5];
893        assert!(center_t < f64::INFINITY);
894    }
895
896    #[test]
897    fn renderer_corner_ray_misses_small_sphere() {
898        let renderer = RayMarchRenderer::new(11, 11, PI / 3.0, [0.0, 0.0, -5.0], [0.0, 0.0, 0.0]);
899        let sphere = SphereSdf::new([0.0; 3], 0.01);
900        let depth = renderer.render_depth(&sphere);
901        // Corner pixel (0,0) should miss the tiny sphere
902        assert_eq!(depth[0], f64::INFINITY);
903    }
904
905    #[test]
906    fn renderer_generate_ray_center() {
907        let renderer = RayMarchRenderer::new(11, 11, PI / 3.0, [0.0, 0.0, -5.0], [0.0, 0.0, 0.0]);
908        let ray = renderer.generate_ray(5, 5);
909        // Center ray should point roughly along +z
910        assert!(ray.direction[2] > 0.0);
911    }
912
913    // ── subsurface_scattering_approx tests ───────────────────────────────
914
915    #[test]
916    fn sss_zero_depth_returns_one() {
917        let v = subsurface_scattering_approx(0.0, 1.0, 1.0);
918        assert!((v - 1.0).abs() < 1e-12);
919    }
920
921    #[test]
922    fn sss_large_depth_near_zero() {
923        let v = subsurface_scattering_approx(1000.0, 1.0, 0.0);
924        assert!(v < 1e-10);
925    }
926
927    #[test]
928    fn sss_negative_depth_clamped() {
929        let v = subsurface_scattering_approx(-5.0, 1.0, 1.0);
930        assert!((v - 1.0).abs() < 1e-12);
931    }
932
933    #[test]
934    fn sss_result_in_range() {
935        let v = subsurface_scattering_approx(0.5, 2.0, 1.0);
936        assert!((0.0..=1.0).contains(&v));
937    }
938
939    #[test]
940    fn sss_monotone_decreasing() {
941        let v1 = subsurface_scattering_approx(1.0, 1.0, 0.5);
942        let v2 = subsurface_scattering_approx(2.0, 1.0, 0.5);
943        assert!(v1 > v2);
944    }
945
946    // ── Vector helper internal tests ─────────────────────────────────────
947
948    #[test]
949    fn test_add3() {
950        let r = add3([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]);
951        assert_eq!(r, [5.0, 7.0, 9.0]);
952    }
953
954    #[test]
955    fn test_sub3() {
956        let r = sub3([4.0, 5.0, 6.0], [1.0, 2.0, 3.0]);
957        assert_eq!(r, [3.0, 3.0, 3.0]);
958    }
959
960    #[test]
961    fn test_scale3() {
962        let r = scale3([1.0, 2.0, 3.0], 2.0);
963        assert_eq!(r, [2.0, 4.0, 6.0]);
964    }
965
966    #[test]
967    fn test_abs3() {
968        let r = abs3([-1.0, 2.0, -3.0]);
969        assert_eq!(r, [1.0, 2.0, 3.0]);
970    }
971
972    #[test]
973    fn test_max3() {
974        let r = max3([1.0, -1.0, 2.0], [0.0, 0.0, 0.0]);
975        assert_eq!(r, [1.0, 0.0, 2.0]);
976    }
977
978    #[test]
979    fn test_normalize3_zero() {
980        let n = normalize3([0.0; 3]);
981        assert_eq!(n, [0.0; 3]);
982    }
983
984    #[test]
985    fn test_clamp() {
986        assert_eq!(clamp(-1.0, 0.0, 1.0), 0.0);
987        assert_eq!(clamp(0.5, 0.0, 1.0), 0.5);
988        assert_eq!(clamp(2.0, 0.0, 1.0), 1.0);
989    }
990}