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