1use glam::{Vec3, Vec4, Mat4};
39use std::f32::consts::PI;
40
41#[derive(Debug, Clone)]
47pub struct VolumetricFogConfig {
48 pub grid_size: (u32, u32, u32),
50 pub near: f32,
52 pub far: f32,
54 pub global_density: f32,
56 pub height_fog_density: f32,
58 pub height_falloff: f32,
60 pub height_base: f32,
62 pub albedo: Vec3,
65 pub anisotropy: f32,
68 pub ambient_light: Vec3,
70 pub noise: NoiseConfig,
72 pub temporal_blend: f32,
74 pub temporal_enabled: bool,
76 pub field_injection: FieldInjectionConfig,
78}
79
80#[derive(Debug, Clone)]
82pub struct NoiseConfig {
83 pub enabled: bool,
85 pub frequency: f32,
87 pub amplitude: f32,
89 pub octaves: u32,
91 pub wind: Vec3,
93 pub offset: Vec3,
95}
96
97#[derive(Debug, Clone)]
99pub struct FieldInjectionConfig {
100 pub enabled: bool,
102 pub attractor_density: f32,
104 pub vortex_density: f32,
106 pub gravity_density: f32,
108 pub shockwave_density: f32,
110 pub max_radius: f32,
112}
113
114impl Default for VolumetricFogConfig {
115 fn default() -> Self {
116 Self {
117 grid_size: (160, 90, 128),
118 near: 0.5,
119 far: 100.0,
120 global_density: 0.005,
121 height_fog_density: 0.02,
122 height_falloff: 0.15,
123 height_base: 0.0,
124 albedo: Vec3::splat(0.9),
125 anisotropy: 0.3,
126 ambient_light: Vec3::new(0.02, 0.025, 0.035),
127 noise: NoiseConfig::default(),
128 temporal_blend: 0.95,
129 temporal_enabled: true,
130 field_injection: FieldInjectionConfig::default(),
131 }
132 }
133}
134
135impl Default for NoiseConfig {
136 fn default() -> Self {
137 Self {
138 enabled: true,
139 frequency: 0.3,
140 amplitude: 0.5,
141 octaves: 3,
142 wind: Vec3::new(0.5, 0.05, 0.2),
143 offset: Vec3::ZERO,
144 }
145 }
146}
147
148impl Default for FieldInjectionConfig {
149 fn default() -> Self {
150 Self {
151 enabled: true,
152 attractor_density: 0.1,
153 vortex_density: 0.05,
154 gravity_density: 0.03,
155 shockwave_density: 0.2,
156 max_radius: 20.0,
157 }
158 }
159}
160
161#[derive(Debug, Clone, Copy, Default)]
167pub struct Froxel {
168 pub scattering: Vec3,
170 pub extinction: f32,
172 pub in_scatter: Vec3,
174 pub in_scatter_directional: Vec3,
176}
177
178#[derive(Debug, Clone, Copy)]
180pub struct FogResult {
181 pub inscatter: Vec3,
183 pub transmittance: f32,
185}
186
187impl Default for FogResult {
188 fn default() -> Self { Self { inscatter: Vec3::ZERO, transmittance: 1.0 } }
189}
190
191#[derive(Debug, Clone, Copy)]
197pub enum FogLight {
198 Directional {
199 direction: Vec3,
200 color: Vec3,
201 intensity: f32,
202 },
203 Point {
204 position: Vec3,
205 color: Vec3,
206 intensity: f32,
207 radius: f32,
208 },
209 Spot {
210 position: Vec3,
211 direction: Vec3,
212 color: Vec3,
213 intensity: f32,
214 radius: f32,
215 cone_angle: f32,
216 },
217}
218
219#[derive(Debug, Clone, Copy)]
225pub struct FogFieldSource {
226 pub position: Vec3,
227 pub radius: f32,
228 pub density: f32,
229 pub color_tint: Vec3,
230 pub field_type: FogFieldType,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq)]
234pub enum FogFieldType {
235 Attractor,
236 Vortex,
237 Gravity,
238 Shockwave { age: f32, speed: f32 },
239}
240
241pub struct VolumetricFogPipeline {
246 pub config: VolumetricFogConfig,
247 grid: Vec<Froxel>,
249 prev_integrated: Vec<FogResult>,
251 integrated: Vec<FogResult>,
253 time: f32,
255 gw: u32, gh: u32, gd: u32,
257}
258
259impl VolumetricFogPipeline {
260 pub fn new(config: VolumetricFogConfig) -> Self {
261 let (gw, gh, gd) = config.grid_size;
262 let froxel_count = (gw * gh * gd) as usize;
263 let pixel_count = (gw * gh) as usize;
264 Self {
265 grid: vec![Froxel::default(); froxel_count],
266 prev_integrated: vec![FogResult::default(); pixel_count],
267 integrated: vec![FogResult::default(); pixel_count],
268 time: 0.0,
269 gw, gh, gd,
270 config,
271 }
272 }
273
274 fn slice_depth(&self, slice: u32) -> f32 {
276 let t = slice as f32 / self.gd as f32;
277 self.config.near * (self.config.far / self.config.near).powf(t)
278 }
279
280 fn depth_to_slice(&self, depth: f32) -> u32 {
282 if depth <= self.config.near { return 0; }
283 let t = (depth / self.config.near).ln() / (self.config.far / self.config.near).ln();
284 (t * self.gd as f32).clamp(0.0, (self.gd - 1) as f32) as u32
285 }
286
287 fn idx(&self, x: u32, y: u32, z: u32) -> usize {
288 (z * self.gh * self.gw + y * self.gw + x) as usize
289 }
290
291 fn idx_2d(&self, x: u32, y: u32) -> usize {
292 (y * self.gw + x) as usize
293 }
294
295 pub fn inject_density(
301 &mut self,
302 dt: f32,
303 inv_view_proj: &Mat4,
304 camera_pos: Vec3,
305 field_sources: &[FogFieldSource],
306 ) {
307 self.time += dt;
308
309 for froxel in &mut self.grid {
310 *froxel = Froxel::default();
311 }
312
313 let noise_time_offset = self.config.noise.wind * self.time;
314
315 for z in 0..self.gd {
316 let depth = self.slice_depth(z);
317 let next_depth = self.slice_depth((z + 1).min(self.gd - 1));
318 let slice_thickness = next_depth - depth;
319
320 for y in 0..self.gh {
321 for x in 0..self.gw {
322 let ndc_x = (x as f32 + 0.5) / self.gw as f32 * 2.0 - 1.0;
324 let ndc_y = (y as f32 + 0.5) / self.gh as f32 * 2.0 - 1.0;
325 let ndc_z = depth / self.config.far * 2.0 - 1.0;
326 let clip = Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
327 let world4 = *inv_view_proj * clip;
328 let world_pos = Vec3::new(world4.x, world4.y, world4.z) / world4.w;
329
330 let idx = self.idx(x, y, z);
331
332 let mut density = self.config.global_density;
334
335 let height = world_pos.y - self.config.height_base;
337 let height_density = self.config.height_fog_density
338 * (-height.max(0.0) * self.config.height_falloff).exp();
339 density += height_density;
340
341 if self.config.noise.enabled {
343 let np = world_pos * self.config.noise.frequency + noise_time_offset;
344 let noise = fbm_3d(np.x, np.y, np.z, self.config.noise.octaves);
345 density *= (1.0 + noise * self.config.noise.amplitude).max(0.0);
346 }
347
348 if self.config.field_injection.enabled {
350 for source in field_sources {
351 let to_field = world_pos - source.position;
352 let dist = to_field.length();
353 if dist > source.radius { continue; }
354
355 let falloff = 1.0 - (dist / source.radius);
356 let falloff_sq = falloff * falloff;
357
358 let field_density = match source.field_type {
359 FogFieldType::Attractor => {
360 source.density * self.config.field_injection.attractor_density * falloff_sq
361 }
362 FogFieldType::Vortex => {
363 let ring_dist = (dist - source.radius * 0.5).abs() / (source.radius * 0.3);
365 let ring = (-ring_dist * ring_dist).exp();
366 source.density * self.config.field_injection.vortex_density * ring
367 }
368 FogFieldType::Gravity => {
369 source.density * self.config.field_injection.gravity_density * falloff
370 }
371 FogFieldType::Shockwave { age, speed } => {
372 let ring_radius = age * speed;
374 let ring_dist = (dist - ring_radius).abs();
375 let ring_width = 2.0;
376 let ring = (-ring_dist * ring_dist / (ring_width * ring_width)).exp();
377 let fade = (1.0 - age / 3.0).max(0.0); source.density * self.config.field_injection.shockwave_density * ring * fade
379 }
380 };
381
382 density += field_density;
383 }
384 }
385
386 let extinction = density;
388 let scattering = self.config.albedo * density;
389 self.grid[idx] = Froxel {
390 scattering,
391 extinction,
392 in_scatter: Vec3::ZERO,
393 in_scatter_directional: Vec3::ZERO,
394 };
395 }
396 }
397 }
398 }
399
400 pub fn scatter_light(
406 &mut self,
407 inv_view_proj: &Mat4,
408 camera_pos: Vec3,
409 lights: &[FogLight],
410 ) {
411 for z in 0..self.gd {
412 let depth = self.slice_depth(z);
413 for y in 0..self.gh {
414 for x in 0..self.gw {
415 let ndc_x = (x as f32 + 0.5) / self.gw as f32 * 2.0 - 1.0;
416 let ndc_y = (y as f32 + 0.5) / self.gh as f32 * 2.0 - 1.0;
417 let ndc_z = depth / self.config.far * 2.0 - 1.0;
418 let clip = Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
419 let world4 = *inv_view_proj * clip;
420 let world_pos = Vec3::new(world4.x, world4.y, world4.z) / world4.w;
421
422 let idx = self.idx(x, y, z);
423 let froxel = &self.grid[idx];
424 if froxel.extinction < 1e-7 { continue; }
425
426 let view_dir = (world_pos - camera_pos).normalize_or_zero();
427 let mut total_inscatter = self.config.ambient_light * froxel.scattering;
428
429 for light in lights {
430 let (light_color, light_intensity, to_light, attenuation) = match light {
431 FogLight::Directional { direction, color, intensity } => {
432 (*color, *intensity, -*direction, 1.0)
433 }
434 FogLight::Point { position, color, intensity, radius } => {
435 let to = *position - world_pos;
436 let dist = to.length();
437 if dist > *radius { continue; }
438 let atten = (1.0 - dist / radius).max(0.0);
439 (*color, *intensity, to.normalize_or_zero(), atten * atten)
440 }
441 FogLight::Spot { position, direction, color, intensity, radius, cone_angle } => {
442 let to = *position - world_pos;
443 let dist = to.length();
444 if dist > *radius { continue; }
445 let to_norm = to.normalize_or_zero();
446 let cos_angle = (-to_norm).dot(*direction);
447 if cos_angle < cone_angle.cos() { continue; }
448 let atten = (1.0 - dist / radius).max(0.0);
449 let spot_atten = ((cos_angle - cone_angle.cos()) / (1.0 - cone_angle.cos())).max(0.0);
450 (*color, *intensity, to_norm, atten * atten * spot_atten)
451 }
452 };
453
454 let cos_theta = view_dir.dot(to_light);
456 let phase = henyey_greenstein(cos_theta, self.config.anisotropy);
457
458 total_inscatter += light_color * light_intensity * attenuation
459 * froxel.scattering * phase;
460 }
461
462 let froxel_mut = &mut self.grid[idx];
464 froxel_mut.in_scatter = total_inscatter;
465 }
466 }
467 }
468 }
469
470 pub fn temporal_reproject(&mut self) {
476 if !self.config.temporal_enabled { return; }
477
478 let blend = self.config.temporal_blend;
479 }
484
485 pub fn integrate(&mut self) {
491 if self.config.temporal_enabled {
493 std::mem::swap(&mut self.integrated, &mut self.prev_integrated);
494 }
495
496 for y in 0..self.gh {
497 for x in 0..self.gw {
498 let mut accumulated_scatter = Vec3::ZERO;
499 let mut accumulated_transmittance = 1.0f32;
500
501 for z in 0..self.gd {
502 let idx = self.idx(x, y, z);
503 let froxel = &self.grid[idx];
504
505 let depth = self.slice_depth(z);
506 let next_depth = self.slice_depth((z + 1).min(self.gd - 1));
507 let slice_thickness = next_depth - depth;
508
509 let slice_extinction = froxel.extinction * slice_thickness;
511 let slice_transmittance = (-slice_extinction).exp();
512
513 let scatter_integral = if slice_extinction > 1e-7 {
515 (1.0 - slice_transmittance) / slice_extinction
516 } else {
517 slice_thickness
518 };
519
520 accumulated_scatter += froxel.in_scatter * scatter_integral * accumulated_transmittance;
521 accumulated_transmittance *= slice_transmittance;
522
523 if accumulated_transmittance < 0.001 { break; }
525 }
526
527 let idx_2d = self.idx_2d(x, y);
528 let mut result = FogResult {
529 inscatter: accumulated_scatter,
530 transmittance: accumulated_transmittance,
531 };
532
533 if self.config.temporal_enabled && idx_2d < self.prev_integrated.len() {
535 let prev = &self.prev_integrated[idx_2d];
536 let blend = self.config.temporal_blend;
537 result.inscatter = prev.inscatter * blend + result.inscatter * (1.0 - blend);
538 result.transmittance = prev.transmittance * blend + result.transmittance * (1.0 - blend);
539 }
540
541 self.integrated[idx_2d] = result;
542 }
543 }
544 }
545
546 pub fn update(
552 &mut self,
553 dt: f32,
554 inv_view_proj: &Mat4,
555 camera_pos: Vec3,
556 lights: &[FogLight],
557 field_sources: &[FogFieldSource],
558 ) {
559 self.inject_density(dt, inv_view_proj, camera_pos, field_sources);
560 self.scatter_light(inv_view_proj, camera_pos, lights);
561 self.integrate();
562 }
563
564 pub fn sample_pixel(&self, screen_x: f32, screen_y: f32) -> FogResult {
566 let px = (screen_x * self.gw as f32).clamp(0.0, (self.gw - 1) as f32) as u32;
567 let py = (screen_y * self.gh as f32).clamp(0.0, (self.gh - 1) as f32) as u32;
568 let idx = self.idx_2d(px, py);
569 if idx < self.integrated.len() { self.integrated[idx] } else { FogResult::default() }
570 }
571
572 pub fn sample_at_depth(&self, screen_x: f32, screen_y: f32, depth: f32) -> FogResult {
575 let px = (screen_x * self.gw as f32).clamp(0.0, (self.gw - 1) as f32) as u32;
576 let py = (screen_y * self.gh as f32).clamp(0.0, (self.gh - 1) as f32) as u32;
577 let target_slice = self.depth_to_slice(depth);
578
579 let mut scatter = Vec3::ZERO;
580 let mut transmittance = 1.0f32;
581
582 for z in 0..=target_slice.min(self.gd - 1) {
583 let idx = self.idx(px, py, z);
584 let froxel = &self.grid[idx];
585 let d = self.slice_depth(z);
586 let nd = self.slice_depth((z + 1).min(self.gd - 1));
587 let thickness = nd - d;
588 let ext = froxel.extinction * thickness;
589 let trans = (-ext).exp();
590 let integral = if ext > 1e-7 { (1.0 - trans) / ext } else { thickness };
591 scatter += froxel.in_scatter * integral * transmittance;
592 transmittance *= trans;
593 }
594
595 FogResult { inscatter: scatter, transmittance }
596 }
597
598 pub fn grid_size(&self) -> (u32, u32, u32) { (self.gw, self.gh, self.gd) }
600
601 pub fn froxel_count(&self) -> usize { (self.gw * self.gh * self.gd) as usize }
603
604 pub fn memory_bytes(&self) -> usize {
606 self.grid.len() * std::mem::size_of::<Froxel>()
607 + self.integrated.len() * std::mem::size_of::<FogResult>() * 2
608 }
609
610 pub fn glsl_inject_compute() -> &'static str {
616 r#"
617#version 430
618layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
619
620layout(rgba16f, binding = 0) uniform image3D u_fog_volume;
621uniform mat4 u_inv_view_proj;
622uniform vec3 u_camera_pos;
623uniform float u_time;
624uniform float u_global_density;
625uniform float u_height_density;
626uniform float u_height_falloff;
627uniform float u_height_base;
628uniform float u_near;
629uniform float u_far;
630uniform int u_depth_slices;
631
632// 3D value noise
633float hash(vec3 p) {
634 p = fract(p * 0.3183099 + 0.1);
635 p *= 17.0;
636 return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
637}
638
639float noise3d(vec3 p) {
640 vec3 i = floor(p);
641 vec3 f = fract(p);
642 f = f * f * (3.0 - 2.0 * f);
643 return mix(mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),
644 mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
645 mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
646 mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y), f.z);
647}
648
649float fbm(vec3 p) {
650 float v = 0.0, a = 0.5;
651 for (int i = 0; i < 3; i++) {
652 v += a * noise3d(p);
653 p *= 2.0;
654 a *= 0.5;
655 }
656 return v;
657}
658
659void main() {
660 ivec3 id = ivec3(gl_GlobalInvocationID.xyz);
661 ivec3 grid = ivec3(imageSize(u_fog_volume));
662 if (any(greaterThanEqual(id, grid))) return;
663
664 // Exponential depth
665 float t = float(id.z) / float(grid.z);
666 float depth = u_near * pow(u_far / u_near, t);
667
668 // NDC to world
669 vec2 ndc = (vec2(id.xy) + 0.5) / vec2(grid.xy) * 2.0 - 1.0;
670 float ndc_z = depth / u_far * 2.0 - 1.0;
671 vec4 world4 = u_inv_view_proj * vec4(ndc, ndc_z, 1.0);
672 vec3 world_pos = world4.xyz / world4.w;
673
674 // Density
675 float density = u_global_density;
676
677 // Height fog
678 float height = world_pos.y - u_height_base;
679 density += u_height_density * exp(-max(height, 0.0) * u_height_falloff);
680
681 // Noise
682 vec3 np = world_pos * 0.3 + vec3(u_time * 0.5, u_time * 0.05, u_time * 0.2);
683 density *= max(0.0, 1.0 + (fbm(np) - 0.5) * 1.0);
684
685 imageStore(u_fog_volume, id, vec4(density, 0.0, 0.0, 0.0));
686}
687 "#
688 }
689
690 pub fn glsl_apply_fragment() -> &'static str {
692 r#"
693// Apply volumetric fog to a scene pixel.
694// Call in the composite/post-process pass.
695vec3 apply_fog(vec3 scene_color, sampler3D fog_inscatter, sampler3D fog_transmittance,
696 vec2 screen_uv, float pixel_depth, float near, float far) {
697 // Map depth to exponential slice coordinate
698 float t = log(pixel_depth / near) / log(far / near);
699 t = clamp(t, 0.0, 1.0);
700
701 vec3 inscatter = texture(fog_inscatter, vec3(screen_uv, t)).rgb;
702 float transmittance = texture(fog_transmittance, vec3(screen_uv, t)).r;
703
704 return scene_color * transmittance + inscatter;
705}
706 "#
707 }
708}
709
710fn henyey_greenstein(cos_theta: f32, g: f32) -> f32 {
716 let g2 = g * g;
717 let denom = 1.0 + g2 - 2.0 * g * cos_theta;
718 if denom < 1e-7 { return 1.0 / (4.0 * PI); }
719 (1.0 - g2) / (4.0 * PI * denom * denom.sqrt())
720}
721
722fn combined_phase(cos_theta: f32, g: f32, rayleigh_weight: f32) -> f32 {
724 let mie = henyey_greenstein(cos_theta, g);
725 let rayleigh = 3.0 / (16.0 * PI) * (1.0 + cos_theta * cos_theta);
726 rayleigh * rayleigh_weight + mie * (1.0 - rayleigh_weight)
727}
728
729fn value_noise_3d(x: f32, y: f32, z: f32) -> f32 {
734 let ix = x.floor() as i32;
735 let iy = y.floor() as i32;
736 let iz = z.floor() as i32;
737 let fx = x - x.floor();
738 let fy = y - y.floor();
739 let fz = z - z.floor();
740 let tx = fx * fx * (3.0 - 2.0 * fx);
741 let ty = fy * fy * (3.0 - 2.0 * fy);
742 let tz = fz * fz * (3.0 - 2.0 * fz);
743
744 let h = |i: i32, j: i32, k: i32| -> f32 {
745 let n = i.wrapping_mul(374761393).wrapping_add(j.wrapping_mul(668265263)).wrapping_add(k.wrapping_mul(1274126177)) as u32;
746 let n = n ^ (n >> 13);
747 let n = n.wrapping_mul(0x5851F42D);
748 (n & 0x00FF_FFFF) as f32 / 0x0080_0000 as f32 - 1.0
749 };
750
751 let v000 = h(ix, iy, iz); let v100 = h(ix+1, iy, iz);
752 let v010 = h(ix, iy+1, iz); let v110 = h(ix+1, iy+1, iz);
753 let v001 = h(ix, iy, iz+1); let v101 = h(ix+1, iy, iz+1);
754 let v011 = h(ix, iy+1, iz+1); let v111 = h(ix+1, iy+1, iz+1);
755
756 let a = v000 + tx*(v100-v000); let b = v010 + tx*(v110-v010);
757 let c = v001 + tx*(v101-v001); let d = v011 + tx*(v111-v011);
758 let e = a + ty*(b-a); let f = c + ty*(d-c);
759 e + tz*(f-e)
760}
761
762fn fbm_3d(x: f32, y: f32, z: f32, octaves: u32) -> f32 {
763 let mut value = 0.0f32;
764 let mut amplitude = 0.5f32;
765 let mut freq = 1.0f32;
766 for _ in 0..octaves {
767 value += amplitude * value_noise_3d(x * freq, y * freq, z * freq);
768 freq *= 2.0;
769 amplitude *= 0.5;
770 }
771 value
772}
773
774pub struct FogPresets;
780
781impl FogPresets {
782 pub fn combat() -> VolumetricFogConfig {
784 VolumetricFogConfig {
785 grid_size: (80, 45, 64),
786 global_density: 0.003,
787 height_fog_density: 0.01,
788 far: 30.0,
789 ..Default::default()
790 }
791 }
792
793 pub fn boss_arena() -> VolumetricFogConfig {
795 VolumetricFogConfig {
796 grid_size: (120, 68, 96),
797 global_density: 0.008,
798 height_fog_density: 0.03,
799 anisotropy: 0.5,
800 far: 50.0,
801 ambient_light: Vec3::new(0.03, 0.02, 0.04),
802 ..Default::default()
803 }
804 }
805
806 pub fn shrine() -> VolumetricFogConfig {
808 VolumetricFogConfig {
809 grid_size: (80, 45, 64),
810 global_density: 0.002,
811 height_fog_density: 0.005,
812 far: 40.0,
813 ambient_light: Vec3::new(0.04, 0.035, 0.02),
814 noise: NoiseConfig { amplitude: 0.3, frequency: 0.2, ..Default::default() },
815 ..Default::default()
816 }
817 }
818
819 pub fn void() -> VolumetricFogConfig {
821 VolumetricFogConfig {
822 grid_size: (80, 45, 64),
823 global_density: 0.02,
824 height_fog_density: 0.05,
825 far: 20.0,
826 albedo: Vec3::new(0.6, 0.5, 0.7),
827 ambient_light: Vec3::new(0.01, 0.005, 0.02),
828 noise: NoiseConfig { amplitude: 0.8, frequency: 0.5, ..Default::default() },
829 ..Default::default()
830 }
831 }
832
833 pub fn corruption(level: f32) -> VolumetricFogConfig {
835 let level = level.clamp(0.0, 1.0);
836 VolumetricFogConfig {
837 grid_size: (80, 45, 64),
838 global_density: 0.003 + level * 0.02,
839 height_fog_density: 0.01 + level * 0.04,
840 far: 30.0 - level * 15.0,
841 albedo: Vec3::new(0.7 - level * 0.3, 0.8 - level * 0.5, 0.9 - level * 0.3),
842 ambient_light: Vec3::new(0.02, 0.015 - level * 0.01, 0.03 - level * 0.02),
843 anisotropy: 0.3 + level * 0.3,
844 noise: NoiseConfig {
845 amplitude: 0.5 + level * 0.5,
846 frequency: 0.3 + level * 0.2,
847 ..Default::default()
848 },
849 ..Default::default()
850 }
851 }
852}
853
854#[cfg(test)]
859mod tests {
860 use super::*;
861
862 #[test]
863 fn test_exponential_depth() {
864 let config = VolumetricFogConfig { near: 0.5, far: 100.0, grid_size: (4, 4, 64), ..Default::default() };
865 let fog = VolumetricFogPipeline::new(config);
866 let d0 = fog.slice_depth(0);
867 let d_mid = fog.slice_depth(32);
868 let d_end = fog.slice_depth(63);
869 assert!((d0 - 0.5).abs() < 0.01, "first slice should be near plane");
870 assert!(d_mid < 50.0, "midpoint should be less than half far (exponential)");
871 assert!(d_end < 100.0, "last slice should be near far plane");
872 }
873
874 #[test]
875 fn test_depth_roundtrip() {
876 let config = VolumetricFogConfig { near: 0.5, far: 100.0, grid_size: (4, 4, 64), ..Default::default() };
877 let fog = VolumetricFogPipeline::new(config);
878 let depth = 10.0;
879 let slice = fog.depth_to_slice(depth);
880 let recovered = fog.slice_depth(slice);
881 assert!((recovered - depth).abs() < 2.0, "roundtrip should be close");
882 }
883
884 #[test]
885 fn test_henyey_greenstein_normalization() {
886 let g = 0.3;
888 let steps = 1000;
889 let mut integral = 0.0f32;
890 for i in 0..steps {
891 let cos_theta = -1.0 + 2.0 * i as f32 / steps as f32;
892 integral += henyey_greenstein(cos_theta, g) * 2.0 * PI * (2.0 / steps as f32);
893 }
894 assert!((integral - 1.0).abs() < 0.1, "HG should integrate to ~1, got {}", integral);
895 }
896
897 #[test]
898 fn test_fog_pipeline_runs() {
899 let config = VolumetricFogConfig { grid_size: (4, 4, 4), ..Default::default() };
900 let mut fog = VolumetricFogPipeline::new(config);
901 let inv_vp = Mat4::IDENTITY;
902 let lights = vec![FogLight::Directional {
903 direction: Vec3::new(0.0, -1.0, 0.0), color: Vec3::ONE, intensity: 1.0,
904 }];
905 fog.update(0.016, &inv_vp, Vec3::ZERO, &lights, &[]);
906 let result = fog.sample_pixel(0.5, 0.5);
907 assert!(result.transmittance <= 1.0 && result.transmittance >= 0.0);
908 }
909
910 #[test]
911 fn test_field_injection() {
912 let config = VolumetricFogConfig { grid_size: (4, 4, 4), ..Default::default() };
913 let mut fog = VolumetricFogPipeline::new(config);
914 let source = FogFieldSource {
915 position: Vec3::ZERO, radius: 10.0, density: 1.0,
916 color_tint: Vec3::ONE, field_type: FogFieldType::Attractor,
917 };
918 fog.inject_density(0.016, &Mat4::IDENTITY, Vec3::ZERO, &[source]);
919 let has_density = fog.grid.iter().any(|f| f.extinction > 0.0);
921 assert!(has_density, "field injection should add density");
922 }
923
924 #[test]
925 fn test_corruption_preset_scales() {
926 let low = FogPresets::corruption(0.0);
927 let high = FogPresets::corruption(1.0);
928 assert!(high.global_density > low.global_density);
929 assert!(high.far < low.far); }
931
932 #[test]
933 fn test_fbm_range() {
934 for i in 0..50 {
935 let v = fbm_3d(i as f32 * 0.3, 0.5, 1.2, 3);
936 assert!(v > -2.0 && v < 2.0, "fbm out of expected range: {}", v);
937 }
938 }
939}