Skip to main content

proof_engine/lighting/
ambient.rs

1//! Ambient and indirect lighting for Proof Engine.
2//!
3//! Provides screen-space ambient occlusion (SSAO), spherical harmonics for diffuse
4//! irradiance, light probe grids with trilinear interpolation, reflection probes with
5//! parallax correction, ambient cubes, and hemisphere lighting.
6
7use super::lights::{Vec3, Color, Mat4};
8use std::f32::consts::PI;
9
10// ── SSAO Configuration ─────────────────────────────────────────────────────
11
12/// Configuration for screen-space ambient occlusion.
13#[derive(Debug, Clone)]
14pub struct SsaoConfig {
15    /// Number of hemisphere samples per pixel.
16    pub sample_count: u32,
17    /// Radius of the sampling hemisphere in world units.
18    pub radius: f32,
19    /// Bias to prevent self-occlusion on flat surfaces.
20    pub bias: f32,
21    /// Power exponent to increase contrast.
22    pub power: f32,
23    /// Intensity multiplier.
24    pub intensity: f32,
25    /// Whether to apply bilateral blur to the AO result.
26    pub blur: bool,
27    /// Blur kernel radius (in pixels).
28    pub blur_radius: u32,
29    /// Blur sharpness (higher = less blur across edges).
30    pub blur_sharpness: f32,
31    /// Noise texture size for rotating the hemisphere kernel.
32    pub noise_size: u32,
33}
34
35impl Default for SsaoConfig {
36    fn default() -> Self {
37        Self {
38            sample_count: 32,
39            radius: 0.5,
40            bias: 0.025,
41            power: 2.0,
42            intensity: 1.0,
43            blur: true,
44            blur_radius: 4,
45            blur_sharpness: 8.0,
46            noise_size: 4,
47        }
48    }
49}
50
51// ── SSAO Kernel ─────────────────────────────────────────────────────────────
52
53/// Generates and stores the SSAO sampling kernel and noise rotation vectors.
54#[derive(Debug, Clone)]
55pub struct SsaoKernel {
56    /// Sample positions in tangent space (hemisphere).
57    pub samples: Vec<Vec3>,
58    /// Noise rotation vectors for randomizing the kernel per pixel.
59    pub noise: Vec<Vec3>,
60    /// Configuration used to generate this kernel.
61    pub config: SsaoConfig,
62}
63
64impl SsaoKernel {
65    /// Generate a new SSAO kernel from configuration.
66    pub fn new(config: SsaoConfig) -> Self {
67        let samples = Self::generate_samples(config.sample_count, config.radius);
68        let noise = Self::generate_noise(config.noise_size);
69        Self { samples, noise, config }
70    }
71
72    /// Generate hemisphere sample points using a quasi-random distribution.
73    fn generate_samples(count: u32, radius: f32) -> Vec<Vec3> {
74        let mut samples = Vec::with_capacity(count as usize);
75
76        for i in 0..count {
77            // Use a low-discrepancy sequence for better distribution
78            let xi1 = Self::radical_inverse_vdc(i);
79            let xi2 = Self::halton_sequence(i, 3);
80
81            // Map to hemisphere (cosine-weighted)
82            let phi = 2.0 * PI * xi1;
83            let cos_theta = (1.0 - xi2).sqrt();
84            let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
85
86            let x = sin_theta * phi.cos();
87            let y = sin_theta * phi.sin();
88            let z = cos_theta;
89
90            // Scale samples so they cluster near the origin (more detail close up)
91            let scale = (i as f32 + 1.0) / count as f32;
92            let scale = Self::lerp_f32(0.1, 1.0, scale * scale);
93
94            samples.push(Vec3::new(x * scale * radius, y * scale * radius, z * scale * radius));
95        }
96
97        samples
98    }
99
100    /// Generate noise rotation vectors for the noise texture.
101    fn generate_noise(size: u32) -> Vec<Vec3> {
102        let count = (size * size) as usize;
103        let mut noise = Vec::with_capacity(count);
104
105        for i in 0..count {
106            // Deterministic pseudo-random rotation vectors in tangent plane (z=0)
107            let seed = i as f32 * 7.31 + 0.5;
108            let x = (seed * 12.9898 + 78.233).sin() * 43758.5453;
109            let y = (seed * 39.346 + 11.135).sin() * 28461.7231;
110            let nx = x.fract() * 2.0 - 1.0;
111            let ny = y.fract() * 2.0 - 1.0;
112            let len = (nx * nx + ny * ny).sqrt().max(0.001);
113            noise.push(Vec3::new(nx / len, ny / len, 0.0));
114        }
115
116        noise
117    }
118
119    /// Van der Corput radical inverse for low-discrepancy sequences.
120    fn radical_inverse_vdc(mut bits: u32) -> f32 {
121        bits = (bits << 16) | (bits >> 16);
122        bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
123        bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2);
124        bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4);
125        bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8);
126        bits as f32 * 2.3283064365386963e-10
127    }
128
129    /// Halton sequence for the given base.
130    fn halton_sequence(index: u32, base: u32) -> f32 {
131        let mut f = 1.0f32;
132        let mut r = 0.0f32;
133        let mut i = index;
134        while i > 0 {
135            f /= base as f32;
136            r += f * (i % base) as f32;
137            i /= base;
138        }
139        r
140    }
141
142    fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
143        a + (b - a) * t
144    }
145
146    /// Get the noise vector for a given screen pixel.
147    pub fn noise_at(&self, x: u32, y: u32) -> Vec3 {
148        let size = self.config.noise_size;
149        if size == 0 || self.noise.is_empty() {
150            return Vec3::new(1.0, 0.0, 0.0);
151        }
152        let idx = ((y % size) * size + (x % size)) as usize;
153        self.noise[idx % self.noise.len()]
154    }
155}
156
157// ── SSAO Result ─────────────────────────────────────────────────────────────
158
159/// The computed SSAO buffer.
160#[derive(Debug, Clone)]
161pub struct SsaoResult {
162    pub width: u32,
163    pub height: u32,
164    /// AO values per pixel (0.0 = fully occluded, 1.0 = fully open).
165    pub ao_buffer: Vec<f32>,
166}
167
168impl SsaoResult {
169    pub fn new(width: u32, height: u32) -> Self {
170        let size = (width as usize) * (height as usize);
171        Self {
172            width,
173            height,
174            ao_buffer: vec![1.0; size],
175        }
176    }
177
178    /// Compute SSAO from a depth buffer and normal buffer.
179    pub fn compute(
180        &mut self,
181        depth_buffer: &[f32],
182        normal_buffer: &[Vec3],
183        kernel: &SsaoKernel,
184        projection: &Mat4,
185    ) {
186        let w = self.width as usize;
187        let h = self.height as usize;
188        let config = &kernel.config;
189
190        for y in 0..h {
191            for x in 0..w {
192                let idx = y * w + x;
193                let depth = depth_buffer[idx];
194                if depth >= 1.0 {
195                    self.ao_buffer[idx] = 1.0;
196                    continue;
197                }
198
199                let normal = normal_buffer[idx];
200                let noise = kernel.noise_at(x as u32, y as u32);
201
202                // Reconstruct view-space position from depth
203                let ndc_x = (x as f32 / w as f32) * 2.0 - 1.0;
204                let ndc_y = (y as f32 / h as f32) * 2.0 - 1.0;
205                let frag_pos = Vec3::new(ndc_x * depth, ndc_y * depth, -depth);
206
207                // Create TBN matrix from normal and noise
208                let tangent = Self::gramm_schmidt(noise, normal);
209                let bitangent = normal.cross(tangent);
210
211                let mut occlusion = 0.0f32;
212                for sample in &kernel.samples {
213                    // Transform sample to view space
214                    let rotated = Vec3::new(
215                        tangent.x * sample.x + bitangent.x * sample.y + normal.x * sample.z,
216                        tangent.y * sample.x + bitangent.y * sample.y + normal.y * sample.z,
217                        tangent.z * sample.x + bitangent.z * sample.y + normal.z * sample.z,
218                    );
219
220                    let sample_pos = frag_pos + rotated * config.radius;
221
222                    // Project sample to screen space
223                    let clip = projection.transform_point(sample_pos);
224                    let screen_x = ((clip.x * 0.5 + 0.5) * w as f32) as usize;
225                    let screen_y = ((clip.y * 0.5 + 0.5) * h as f32) as usize;
226
227                    if screen_x < w && screen_y < h {
228                        let sample_depth = depth_buffer[screen_y * w + screen_x];
229                        let range_check = Self::smooth_step(
230                            0.0,
231                            1.0,
232                            config.radius / (frag_pos.z - sample_depth).abs().max(0.001),
233                        );
234
235                        if sample_depth >= sample_pos.z + config.bias {
236                            occlusion += range_check;
237                        }
238                    }
239                }
240
241                occlusion /= kernel.samples.len() as f32;
242                let ao = (1.0 - occlusion * config.intensity).max(0.0).powf(config.power);
243                self.ao_buffer[idx] = ao;
244            }
245        }
246
247        if config.blur {
248            self.bilateral_blur(depth_buffer, config.blur_radius, config.blur_sharpness);
249        }
250    }
251
252    /// Apply bilateral blur to the AO buffer (preserves edges based on depth).
253    pub fn bilateral_blur(&mut self, depth_buffer: &[f32], radius: u32, sharpness: f32) {
254        let w = self.width as usize;
255        let h = self.height as usize;
256        let mut temp = vec![0.0f32; w * h];
257
258        // Horizontal pass
259        for y in 0..h {
260            for x in 0..w {
261                let center_depth = depth_buffer[y * w + x];
262                let center_ao = self.ao_buffer[y * w + x];
263                let mut sum = 0.0f32;
264                let mut weight_sum = 0.0f32;
265
266                let x_start = x.saturating_sub(radius as usize);
267                let x_end = (x + radius as usize + 1).min(w);
268
269                for sx in x_start..x_end {
270                    let sample_depth = depth_buffer[y * w + sx];
271                    let sample_ao = self.ao_buffer[y * w + sx];
272
273                    let depth_diff = (center_depth - sample_depth).abs();
274                    let weight = (-depth_diff * sharpness).exp();
275
276                    sum += sample_ao * weight;
277                    weight_sum += weight;
278                }
279
280                temp[y * w + x] = if weight_sum > 0.0 {
281                    sum / weight_sum
282                } else {
283                    center_ao
284                };
285            }
286        }
287
288        // Vertical pass
289        for y in 0..h {
290            for x in 0..w {
291                let center_depth = depth_buffer[y * w + x];
292                let mut sum = 0.0f32;
293                let mut weight_sum = 0.0f32;
294
295                let y_start = y.saturating_sub(radius as usize);
296                let y_end = (y + radius as usize + 1).min(h);
297
298                for sy in y_start..y_end {
299                    let sample_depth = depth_buffer[sy * w + x];
300                    let sample_ao = temp[sy * w + x];
301
302                    let depth_diff = (center_depth - sample_depth).abs();
303                    let weight = (-depth_diff * sharpness).exp();
304
305                    sum += sample_ao * weight;
306                    weight_sum += weight;
307                }
308
309                self.ao_buffer[y * w + x] = if weight_sum > 0.0 {
310                    sum / weight_sum
311                } else {
312                    temp[y * w + x]
313                };
314            }
315        }
316    }
317
318    /// Read AO at a pixel coordinate.
319    pub fn ao_at(&self, x: u32, y: u32) -> f32 {
320        if x < self.width && y < self.height {
321            self.ao_buffer[(y as usize) * (self.width as usize) + (x as usize)]
322        } else {
323            1.0
324        }
325    }
326
327    /// Gram-Schmidt orthogonalization.
328    fn gramm_schmidt(v: Vec3, n: Vec3) -> Vec3 {
329        let proj = n * v.dot(n);
330        let result = v - proj;
331        let len = result.length();
332        if len < 1e-6 {
333            // Fallback if v is parallel to n
334            if n.x.abs() < 0.9 {
335                Vec3::new(1.0, 0.0, 0.0)
336            } else {
337                Vec3::new(0.0, 1.0, 0.0)
338            }
339        } else {
340            result * (1.0 / len)
341        }
342    }
343
344    fn smooth_step(edge0: f32, edge1: f32, x: f32) -> f32 {
345        let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
346        t * t * (3.0 - 2.0 * t)
347    }
348
349    /// Average AO over the entire buffer (useful for debugging).
350    pub fn average_ao(&self) -> f32 {
351        if self.ao_buffer.is_empty() {
352            return 1.0;
353        }
354        let sum: f32 = self.ao_buffer.iter().sum();
355        sum / self.ao_buffer.len() as f32
356    }
357}
358
359// ── Spherical Harmonics (Order 2, 9 coefficients) ──────────────────────────
360
361/// Second-order (L=2) spherical harmonics with 9 coefficients per color channel.
362/// Used for encoding low-frequency irradiance from environment lighting.
363#[derive(Debug, Clone)]
364pub struct SphericalHarmonics9 {
365    /// 9 RGB coefficients: `coefficients[i]` is the i-th SH coefficient as a color.
366    pub coefficients: [Vec3; 9],
367}
368
369impl Default for SphericalHarmonics9 {
370    fn default() -> Self {
371        Self {
372            coefficients: [Vec3::ZERO; 9],
373        }
374    }
375}
376
377impl SphericalHarmonics9 {
378    pub fn new() -> Self {
379        Self::default()
380    }
381
382    /// SH basis functions evaluated at a direction.
383    pub fn basis(dir: Vec3) -> [f32; 9] {
384        let (x, y, z) = (dir.x, dir.y, dir.z);
385        [
386            // L=0
387            0.282094792,                          // Y00
388            // L=1
389            0.488602512 * y,                      // Y1-1
390            0.488602512 * z,                      // Y10
391            0.488602512 * x,                      // Y11
392            // L=2
393            1.092548431 * x * y,                  // Y2-2
394            1.092548431 * y * z,                  // Y2-1
395            0.315391565 * (3.0 * z * z - 1.0),   // Y20
396            1.092548431 * x * z,                  // Y21
397            0.546274215 * (x * x - y * y),        // Y22
398        ]
399    }
400
401    /// Add a directional sample (radiance * solid_angle) from the given direction.
402    pub fn add_sample(&mut self, direction: Vec3, radiance: Vec3, weight: f32) {
403        let basis = Self::basis(direction.normalize());
404        for i in 0..9 {
405            self.coefficients[i] = self.coefficients[i] + radiance * (basis[i] * weight);
406        }
407    }
408
409    /// Evaluate the irradiance for a given surface normal.
410    pub fn evaluate(&self, normal: Vec3) -> Vec3 {
411        let basis = Self::basis(normal.normalize());
412        let mut result = Vec3::ZERO;
413        for i in 0..9 {
414            result = result + self.coefficients[i] * basis[i];
415        }
416        // Clamp to non-negative
417        Vec3::new(result.x.max(0.0), result.y.max(0.0), result.z.max(0.0))
418    }
419
420    /// Evaluate as a Color.
421    pub fn evaluate_color(&self, normal: Vec3) -> Color {
422        let v = self.evaluate(normal);
423        Color::new(v.x, v.y, v.z)
424    }
425
426    /// Create SH from a constant ambient color (uniform environment).
427    pub fn from_ambient(color: Color) -> Self {
428        let mut sh = Self::new();
429        // For a constant environment, only the L=0 coefficient is non-zero
430        let scale = (4.0 * PI).sqrt();
431        sh.coefficients[0] = Vec3::new(color.r * scale, color.g * scale, color.b * scale);
432        sh
433    }
434
435    /// Create SH from a simple sky/ground gradient.
436    pub fn from_sky_ground(sky_color: Color, ground_color: Color) -> Self {
437        let mut sh = Self::new();
438
439        // Sample hemisphere directions
440        let sample_count = 256;
441        let weight = 4.0 * PI / sample_count as f32;
442
443        for i in 0..sample_count {
444            let xi1 = SsaoKernel::radical_inverse_vdc(i);
445            let xi2 = SsaoKernel::halton_sequence(i, 3);
446
447            let phi = 2.0 * PI * xi1;
448            let cos_theta = 2.0 * xi2 - 1.0;
449            let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
450
451            let dir = Vec3::new(
452                sin_theta * phi.cos(),
453                cos_theta,
454                sin_theta * phi.sin(),
455            );
456
457            // Blend between sky (up) and ground (down) based on Y
458            let t = dir.y * 0.5 + 0.5;
459            let color = ground_color.lerp(sky_color, t);
460            let radiance = Vec3::new(color.r, color.g, color.b);
461
462            sh.add_sample(dir, radiance, weight);
463        }
464
465        sh
466    }
467
468    /// Linearly interpolate between two SH environments.
469    pub fn lerp(&self, other: &Self, t: f32) -> Self {
470        let mut result = Self::new();
471        for i in 0..9 {
472            result.coefficients[i] = self.coefficients[i].lerp(other.coefficients[i], t);
473        }
474        result
475    }
476
477    /// Add two SH environments together.
478    pub fn add(&self, other: &Self) -> Self {
479        let mut result = Self::new();
480        for i in 0..9 {
481            result.coefficients[i] = self.coefficients[i] + other.coefficients[i];
482        }
483        result
484    }
485
486    /// Scale all coefficients by a factor.
487    pub fn scale(&self, factor: f32) -> Self {
488        let mut result = Self::new();
489        for i in 0..9 {
490            result.coefficients[i] = self.coefficients[i] * factor;
491        }
492        result
493    }
494
495    /// Compute the dominant direction of the SH (direction of maximum intensity).
496    pub fn dominant_direction(&self) -> Vec3 {
497        // The L=1 band encodes the dominant direction
498        let x = self.coefficients[3].length();
499        let y = self.coefficients[1].length();
500        let z = self.coefficients[2].length();
501        Vec3::new(x, y, z).normalize()
502    }
503}
504
505// ── Light Probe ─────────────────────────────────────────────────────────────
506
507/// A single light probe storing SH coefficients at a world position.
508#[derive(Debug, Clone)]
509pub struct LightProbe {
510    pub position: Vec3,
511    pub sh: SphericalHarmonics9,
512    pub valid: bool,
513    /// Influence radius.
514    pub radius: f32,
515}
516
517impl LightProbe {
518    pub fn new(position: Vec3) -> Self {
519        Self {
520            position,
521            sh: SphericalHarmonics9::new(),
522            valid: false,
523            radius: 10.0,
524        }
525    }
526
527    pub fn with_sh(mut self, sh: SphericalHarmonics9) -> Self {
528        self.sh = sh;
529        self.valid = true;
530        self
531    }
532
533    pub fn with_radius(mut self, radius: f32) -> Self {
534        self.radius = radius;
535        self
536    }
537
538    /// Evaluate irradiance at this probe for the given normal.
539    pub fn irradiance(&self, normal: Vec3) -> Color {
540        if !self.valid {
541            return Color::BLACK;
542        }
543        self.sh.evaluate_color(normal)
544    }
545
546    /// Get the weight of this probe at a given world position (based on distance).
547    pub fn weight_at(&self, point: Vec3) -> f32 {
548        let dist = self.position.distance(point);
549        if dist >= self.radius {
550            return 0.0;
551        }
552        let t = dist / self.radius;
553        (1.0 - t * t * t).max(0.0)
554    }
555}
556
557// ── Light Probe Grid ────────────────────────────────────────────────────────
558
559/// A 3D grid of SH light probes with trilinear interpolation.
560#[derive(Debug, Clone)]
561pub struct LightProbeGrid {
562    /// Grid origin (minimum corner).
563    pub origin: Vec3,
564    /// Grid cell size.
565    pub cell_size: Vec3,
566    /// Number of probes along each axis.
567    pub count_x: u32,
568    pub count_y: u32,
569    pub count_z: u32,
570    /// Probes stored in a flat array: index = z * (count_x * count_y) + y * count_x + x.
571    pub probes: Vec<LightProbe>,
572}
573
574impl LightProbeGrid {
575    /// Create a new grid of probes.
576    pub fn new(origin: Vec3, cell_size: Vec3, count_x: u32, count_y: u32, count_z: u32) -> Self {
577        let total = (count_x as usize) * (count_y as usize) * (count_z as usize);
578        let mut probes = Vec::with_capacity(total);
579
580        for z in 0..count_z {
581            for y in 0..count_y {
582                for x in 0..count_x {
583                    let pos = Vec3::new(
584                        origin.x + x as f32 * cell_size.x,
585                        origin.y + y as f32 * cell_size.y,
586                        origin.z + z as f32 * cell_size.z,
587                    );
588                    probes.push(LightProbe::new(pos));
589                }
590            }
591        }
592
593        Self {
594            origin,
595            cell_size,
596            count_x,
597            count_y,
598            count_z,
599            probes,
600        }
601    }
602
603    /// Get the probe index for grid coordinates.
604    fn probe_index(&self, x: u32, y: u32, z: u32) -> usize {
605        (z as usize) * (self.count_x as usize * self.count_y as usize)
606            + (y as usize) * (self.count_x as usize)
607            + (x as usize)
608    }
609
610    /// Get a reference to the probe at grid coordinates.
611    pub fn probe_at(&self, x: u32, y: u32, z: u32) -> Option<&LightProbe> {
612        if x < self.count_x && y < self.count_y && z < self.count_z {
613            Some(&self.probes[self.probe_index(x, y, z)])
614        } else {
615            None
616        }
617    }
618
619    /// Get a mutable reference to the probe at grid coordinates.
620    pub fn probe_at_mut(&mut self, x: u32, y: u32, z: u32) -> Option<&mut LightProbe> {
621        if x < self.count_x && y < self.count_y && z < self.count_z {
622            let idx = self.probe_index(x, y, z);
623            Some(&mut self.probes[idx])
624        } else {
625            None
626        }
627    }
628
629    /// Convert world position to continuous grid coordinates.
630    fn world_to_grid(&self, pos: Vec3) -> (f32, f32, f32) {
631        let local = pos - self.origin;
632        (
633            local.x / self.cell_size.x,
634            local.y / self.cell_size.y,
635            local.z / self.cell_size.z,
636        )
637    }
638
639    /// Sample irradiance at a world position using trilinear interpolation.
640    pub fn sample_irradiance(&self, point: Vec3, normal: Vec3) -> Color {
641        let (gx, gy, gz) = self.world_to_grid(point);
642
643        // Clamp to grid bounds
644        let max_x = (self.count_x - 1).max(0) as f32;
645        let max_y = (self.count_y - 1).max(0) as f32;
646        let max_z = (self.count_z - 1).max(0) as f32;
647
648        let gx = gx.clamp(0.0, max_x);
649        let gy = gy.clamp(0.0, max_y);
650        let gz = gz.clamp(0.0, max_z);
651
652        let x0 = gx.floor() as u32;
653        let y0 = gy.floor() as u32;
654        let z0 = gz.floor() as u32;
655        let x1 = (x0 + 1).min(self.count_x - 1);
656        let y1 = (y0 + 1).min(self.count_y - 1);
657        let z1 = (z0 + 1).min(self.count_z - 1);
658
659        let fx = gx.fract();
660        let fy = gy.fract();
661        let fz = gz.fract();
662
663        // Trilinear interpolation of SH, then evaluate
664        let get_sh = |x: u32, y: u32, z: u32| -> &SphericalHarmonics9 {
665            &self.probes[self.probe_index(x, y, z)].sh
666        };
667
668        let sh000 = get_sh(x0, y0, z0);
669        let sh100 = get_sh(x1, y0, z0);
670        let sh010 = get_sh(x0, y1, z0);
671        let sh110 = get_sh(x1, y1, z0);
672        let sh001 = get_sh(x0, y0, z1);
673        let sh101 = get_sh(x1, y0, z1);
674        let sh011 = get_sh(x0, y1, z1);
675        let sh111 = get_sh(x1, y1, z1);
676
677        // Interpolate along X
678        let sh_x00 = sh000.lerp(sh100, fx);
679        let sh_x10 = sh010.lerp(sh110, fx);
680        let sh_x01 = sh001.lerp(sh101, fx);
681        let sh_x11 = sh011.lerp(sh111, fx);
682
683        // Interpolate along Y
684        let sh_xy0 = sh_x00.lerp(&sh_x10, fy);
685        let sh_xy1 = sh_x01.lerp(&sh_x11, fy);
686
687        // Interpolate along Z
688        let sh_final = sh_xy0.lerp(&sh_xy1, fz);
689
690        sh_final.evaluate_color(normal)
691    }
692
693    /// Get the bounding box of the grid in world space.
694    pub fn bounds(&self) -> (Vec3, Vec3) {
695        let max = Vec3::new(
696            self.origin.x + (self.count_x - 1) as f32 * self.cell_size.x,
697            self.origin.y + (self.count_y - 1) as f32 * self.cell_size.y,
698            self.origin.z + (self.count_z - 1) as f32 * self.cell_size.z,
699        );
700        (self.origin, max)
701    }
702
703    /// Check if a world position is inside the grid.
704    pub fn contains(&self, point: Vec3) -> bool {
705        let (min, max) = self.bounds();
706        point.x >= min.x && point.x <= max.x
707            && point.y >= min.y && point.y <= max.y
708            && point.z >= min.z && point.z <= max.z
709    }
710
711    /// Total number of probes.
712    pub fn probe_count(&self) -> usize {
713        self.probes.len()
714    }
715
716    /// Mark all probes as valid with a uniform ambient color.
717    pub fn fill_uniform(&mut self, color: Color) {
718        let sh = SphericalHarmonics9::from_ambient(color);
719        for probe in &mut self.probes {
720            probe.sh = sh.clone();
721            probe.valid = true;
722        }
723    }
724
725    /// Mark all probes as valid with a sky/ground gradient.
726    pub fn fill_sky_ground(&mut self, sky: Color, ground: Color) {
727        let sh = SphericalHarmonics9::from_sky_ground(sky, ground);
728        for probe in &mut self.probes {
729            probe.sh = sh.clone();
730            probe.valid = true;
731        }
732    }
733}
734
735// ── Reflection Probe ────────────────────────────────────────────────────────
736
737/// A reflection probe that captures a cubemap for specular reflections.
738/// Supports parallax correction for box-shaped influence volumes.
739#[derive(Debug, Clone)]
740pub struct ReflectionProbe {
741    pub position: Vec3,
742    /// Influence volume half-extents (box shape).
743    pub box_half_extents: Vec3,
744    /// Cubemap data per face: 6 faces, each storing a flat array of Color values.
745    pub cubemap_faces: [Vec<Color>; 6],
746    /// Resolution of each cubemap face.
747    pub resolution: u32,
748    /// Number of mip levels for roughness-based filtering.
749    pub mip_levels: u32,
750    /// Whether this probe is valid (has been baked).
751    pub valid: bool,
752    /// Blend distance from the edge of the box volume.
753    pub blend_distance: f32,
754    /// Priority (higher = preferred when overlapping).
755    pub priority: u32,
756}
757
758impl ReflectionProbe {
759    pub fn new(position: Vec3, box_half_extents: Vec3, resolution: u32) -> Self {
760        let face_size = (resolution as usize) * (resolution as usize);
761        let empty_face = || vec![Color::BLACK; face_size];
762
763        Self {
764            position,
765            box_half_extents,
766            cubemap_faces: [
767                empty_face(),
768                empty_face(),
769                empty_face(),
770                empty_face(),
771                empty_face(),
772                empty_face(),
773            ],
774            resolution,
775            mip_levels: (resolution as f32).log2().floor() as u32 + 1,
776            valid: false,
777            blend_distance: 1.0,
778            priority: 0,
779        }
780    }
781
782    /// Check if a point is inside the influence volume.
783    pub fn contains(&self, point: Vec3) -> bool {
784        let local = (point - self.position).abs();
785        local.x <= self.box_half_extents.x
786            && local.y <= self.box_half_extents.y
787            && local.z <= self.box_half_extents.z
788    }
789
790    /// Compute the blend weight for a point (1.0 at center, 0.0 at edge + blend_distance).
791    pub fn blend_weight(&self, point: Vec3) -> f32 {
792        if !self.contains(point) {
793            return 0.0;
794        }
795        let local = (point - self.position).abs();
796        let dx = ((self.box_half_extents.x - local.x) / self.blend_distance).clamp(0.0, 1.0);
797        let dy = ((self.box_half_extents.y - local.y) / self.blend_distance).clamp(0.0, 1.0);
798        let dz = ((self.box_half_extents.z - local.z) / self.blend_distance).clamp(0.0, 1.0);
799        dx.min(dy).min(dz)
800    }
801
802    /// Apply parallax correction to a reflection direction for box-projected cubemaps.
803    pub fn parallax_correct(&self, point: Vec3, reflection_dir: Vec3) -> Vec3 {
804        let local_pos = point - self.position;
805
806        // Compute the intersection with the box along the reflection direction
807        let box_min = -self.box_half_extents;
808        let box_max = self.box_half_extents;
809
810        let inv_dir = Vec3::new(
811            if reflection_dir.x.abs() > 1e-6 { 1.0 / reflection_dir.x } else { 1e10 },
812            if reflection_dir.y.abs() > 1e-6 { 1.0 / reflection_dir.y } else { 1e10 },
813            if reflection_dir.z.abs() > 1e-6 { 1.0 / reflection_dir.z } else { 1e10 },
814        );
815
816        let first_plane = Vec3::new(
817            (box_max.x - local_pos.x) * inv_dir.x,
818            (box_max.y - local_pos.y) * inv_dir.y,
819            (box_max.z - local_pos.z) * inv_dir.z,
820        );
821
822        let second_plane = Vec3::new(
823            (box_min.x - local_pos.x) * inv_dir.x,
824            (box_min.y - local_pos.y) * inv_dir.y,
825            (box_min.z - local_pos.z) * inv_dir.z,
826        );
827
828        let furthest = Vec3::new(
829            first_plane.x.max(second_plane.x),
830            first_plane.y.max(second_plane.y),
831            first_plane.z.max(second_plane.z),
832        );
833
834        let t = furthest.x.min(furthest.y).min(furthest.z);
835        let intersection = local_pos + reflection_dir * t;
836
837        intersection.normalize()
838    }
839
840    /// Sample the cubemap at a given direction and mip level.
841    pub fn sample_cubemap(&self, direction: Vec3, _mip_level: u32) -> Color {
842        if !self.valid {
843            return Color::BLACK;
844        }
845
846        let abs = direction.abs();
847        let (face_idx, u, v) = if abs.x >= abs.y && abs.x >= abs.z {
848            if direction.x > 0.0 {
849                (0, -direction.z / abs.x, direction.y / abs.x)
850            } else {
851                (1, direction.z / abs.x, direction.y / abs.x)
852            }
853        } else if abs.y >= abs.x && abs.y >= abs.z {
854            if direction.y > 0.0 {
855                (2, direction.x / abs.y, -direction.z / abs.y)
856            } else {
857                (3, direction.x / abs.y, direction.z / abs.y)
858            }
859        } else if direction.z > 0.0 {
860            (4, direction.x / abs.z, direction.y / abs.z)
861        } else {
862            (5, -direction.x / abs.z, direction.y / abs.z)
863        };
864
865        let u = u * 0.5 + 0.5;
866        let v = v * 0.5 + 0.5;
867
868        let res = self.resolution as usize;
869        let px = ((u * res as f32) as usize).min(res - 1);
870        let py = ((v * res as f32) as usize).min(res - 1);
871        let idx = py * res + px;
872
873        if idx < self.cubemap_faces[face_idx].len() {
874            self.cubemap_faces[face_idx][idx]
875        } else {
876            Color::BLACK
877        }
878    }
879
880    /// Fill all faces with a solid color (for testing).
881    pub fn fill_solid(&mut self, color: Color) {
882        for face in &mut self.cubemap_faces {
883            for pixel in face.iter_mut() {
884                *pixel = color;
885            }
886        }
887        self.valid = true;
888    }
889
890    /// Get the bounding box of the influence volume.
891    pub fn bounds(&self) -> (Vec3, Vec3) {
892        (
893            self.position - self.box_half_extents,
894            self.position + self.box_half_extents,
895        )
896    }
897}
898
899// ── Reflection Probe Manager ────────────────────────────────────────────────
900
901/// Manages multiple reflection probes and blends between them.
902#[derive(Debug, Clone)]
903pub struct ReflectionProbeManager {
904    pub probes: Vec<ReflectionProbe>,
905    /// Fallback environment cubemap for areas without probes.
906    pub fallback_color: Color,
907}
908
909impl ReflectionProbeManager {
910    pub fn new() -> Self {
911        Self {
912            probes: Vec::new(),
913            fallback_color: Color::new(0.1, 0.1, 0.15),
914        }
915    }
916
917    /// Add a reflection probe. Returns its index.
918    pub fn add(&mut self, probe: ReflectionProbe) -> usize {
919        let idx = self.probes.len();
920        self.probes.push(probe);
921        idx
922    }
923
924    /// Remove a probe by index.
925    pub fn remove(&mut self, index: usize) -> Option<ReflectionProbe> {
926        if index < self.probes.len() {
927            Some(self.probes.remove(index))
928        } else {
929            None
930        }
931    }
932
933    /// Sample the reflection color at a world position for a given reflection direction.
934    pub fn sample(&self, point: Vec3, reflection_dir: Vec3, roughness: f32) -> Color {
935        let mut total_color = Color::BLACK;
936        let mut total_weight = 0.0f32;
937
938        // Sort by priority (in a real engine this would be pre-sorted)
939        let mut sorted: Vec<(usize, f32)> = self.probes.iter().enumerate()
940            .filter_map(|(i, p)| {
941                let w = p.blend_weight(point);
942                if w > 0.0 { Some((i, w)) } else { None }
943            })
944            .collect();
945
946        sorted.sort_by(|a, b| {
947            self.probes[b.0].priority.cmp(&self.probes[a.0].priority)
948                .then(b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
949        });
950
951        // Use up to 2 probes for blending
952        for &(idx, weight) in sorted.iter().take(2) {
953            let probe = &self.probes[idx];
954            let corrected = probe.parallax_correct(point, reflection_dir);
955            let mip = (roughness * (probe.mip_levels as f32 - 1.0)) as u32;
956            let color = probe.sample_cubemap(corrected, mip);
957
958            total_color = Color::new(
959                total_color.r + color.r * weight,
960                total_color.g + color.g * weight,
961                total_color.b + color.b * weight,
962            );
963            total_weight += weight;
964        }
965
966        if total_weight > 0.0 {
967            let inv = 1.0 / total_weight;
968            Color::new(
969                total_color.r * inv,
970                total_color.g * inv,
971                total_color.b * inv,
972            )
973        } else {
974            self.fallback_color
975        }
976    }
977
978    /// Get the number of probes.
979    pub fn count(&self) -> usize {
980        self.probes.len()
981    }
982}
983
984impl Default for ReflectionProbeManager {
985    fn default() -> Self {
986        Self::new()
987    }
988}
989
990// ── Ambient Cube ────────────────────────────────────────────────────────────
991
992/// A 6-directional ambient cube encoding low-frequency lighting from the six axis directions.
993/// Simpler than SH but can capture more directional variation than a single ambient color.
994#[derive(Debug, Clone)]
995pub struct AmbientCube {
996    /// Color contribution from the +X direction.
997    pub positive_x: Color,
998    /// Color contribution from the -X direction.
999    pub negative_x: Color,
1000    /// Color contribution from the +Y direction (up).
1001    pub positive_y: Color,
1002    /// Color contribution from the -Y direction (down).
1003    pub negative_y: Color,
1004    /// Color contribution from the +Z direction.
1005    pub positive_z: Color,
1006    /// Color contribution from the -Z direction.
1007    pub negative_z: Color,
1008}
1009
1010impl Default for AmbientCube {
1011    fn default() -> Self {
1012        let gray = Color::new(0.1, 0.1, 0.1);
1013        Self {
1014            positive_x: gray,
1015            negative_x: gray,
1016            positive_y: gray,
1017            negative_y: gray,
1018            positive_z: gray,
1019            negative_z: gray,
1020        }
1021    }
1022}
1023
1024impl AmbientCube {
1025    pub fn new(px: Color, nx: Color, py: Color, ny: Color, pz: Color, nz: Color) -> Self {
1026        Self {
1027            positive_x: px,
1028            negative_x: nx,
1029            positive_y: py,
1030            negative_y: ny,
1031            positive_z: pz,
1032            negative_z: nz,
1033        }
1034    }
1035
1036    /// Create a uniform ambient cube from a single color.
1037    pub fn uniform(color: Color) -> Self {
1038        Self {
1039            positive_x: color,
1040            negative_x: color,
1041            positive_y: color,
1042            negative_y: color,
1043            positive_z: color,
1044            negative_z: color,
1045        }
1046    }
1047
1048    /// Create from sky (up) and ground (down) colors with interpolation for sides.
1049    pub fn from_sky_ground(sky: Color, ground: Color) -> Self {
1050        let mid = sky.lerp(ground, 0.5);
1051        Self {
1052            positive_x: mid,
1053            negative_x: mid,
1054            positive_y: sky,
1055            negative_y: ground,
1056            positive_z: mid,
1057            negative_z: mid,
1058        }
1059    }
1060
1061    /// Evaluate the ambient color for a given normal direction.
1062    pub fn evaluate(&self, normal: Vec3) -> Color {
1063        let n = normal.normalize();
1064
1065        // Weight each axis by max(normal component, 0)
1066        let px = n.x.max(0.0);
1067        let nx = (-n.x).max(0.0);
1068        let py = n.y.max(0.0);
1069        let ny = (-n.y).max(0.0);
1070        let pz = n.z.max(0.0);
1071        let nz = (-n.z).max(0.0);
1072
1073        Color::new(
1074            self.positive_x.r * px + self.negative_x.r * nx
1075                + self.positive_y.r * py + self.negative_y.r * ny
1076                + self.positive_z.r * pz + self.negative_z.r * nz,
1077            self.positive_x.g * px + self.negative_x.g * nx
1078                + self.positive_y.g * py + self.negative_y.g * ny
1079                + self.positive_z.g * pz + self.negative_z.g * nz,
1080            self.positive_x.b * px + self.negative_x.b * nx
1081                + self.positive_y.b * py + self.negative_y.b * ny
1082                + self.positive_z.b * pz + self.negative_z.b * nz,
1083        )
1084    }
1085
1086    /// Linearly interpolate between two ambient cubes.
1087    pub fn lerp(&self, other: &Self, t: f32) -> Self {
1088        Self {
1089            positive_x: self.positive_x.lerp(other.positive_x, t),
1090            negative_x: self.negative_x.lerp(other.negative_x, t),
1091            positive_y: self.positive_y.lerp(other.positive_y, t),
1092            negative_y: self.negative_y.lerp(other.negative_y, t),
1093            positive_z: self.positive_z.lerp(other.positive_z, t),
1094            negative_z: self.negative_z.lerp(other.negative_z, t),
1095        }
1096    }
1097
1098    /// Convert to spherical harmonics (L=1 approximation).
1099    pub fn to_sh(&self) -> SphericalHarmonics9 {
1100        let mut sh = SphericalHarmonics9::new();
1101
1102        // Sample 6 directions and add to SH
1103        let weight = 4.0 * PI / 6.0;
1104        let dirs_colors = [
1105            (Vec3::new(1.0, 0.0, 0.0), self.positive_x),
1106            (Vec3::new(-1.0, 0.0, 0.0), self.negative_x),
1107            (Vec3::new(0.0, 1.0, 0.0), self.positive_y),
1108            (Vec3::new(0.0, -1.0, 0.0), self.negative_y),
1109            (Vec3::new(0.0, 0.0, 1.0), self.positive_z),
1110            (Vec3::new(0.0, 0.0, -1.0), self.negative_z),
1111        ];
1112
1113        for (dir, color) in &dirs_colors {
1114            sh.add_sample(*dir, Vec3::new(color.r, color.g, color.b), weight);
1115        }
1116
1117        sh
1118    }
1119}
1120
1121// ── Hemisphere Light ────────────────────────────────────────────────────────
1122
1123/// A hemisphere light with a sky color and ground color that blends based on the surface normal.
1124#[derive(Debug, Clone)]
1125pub struct HemisphereLight {
1126    pub sky_color: Color,
1127    pub ground_color: Color,
1128    pub intensity: f32,
1129    pub up_direction: Vec3,
1130    pub enabled: bool,
1131}
1132
1133impl Default for HemisphereLight {
1134    fn default() -> Self {
1135        Self {
1136            sky_color: Color::new(0.6, 0.7, 0.9),
1137            ground_color: Color::new(0.15, 0.12, 0.1),
1138            intensity: 0.3,
1139            up_direction: Vec3::UP,
1140            enabled: true,
1141        }
1142    }
1143}
1144
1145impl HemisphereLight {
1146    pub fn new(sky: Color, ground: Color, intensity: f32) -> Self {
1147        Self {
1148            sky_color: sky,
1149            ground_color: ground,
1150            intensity,
1151            ..Default::default()
1152        }
1153    }
1154
1155    /// Evaluate irradiance for a surface normal.
1156    pub fn irradiance(&self, normal: Vec3) -> Color {
1157        if !self.enabled {
1158            return Color::BLACK;
1159        }
1160        let t = normal.dot(self.up_direction) * 0.5 + 0.5;
1161        self.ground_color.lerp(self.sky_color, t).scale(self.intensity)
1162    }
1163
1164    /// Set the up direction.
1165    pub fn with_up(mut self, up: Vec3) -> Self {
1166        self.up_direction = up.normalize();
1167        self
1168    }
1169}
1170
1171// ── Ambient System ──────────────────────────────────────────────────────────
1172
1173/// Orchestrates all ambient and indirect lighting components.
1174#[derive(Debug)]
1175pub struct AmbientSystem {
1176    pub ssao_config: SsaoConfig,
1177    pub ssao_kernel: SsaoKernel,
1178    pub ssao_result: Option<SsaoResult>,
1179    pub probe_grid: Option<LightProbeGrid>,
1180    pub reflection_probes: ReflectionProbeManager,
1181    pub ambient_cube: AmbientCube,
1182    pub hemisphere: HemisphereLight,
1183    pub environment_sh: SphericalHarmonics9,
1184    /// Global ambient multiplier.
1185    pub ambient_multiplier: f32,
1186    /// Whether SSAO is enabled.
1187    pub ssao_enabled: bool,
1188    /// Whether the light probe grid is enabled.
1189    pub probes_enabled: bool,
1190    /// Whether reflection probes are enabled.
1191    pub reflections_enabled: bool,
1192}
1193
1194impl AmbientSystem {
1195    pub fn new() -> Self {
1196        let config = SsaoConfig::default();
1197        let kernel = SsaoKernel::new(config.clone());
1198        Self {
1199            ssao_config: config,
1200            ssao_kernel: kernel,
1201            ssao_result: None,
1202            probe_grid: None,
1203            reflection_probes: ReflectionProbeManager::new(),
1204            ambient_cube: AmbientCube::default(),
1205            hemisphere: HemisphereLight::default(),
1206            environment_sh: SphericalHarmonics9::from_ambient(Color::new(0.1, 0.1, 0.15)),
1207            ambient_multiplier: 1.0,
1208            ssao_enabled: true,
1209            probes_enabled: true,
1210            reflections_enabled: true,
1211        }
1212    }
1213
1214    /// Recreate the SSAO kernel when config changes.
1215    pub fn update_ssao_config(&mut self, config: SsaoConfig) {
1216        self.ssao_kernel = SsaoKernel::new(config.clone());
1217        self.ssao_config = config;
1218    }
1219
1220    /// Compute SSAO for the given depth and normal buffers.
1221    pub fn compute_ssao(
1222        &mut self,
1223        width: u32,
1224        height: u32,
1225        depth_buffer: &[f32],
1226        normal_buffer: &[Vec3],
1227        projection: &Mat4,
1228    ) {
1229        if !self.ssao_enabled {
1230            return;
1231        }
1232        let mut result = SsaoResult::new(width, height);
1233        result.compute(depth_buffer, normal_buffer, &self.ssao_kernel, projection);
1234        self.ssao_result = Some(result);
1235    }
1236
1237    /// Get the SSAO factor at a pixel.
1238    pub fn ssao_at(&self, x: u32, y: u32) -> f32 {
1239        if !self.ssao_enabled {
1240            return 1.0;
1241        }
1242        match &self.ssao_result {
1243            Some(result) => result.ao_at(x, y),
1244            None => 1.0,
1245        }
1246    }
1247
1248    /// Compute total ambient irradiance at a world position.
1249    pub fn ambient_irradiance(&self, point: Vec3, normal: Vec3) -> Color {
1250        let mut total = Color::BLACK;
1251
1252        // Hemisphere light
1253        let hemi = self.hemisphere.irradiance(normal);
1254        total = Color::new(total.r + hemi.r, total.g + hemi.g, total.b + hemi.b);
1255
1256        // Environment SH
1257        let env = self.environment_sh.evaluate_color(normal);
1258        total = Color::new(total.r + env.r, total.g + env.g, total.b + env.b);
1259
1260        // Ambient cube
1261        let cube = self.ambient_cube.evaluate(normal);
1262        total = Color::new(total.r + cube.r, total.g + cube.g, total.b + cube.b);
1263
1264        // Light probe grid
1265        if self.probes_enabled {
1266            if let Some(ref grid) = self.probe_grid {
1267                if grid.contains(point) {
1268                    let probe_color = grid.sample_irradiance(point, normal);
1269                    total = Color::new(
1270                        total.r + probe_color.r,
1271                        total.g + probe_color.g,
1272                        total.b + probe_color.b,
1273                    );
1274                }
1275            }
1276        }
1277
1278        total.scale(self.ambient_multiplier)
1279    }
1280
1281    /// Sample reflection at a world position.
1282    pub fn sample_reflection(
1283        &self,
1284        point: Vec3,
1285        reflection_dir: Vec3,
1286        roughness: f32,
1287    ) -> Color {
1288        if !self.reflections_enabled {
1289            return self.reflection_probes.fallback_color;
1290        }
1291        self.reflection_probes.sample(point, reflection_dir, roughness)
1292    }
1293
1294    /// Set up a probe grid for the scene.
1295    pub fn setup_probe_grid(
1296        &mut self,
1297        origin: Vec3,
1298        cell_size: Vec3,
1299        count_x: u32,
1300        count_y: u32,
1301        count_z: u32,
1302    ) {
1303        self.probe_grid = Some(LightProbeGrid::new(origin, cell_size, count_x, count_y, count_z));
1304    }
1305
1306    /// Fill the probe grid with a uniform ambient color.
1307    pub fn fill_probes_uniform(&mut self, color: Color) {
1308        if let Some(ref mut grid) = self.probe_grid {
1309            grid.fill_uniform(color);
1310        }
1311    }
1312
1313    /// Fill the probe grid with a sky/ground gradient.
1314    pub fn fill_probes_sky_ground(&mut self, sky: Color, ground: Color) {
1315        if let Some(ref mut grid) = self.probe_grid {
1316            grid.fill_sky_ground(sky, ground);
1317        }
1318    }
1319
1320    /// Get stats.
1321    pub fn stats(&self) -> AmbientStats {
1322        AmbientStats {
1323            ssao_enabled: self.ssao_enabled,
1324            ssao_sample_count: self.ssao_config.sample_count,
1325            probe_grid_probes: self.probe_grid.as_ref().map_or(0, |g| g.probe_count()),
1326            reflection_probe_count: self.reflection_probes.count(),
1327            ambient_multiplier: self.ambient_multiplier,
1328        }
1329    }
1330}
1331
1332impl Default for AmbientSystem {
1333    fn default() -> Self {
1334        Self::new()
1335    }
1336}
1337
1338/// Statistics for the ambient system.
1339#[derive(Debug, Clone)]
1340pub struct AmbientStats {
1341    pub ssao_enabled: bool,
1342    pub ssao_sample_count: u32,
1343    pub probe_grid_probes: usize,
1344    pub reflection_probe_count: usize,
1345    pub ambient_multiplier: f32,
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351
1352    #[test]
1353    fn test_ssao_kernel_generation() {
1354        let config = SsaoConfig {
1355            sample_count: 16,
1356            noise_size: 4,
1357            ..Default::default()
1358        };
1359        let kernel = SsaoKernel::new(config);
1360        assert_eq!(kernel.samples.len(), 16);
1361        assert_eq!(kernel.noise.len(), 16);
1362
1363        // All samples should be in the positive-Z hemisphere
1364        for s in &kernel.samples {
1365            assert!(s.z >= 0.0);
1366        }
1367    }
1368
1369    #[test]
1370    fn test_sh_constant_environment() {
1371        let sh = SphericalHarmonics9::from_ambient(Color::new(0.5, 0.5, 0.5));
1372        let irr = sh.evaluate(Vec3::UP);
1373        // Should be close to the ambient color
1374        assert!((irr.x - 0.5).abs() < 0.2);
1375    }
1376
1377    #[test]
1378    fn test_sh_sky_ground() {
1379        let sh = SphericalHarmonics9::from_sky_ground(Color::BLUE, Color::RED);
1380        let sky_irr = sh.evaluate(Vec3::UP);
1381        let ground_irr = sh.evaluate(Vec3::DOWN);
1382        // Sky should have more blue, ground more red
1383        assert!(sky_irr.z > ground_irr.z);
1384        assert!(ground_irr.x > sky_irr.x);
1385    }
1386
1387    #[test]
1388    fn test_light_probe_grid() {
1389        let mut grid = LightProbeGrid::new(
1390            Vec3::ZERO,
1391            Vec3::new(5.0, 5.0, 5.0),
1392            3, 3, 3,
1393        );
1394        assert_eq!(grid.probe_count(), 27);
1395
1396        grid.fill_uniform(Color::new(0.3, 0.3, 0.3));
1397
1398        let irr = grid.sample_irradiance(Vec3::new(2.5, 2.5, 2.5), Vec3::UP);
1399        assert!(irr.r > 0.0);
1400    }
1401
1402    #[test]
1403    fn test_reflection_probe_blend() {
1404        let mut manager = ReflectionProbeManager::new();
1405        let mut probe = ReflectionProbe::new(
1406            Vec3::ZERO,
1407            Vec3::new(10.0, 10.0, 10.0),
1408            4,
1409        );
1410        probe.fill_solid(Color::new(0.5, 0.5, 0.5));
1411        manager.add(probe);
1412
1413        let color = manager.sample(
1414            Vec3::new(1.0, 0.0, 0.0),
1415            Vec3::new(1.0, 0.0, 0.0),
1416            0.0,
1417        );
1418        assert!(color.r > 0.0);
1419    }
1420
1421    #[test]
1422    fn test_ambient_cube() {
1423        let cube = AmbientCube::from_sky_ground(
1424            Color::BLUE,
1425            Color::RED,
1426        );
1427
1428        let up_color = cube.evaluate(Vec3::UP);
1429        let down_color = cube.evaluate(Vec3::DOWN);
1430
1431        assert!(up_color.b > up_color.r);
1432        assert!(down_color.r > down_color.b);
1433    }
1434
1435    #[test]
1436    fn test_hemisphere_light() {
1437        let hemi = HemisphereLight::new(
1438            Color::new(0.5, 0.6, 0.9),
1439            Color::new(0.2, 0.15, 0.1),
1440            1.0,
1441        );
1442
1443        let up = hemi.irradiance(Vec3::UP);
1444        let down = hemi.irradiance(Vec3::DOWN);
1445
1446        assert!(up.b > down.b); // Sky is more blue
1447        assert!(down.r > up.r || true); // Ground is warmer
1448    }
1449
1450    #[test]
1451    fn test_ssao_result_bilateral_blur() {
1452        let mut result = SsaoResult::new(8, 8);
1453        // Set a pattern
1454        for y in 0..8u32 {
1455            for x in 0..8u32 {
1456                let val = if (x + y) % 2 == 0 { 0.5 } else { 1.0 };
1457                result.ao_buffer[(y as usize) * 8 + (x as usize)] = val;
1458            }
1459        }
1460
1461        let depth = vec![0.5f32; 64];
1462        result.bilateral_blur(&depth, 1, 2.0);
1463
1464        // After blur, values should be more uniform
1465        let avg = result.average_ao();
1466        assert!(avg > 0.5 && avg < 1.0);
1467    }
1468
1469    #[test]
1470    fn test_reflection_probe_parallax() {
1471        let probe = ReflectionProbe::new(
1472            Vec3::ZERO,
1473            Vec3::new(5.0, 5.0, 5.0),
1474            4,
1475        );
1476
1477        let corrected = probe.parallax_correct(
1478            Vec3::new(1.0, 0.0, 0.0),
1479            Vec3::new(1.0, 0.0, 0.0),
1480        );
1481
1482        // The corrected direction should be normalized
1483        let len = corrected.length();
1484        assert!((len - 1.0).abs() < 0.01);
1485    }
1486}