Skip to main content

viewport_lib/scene/
scatter_volume.rs

1//! Participating-media volume primitive: per-pixel ray-marched fog / smoke / clouds.
2//!
3//! A `ScatterVolume` is a box- or sphere-bounded region of participating media.
4//! The renderer rasterises each visible volume through its screen-space
5//! bounding rectangle, ray-marches the bound region per covered fragment,
6//! accumulates absorption (Beer-Lambert) plus a lit / emissive scattered
7//! colour, and composites the result over the opaque scene.
8
9use crate::scene::aabb::Aabb;
10
11/// A ray-marched participating-media region.
12///
13/// Add to a frame via [`ScatterVolumeItem`](crate::renderer::ScatterVolumeItem)
14/// and push into `SceneFrame::scatter_volumes`. No upload step is required;
15/// the renderer packs visible volumes into a storage buffer each frame.
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct ScatterVolume {
19    /// Spatial bounds: axis-aligned box or sphere.
20    pub shape: ScatterShape,
21    /// Beer-Lambert extinction coefficient in world units.
22    ///
23    /// Typical range 0.05 to 1.0. A value of 0 disables the volume.
24    pub density: f32,
25    /// Colour source: a flat RGB or a colourmap LUT indexed by local density.
26    pub colour: ColourSource,
27    /// Henyey-Greenstein phase anisotropy in [-1, 1].
28    ///
29    /// 0.0 = isotropic (fog), positive = forward scattering (clouds, ~0.7),
30    /// negative = back scattering.
31    pub anisotropy: f32,
32    /// Self-emission. `Emission::None` disables emissive contribution.
33    pub emission: Emission,
34    /// Density curve applied before colour and emission sampling.
35    pub density_remap: DensityRemap,
36    /// Procedural noise driver. None disables the noise modulation; the
37    /// density at each march step then comes only from the base `density`
38    /// times the active remap and (if set) the density texture.
39    pub noise: Option<NoiseDriver>,
40    /// Per-volume ray-march step count override. `None` uses the global
41    /// step count from `ScatterSettings::quality`. Use a higher value for
42    /// volumes that need extra detail (clouds, fire); a lower value for
43    /// cheap background fog.
44    pub step_budget: Option<u32>,
45    /// External 3D density texture (typically baked sim output) that
46    /// modulates per-step density instead of procedural noise. Uploaded
47    /// via [`upload_volume`](crate::resources::DeviceResources::upload_volume).
48    /// The texture is sampled at normalized coordinates inside the volume's
49    /// world-space AABB. When both `noise` and `density_texture` are set
50    /// the texture takes precedence (noise is ignored for that volume).
51    /// Only one density texture can be bound per frame; the first volume
52    /// in `SceneFrame::scatter_volumes` with a texture wins for the pass.
53    pub density_texture: Option<crate::resources::VolumeId>,
54    /// Refractive distortion of the scene behind the volume. `None` is the
55    /// default and skips the refraction pass entirely. When `Some`, the
56    /// renderer copies the scene colour, samples it at a UV offset derived
57    /// from the local density gradient, and writes the distorted result
58    /// back over the volume's screen footprint before the scatter pass
59    /// runs. Used for heat haze and similar shimmer effects.
60    pub refraction: Option<RefractionParams>,
61}
62
63impl Default for ScatterVolume {
64    fn default() -> Self {
65        Self {
66            shape: ScatterShape::Box(Aabb {
67                min: glam::Vec3::splat(-0.5),
68                max: glam::Vec3::splat(0.5),
69            }),
70            density: 0.0,
71            colour: ColourSource::Flat([0.8, 0.85, 0.9]),
72            anisotropy: 0.0,
73            emission: Emission::None,
74            density_remap: DensityRemap::Identity,
75            noise: None,
76            step_budget: None,
77            density_texture: None,
78            refraction: None,
79        }
80    }
81}
82
83impl ScatterVolume {
84    /// Convenience: a uniform-density box volume with flat colour.
85    pub fn box_uniform(aabb: Aabb, density: f32, colour: [f32; 3]) -> Self {
86        Self {
87            shape: ScatterShape::Box(aabb),
88            density,
89            colour: ColourSource::Flat(colour),
90            ..Default::default()
91        }
92    }
93
94    /// Convenience: a uniform-density sphere volume with flat colour.
95    pub fn sphere_uniform(center: [f32; 3], radius: f32, density: f32, colour: [f32; 3]) -> Self {
96        Self {
97            shape: ScatterShape::Sphere { center, radius },
98            density,
99            colour: ColourSource::Flat(colour),
100            ..Default::default()
101        }
102    }
103
104    /// Conservative world-space AABB enclosing the volume.
105    pub fn world_aabb(&self) -> Aabb {
106        match self.shape {
107            ScatterShape::Box(b) => b,
108            ScatterShape::Sphere { center, radius } => {
109                let c = glam::Vec3::from(center);
110                let r = glam::Vec3::splat(radius);
111                Aabb {
112                    min: c - r,
113                    max: c + r,
114                }
115            }
116        }
117    }
118
119    /// World-space centre of the volume's shape.
120    pub fn shape_centre(&self) -> [f32; 3] {
121        match self.shape {
122            ScatterShape::Box(b) => {
123                let c = (b.min + b.max) * 0.5;
124                [c.x, c.y, c.z]
125            }
126            ScatterShape::Sphere { center, .. } => center,
127        }
128    }
129}
130
131/// Spatial bounds of a [`ScatterVolume`].
132#[derive(Debug, Clone, Copy)]
133pub enum ScatterShape {
134    /// Axis-aligned box.
135    Box(Aabb),
136    /// Sphere defined by world-space center and radius.
137    Sphere {
138        /// World-space center.
139        center: [f32; 3],
140        /// World-space radius.
141        radius: f32,
142    },
143}
144
145/// How a volume's colour is determined at each ray-march step.
146#[derive(Debug, Clone, Copy)]
147#[non_exhaustive]
148pub enum ColourSource {
149    /// Single RGB colour applied uniformly throughout the volume.
150    Flat([f32; 3]),
151    /// Density-indexed lookup through a colourmap LUT.
152    Ramp(crate::resources::ColourmapId),
153}
154
155/// Self-emission specification for a [`ScatterVolume`].
156#[derive(Debug, Clone, Copy)]
157#[non_exhaustive]
158pub enum Emission {
159    /// No emission.
160    None,
161    /// Emission proportional to a function of local density.
162    Strength {
163        /// Multiplier on the volume's colour to produce emitted radiance.
164        strength: f32,
165        /// Function mapping local density (after remap) to an emission scalar.
166        curve: EmissionCurve,
167    },
168}
169
170/// Curve mapping local density to an emission multiplier.
171#[derive(Debug, Clone, Copy)]
172pub enum EmissionCurve {
173    /// Linear in density.
174    Linear,
175    /// `density^exponent`.
176    Power(f32),
177    /// Hard threshold; emit only where density exceeds the cutoff.
178    Threshold(f32),
179}
180
181/// Remap of the raw density value before colour and emission sampling.
182#[derive(Debug, Clone, Copy)]
183#[non_exhaustive]
184pub enum DensityRemap {
185    /// Pass-through.
186    Identity,
187    /// `smoothstep(lo, hi, density)`.
188    Smoothstep {
189        /// Lower edge.
190        lo: f32,
191        /// Upper edge.
192        hi: f32,
193    },
194    /// Exponential falloff from a centre point.
195    ExpFalloff {
196        /// World-space falloff origin.
197        center: [f32; 3],
198        /// Falloff coefficient in inverse world units.
199        falloff: f32,
200    },
201}
202
203/// Procedural noise driver: fbm value noise modulates per-step density and,
204/// when `time_scale` is non-zero, evolves over time.
205#[derive(Debug, Clone, Copy)]
206#[non_exhaustive]
207pub struct NoiseDriver {
208    /// Base frequency in inverse world units. Higher = finer detail.
209    pub scale: f32,
210    /// Number of fbm octaves (clamped to 1..=6 by the shader).
211    pub octaves: u32,
212    /// Animation scroll velocity (world units per second). Adds to the
213    /// sample position each frame for drifting smoke / flowing clouds.
214    pub scroll_velocity: [f32; 3],
215    /// Per-second domain-warp rate. Non-zero values make the noise field
216    /// evolve in place without drifting in any direction (good for fire
217    /// flicker). 0 = static (only `scroll_velocity` animates).
218    pub time_scale: f32,
219    /// Fbm frequency multiplier per octave. Typical values 1.8..2.2; 2.0
220    /// is the standard. Clamped to 1.1..=4.0 by the shader.
221    pub lacunarity: f32,
222}
223
224impl Default for NoiseDriver {
225    fn default() -> Self {
226        Self {
227            scale: 1.0,
228            octaves: 3,
229            scroll_velocity: [0.0; 3],
230            time_scale: 0.0,
231            lacunarity: 2.0,
232        }
233    }
234}
235
236/// Refractive distortion parameters for a [`ScatterVolume`].
237///
238/// The renderer samples the scene colour at a UV offset taken from the
239/// local density gradient and writes the result over the volume's screen
240/// footprint. The scatter pass then runs on top of the distorted scene,
241/// so absorption and in-scattering still apply normally.
242#[derive(Debug, Clone, Copy)]
243#[non_exhaustive]
244pub struct RefractionParams {
245    /// Maximum screen-space displacement, in normalized UV units. Typical
246    /// values are 0.005 to 0.04. Larger values look like strong heat haze.
247    pub strength: f32,
248    /// Density threshold below which a sample contributes no distortion.
249    /// Useful for keeping wispy edges quiet while the hot core shimmers.
250    pub density_threshold: f32,
251    /// Frequency multiplier on the noise field that drives the gradient.
252    /// Higher values make the shimmer finer.
253    pub noise_scale: f32,
254}
255
256impl Default for RefractionParams {
257    fn default() -> Self {
258        Self {
259            strength: 0.015,
260            density_threshold: 0.05,
261            noise_scale: 1.5,
262        }
263    }
264}
265
266/// Packed GPU layout for a refractive volume. 80 bytes, 16-byte aligned.
267#[repr(C)]
268#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
269pub struct GpuRefractionVolume {
270    /// 0 = Box, 1 = Sphere.
271    pub shape_kind: u32,
272    /// Padding to keep the following `vec4<f32>` 16-byte aligned.
273    pub _pad0: u32,
274    /// Padding to keep the following `vec4<f32>` 16-byte aligned.
275    pub _pad1: u32,
276    /// Padding to keep the following `vec4<f32>` 16-byte aligned.
277    pub _pad2: u32,
278    /// Box: `min.xyz, _`. Sphere: `center.xyz, radius`.
279    pub p0: [f32; 4],
280    /// Box: `max.xyz, _`. Sphere: unused.
281    pub p1: [f32; 4],
282    /// Distortion parameters: `(strength, density_threshold, noise_scale, time)`.
283    pub params: [f32; 4],
284}
285
286impl GpuRefractionVolume {
287    /// Pack the refraction half of a `ScatterVolume`. Returns `None` when the
288    /// volume has no refraction enabled or zero strength.
289    pub fn pack(volume: &ScatterVolume, time_seconds: f32) -> Option<Self> {
290        let r = volume.refraction?;
291        if !(r.strength > 0.0) {
292            return None;
293        }
294        let (shape_kind, p0, p1) = match volume.shape {
295            ScatterShape::Box(b) => (
296                0u32,
297                [b.min.x, b.min.y, b.min.z, 0.0],
298                [b.max.x, b.max.y, b.max.z, 0.0],
299            ),
300            ScatterShape::Sphere { center, radius } => {
301                (1u32, [center[0], center[1], center[2], radius], [0.0; 4])
302            }
303        };
304        Some(Self {
305            shape_kind,
306            _pad0: 0,
307            _pad1: 0,
308            _pad2: 0,
309            p0,
310            p1,
311            params: [
312                r.strength,
313                r.density_threshold.max(0.0),
314                r.noise_scale.max(1e-4),
315                time_seconds,
316            ],
317        })
318    }
319}
320
321/// CPU representation of the GPU storage entry. Public so consumers writing
322/// custom render paths can pack their own buffers; ordinary use does not need
323/// to touch this.
324///
325/// Layout: 112 bytes, 16-byte aligned. Matches `GpuScatterVolume` in
326/// `src/shaders/scatter_volume.wgsl`.
327#[repr(C)]
328#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
329pub struct GpuScatterVolume {
330    /// 0 = Box, 1 = Sphere. Future variants extend this number.
331    pub shape_kind: u32,
332    /// Bit flags: 1 = unlit (skip in-scattering), 2 = receive_shadows,
333    /// 4 = use_ramp (sample colourmap LUT instead of flat colour).
334    pub flags: u32,
335    /// Density remap kind: 0 = Identity, 1 = Smoothstep, 2 = ExpFalloff.
336    pub remap_kind: u32,
337    /// Emission kind: 0 = None, 1 = Linear, 2 = Power, 3 = Threshold.
338    pub emission_kind: u32,
339    /// Box: `min.xyz`, `_`. Sphere: `center.xyz, radius`.
340    pub p0: [f32; 4],
341    /// Box: `max.xyz, _`. Sphere: unused.
342    pub p1: [f32; 4],
343    /// RGB scattered colour and density (a = density). When the `use_ramp`
344    /// flag is set, rgb is a tint that multiplies the LUT sample.
345    pub colour_density: [f32; 4],
346    /// Scalar parameters: x = Henyey-Greenstein anisotropy g,
347    /// y = emission_strength, z = emission_curve_param, w = step_budget
348    /// (per-volume march steps; 0 = use global default).
349    pub params: [f32; 4],
350    /// Per-remap centre + a leading scalar: `(center.x, center.y, center.z, a)`.
351    /// Smoothstep: `a = lo`. ExpFalloff: `a = falloff`. Identity: unused.
352    pub remap_data: [f32; 4],
353    /// Per-remap overflow: `x = hi` for Smoothstep; unused otherwise.
354    pub remap_data2: [f32; 4],
355    /// Procedural noise driver: `(scale, octaves_as_f32, time_scale, lacunarity)`.
356    /// Only honoured when the `USE_NOISE` flag is set.
357    pub noise_pack: [f32; 4],
358    /// Noise animation: `(scroll_velocity.xyz, _)` in world units per second.
359    pub noise_vel: [f32; 4],
360}
361
362/// Flag bit: skip in-scattering (treat the volume as `unlit`).
363pub const SCATTER_FLAG_UNLIT: u32 = 1;
364/// Flag bit: sample the shadow map at each march step.
365pub const SCATTER_FLAG_RECEIVE_SHADOWS: u32 = 2;
366/// Flag bit: this volume's colour comes from the bound colourmap LUT.
367pub const SCATTER_FLAG_USE_RAMP: u32 = 4;
368/// Flag bit: modulate per-step density by procedural noise.
369pub const SCATTER_FLAG_USE_NOISE: u32 = 8;
370/// Flag bit: modulate per-step density by sampling the bound 3D density texture.
371pub const SCATTER_FLAG_USE_DENSITY_TEXTURE: u32 = 16;
372
373impl GpuScatterVolume {
374    /// Pack a CPU `ScatterVolume` into the GPU layout. `density_multiplier`
375    /// folds `ItemSettings::opacity` into the effective density. `flags` is
376    /// the per-volume settings bitfield (see `SCATTER_FLAG_*` constants).
377    /// Returns `None` if the resulting density is non-positive.
378    pub fn pack(volume: &ScatterVolume, density_multiplier: f32, flags: u32) -> Option<Self> {
379        let density = volume.density * density_multiplier;
380        if !(density > 0.0) {
381            return None;
382        }
383        let mut effective_flags = flags;
384        let colour = match volume.colour {
385            ColourSource::Flat(rgb) => rgb,
386            ColourSource::Ramp(_) => {
387                // Tag the volume so the shader samples the bound colourmap
388                // LUT. `colour_density.rgb` becomes a tint applied on top of
389                // the LUT sample; default to white so the LUT shows through
390                // unchanged. Consumers wanting a tinted ramp can construct
391                // with a tinted `Flat` colour before switching to `Ramp`.
392                effective_flags |= SCATTER_FLAG_USE_RAMP;
393                [1.0, 1.0, 1.0]
394            }
395        };
396        let (shape_kind, p0, p1) = match volume.shape {
397            ScatterShape::Box(b) => (
398                0u32,
399                [b.min.x, b.min.y, b.min.z, 0.0],
400                [b.max.x, b.max.y, b.max.z, 0.0],
401            ),
402            ScatterShape::Sphere { center, radius } => {
403                (1u32, [center[0], center[1], center[2], radius], [0.0; 4])
404            }
405        };
406        let anisotropy = volume.anisotropy.clamp(-0.95, 0.95);
407        let centre = match volume.shape {
408            ScatterShape::Box(b) => {
409                let c = (b.min + b.max) * 0.5;
410                [c.x, c.y, c.z]
411            }
412            ScatterShape::Sphere { center, .. } => center,
413        };
414        let (remap_kind, remap_data, remap_data2) = match volume.density_remap {
415            DensityRemap::Identity => (0u32, [0.0; 4], [0.0; 4]),
416            DensityRemap::Smoothstep { lo, hi } => (
417                1u32,
418                [centre[0], centre[1], centre[2], lo],
419                [hi, 0.0, 0.0, 0.0],
420            ),
421            DensityRemap::ExpFalloff { center, falloff } => {
422                (2u32, [center[0], center[1], center[2], falloff], [0.0; 4])
423            }
424        };
425        let (emission_kind, emission_strength, emission_param) = match volume.emission {
426            Emission::None => (0u32, 0.0, 0.0),
427            Emission::Strength { strength, curve } => match curve {
428                EmissionCurve::Linear => (1u32, strength, 0.0),
429                EmissionCurve::Power(exponent) => (2u32, strength, exponent),
430                EmissionCurve::Threshold(min_density) => (3u32, strength, min_density),
431            },
432        };
433        let (noise_pack, noise_vel) = match volume.noise {
434            None => ([0.0; 4], [0.0; 4]),
435            Some(n) => {
436                effective_flags |= SCATTER_FLAG_USE_NOISE;
437                (
438                    [
439                        n.scale.max(1e-4),
440                        n.octaves.clamp(1, 6) as f32,
441                        n.time_scale,
442                        n.lacunarity.clamp(1.1, 4.0),
443                    ],
444                    [
445                        n.scroll_velocity[0],
446                        n.scroll_velocity[1],
447                        n.scroll_velocity[2],
448                        0.0,
449                    ],
450                )
451            }
452        };
453        if volume.density_texture.is_some() {
454            effective_flags |= SCATTER_FLAG_USE_DENSITY_TEXTURE;
455            // Density texture takes precedence over noise per the docs.
456            effective_flags &= !SCATTER_FLAG_USE_NOISE;
457        }
458        let step_budget_f = volume
459            .step_budget
460            .map(|b| b.clamp(1, 128) as f32)
461            .unwrap_or(0.0);
462        Some(Self {
463            shape_kind,
464            flags: effective_flags,
465            remap_kind,
466            emission_kind,
467            p0,
468            p1,
469            colour_density: [colour[0], colour[1], colour[2], density],
470            params: [anisotropy, emission_strength, emission_param, step_budget_f],
471            remap_data,
472            remap_data2,
473            noise_pack,
474            noise_vel,
475        })
476    }
477}
478
479/// CPU ray-vs-shape intersection used by picking and verified by tests.
480///
481/// Returns `Some((t_enter, t_exit))` in ray parameter units. Both are clamped
482/// to be non-negative (camera-inside case sets `t_enter = 0`). Returns `None`
483/// when the ray misses the shape or exits before entering.
484pub fn ray_intersect(
485    shape: &ScatterShape,
486    origin: glam::Vec3,
487    dir: glam::Vec3,
488) -> Option<(f32, f32)> {
489    match shape {
490        ScatterShape::Box(b) => ray_box(b, origin, dir),
491        ScatterShape::Sphere { center, radius } => {
492            ray_sphere(glam::Vec3::from(*center), *radius, origin, dir)
493        }
494    }
495}
496
497fn ray_box(b: &Aabb, o: glam::Vec3, d: glam::Vec3) -> Option<(f32, f32)> {
498    let inv = glam::Vec3::new(
499        if d.x.abs() > 1e-8 {
500            1.0 / d.x
501        } else {
502            f32::INFINITY
503        },
504        if d.y.abs() > 1e-8 {
505            1.0 / d.y
506        } else {
507            f32::INFINITY
508        },
509        if d.z.abs() > 1e-8 {
510            1.0 / d.z
511        } else {
512            f32::INFINITY
513        },
514    );
515    let t0 = (b.min - o) * inv;
516    let t1 = (b.max - o) * inv;
517    let t_min = t0.min(t1);
518    let t_max = t0.max(t1);
519    let t_enter = t_min.x.max(t_min.y).max(t_min.z).max(0.0);
520    let t_exit = t_max.x.min(t_max.y).min(t_max.z);
521    if t_enter >= t_exit || t_exit <= 0.0 {
522        None
523    } else {
524        Some((t_enter, t_exit))
525    }
526}
527
528fn ray_sphere(c: glam::Vec3, r: f32, o: glam::Vec3, d: glam::Vec3) -> Option<(f32, f32)> {
529    let oc = o - c;
530    let a = d.dot(d);
531    let b = 2.0 * oc.dot(d);
532    let cc = oc.dot(oc) - r * r;
533    let disc = b * b - 4.0 * a * cc;
534    if disc < 0.0 {
535        return None;
536    }
537    let sq = disc.sqrt();
538    let t0 = (-b - sq) / (2.0 * a);
539    let t1 = (-b + sq) / (2.0 * a);
540    let t_enter = t0.max(0.0);
541    let t_exit = t1;
542    if t_enter >= t_exit || t_exit <= 0.0 {
543        None
544    } else {
545        Some((t_enter, t_exit))
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn default_volume_has_zero_density() {
555        let v = ScatterVolume::default();
556        assert_eq!(v.density, 0.0);
557        assert!(matches!(v.colour, ColourSource::Flat(_)));
558        assert!(matches!(v.emission, Emission::None));
559        assert!(matches!(v.density_remap, DensityRemap::Identity));
560        assert!(v.noise.is_none());
561    }
562
563    #[test]
564    fn pack_zero_density_returns_none() {
565        let v = ScatterVolume::default();
566        assert!(GpuScatterVolume::pack(&v, 1.0, 0).is_none());
567    }
568
569    #[test]
570    fn pack_box_round_trips() {
571        let v = ScatterVolume::box_uniform(
572            Aabb {
573                min: glam::Vec3::new(-1.0, -2.0, -3.0),
574                max: glam::Vec3::new(4.0, 5.0, 6.0),
575            },
576            0.2,
577            [0.1, 0.2, 0.3],
578        );
579        let g = GpuScatterVolume::pack(&v, 1.0, 0).unwrap();
580        assert_eq!(g.shape_kind, 0);
581        assert_eq!(&g.p0[..3], &[-1.0, -2.0, -3.0]);
582        assert_eq!(&g.p1[..3], &[4.0, 5.0, 6.0]);
583        assert_eq!(g.colour_density, [0.1, 0.2, 0.3, 0.2]);
584    }
585
586    #[test]
587    fn pack_sphere_round_trips() {
588        let v = ScatterVolume::sphere_uniform([1.0, 2.0, 3.0], 4.0, 0.5, [0.4, 0.5, 0.6]);
589        let g = GpuScatterVolume::pack(&v, 1.0, 0).unwrap();
590        assert_eq!(g.shape_kind, 1);
591        assert_eq!(g.p0, [1.0, 2.0, 3.0, 4.0]);
592        assert_eq!(g.colour_density, [0.4, 0.5, 0.6, 0.5]);
593    }
594
595    #[test]
596    fn opacity_multiplier_scales_density() {
597        let v = ScatterVolume::box_uniform(
598            Aabb {
599                min: glam::Vec3::ZERO,
600                max: glam::Vec3::ONE,
601            },
602            0.4,
603            [1.0; 3],
604        );
605        let g = GpuScatterVolume::pack(&v, 0.5, 0).unwrap();
606        assert!((g.colour_density[3] - 0.2).abs() < 1e-6);
607    }
608
609    #[test]
610    fn ray_box_hits_from_outside() {
611        let b = Aabb {
612            min: glam::Vec3::new(-1.0, -1.0, -1.0),
613            max: glam::Vec3::new(1.0, 1.0, 1.0),
614        };
615        let hit = ray_intersect(
616            &ScatterShape::Box(b),
617            glam::Vec3::new(0.0, 0.0, -5.0),
618            glam::Vec3::Z,
619        );
620        let (enter, exit) = hit.unwrap();
621        assert!((enter - 4.0).abs() < 1e-4);
622        assert!((exit - 6.0).abs() < 1e-4);
623    }
624
625    #[test]
626    fn ray_box_camera_inside_starts_at_zero() {
627        let b = Aabb {
628            min: glam::Vec3::new(-1.0, -1.0, -1.0),
629            max: glam::Vec3::new(1.0, 1.0, 1.0),
630        };
631        let hit = ray_intersect(&ScatterShape::Box(b), glam::Vec3::ZERO, glam::Vec3::Z);
632        let (enter, exit) = hit.unwrap();
633        assert_eq!(enter, 0.0);
634        assert!((exit - 1.0).abs() < 1e-4);
635    }
636
637    #[test]
638    fn ray_sphere_misses() {
639        let hit = ray_intersect(
640            &ScatterShape::Sphere {
641                center: [0.0, 0.0, 0.0],
642                radius: 1.0,
643            },
644            glam::Vec3::new(2.0, 0.0, -5.0),
645            glam::Vec3::Z,
646        );
647        assert!(hit.is_none());
648    }
649
650    #[test]
651    fn ray_sphere_camera_inside_starts_at_zero() {
652        let hit = ray_intersect(
653            &ScatterShape::Sphere {
654                center: [0.0, 0.0, 0.0],
655                radius: 1.0,
656            },
657            glam::Vec3::ZERO,
658            glam::Vec3::Z,
659        );
660        let (enter, exit) = hit.unwrap();
661        assert_eq!(enter, 0.0);
662        assert!((exit - 1.0).abs() < 1e-4);
663    }
664
665    #[test]
666    fn world_aabb_sphere_matches_bounds() {
667        let v = ScatterVolume::sphere_uniform([0.0, 0.0, 0.0], 2.0, 0.1, [1.0; 3]);
668        let b = v.world_aabb();
669        assert_eq!(b.min, glam::Vec3::splat(-2.0));
670        assert_eq!(b.max, glam::Vec3::splat(2.0));
671    }
672}