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}