Skip to main content

proof_engine/editor/
light_probe.rs

1
2//! Light probe baker and reflection capture system.
3
4use glam::{Vec2, Vec3, Vec4, Mat4, Quat};
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// Spherical harmonics
9// ---------------------------------------------------------------------------
10
11/// L2 spherical harmonic coefficients (9 coefficients per color channel).
12#[derive(Debug, Clone, Copy)]
13pub struct SphericalHarmonicsL2 {
14    pub coeffs: [[f32; 9]; 3], // [R, G, B][coeff]
15}
16
17impl SphericalHarmonicsL2 {
18    pub fn zero() -> Self {
19        Self { coeffs: [[0.0; 9]; 3] }
20    }
21
22    pub fn ambient(color: Vec3) -> Self {
23        let mut sh = Self::zero();
24        // DC term (L0)
25        let scale = 2.0 * std::f32::consts::PI / 3.0;
26        sh.coeffs[0][0] = color.x * scale;
27        sh.coeffs[1][0] = color.y * scale;
28        sh.coeffs[2][0] = color.z * scale;
29        sh
30    }
31
32    /// Evaluate SH at direction `dir` (must be unit vector).
33    pub fn evaluate(&self, dir: Vec3) -> Vec3 {
34        let (x, y, z) = (dir.x, dir.y, dir.z);
35        // Basis functions Y_l^m evaluated at direction
36        let b = [
37            0.282_095,                        // L0
38            0.488_603 * y,                    // L1 m=-1
39            0.488_603 * z,                    // L1 m=0
40            0.488_603 * x,                    // L1 m=1
41            1.092_548 * x * y,                // L2 m=-2
42            1.092_548 * y * z,                // L2 m=-1
43            0.315_392 * (3.0 * z * z - 1.0), // L2 m=0
44            1.092_548 * x * z,                // L2 m=1
45            0.546_274 * (x * x - y * y),     // L2 m=2
46        ];
47        let r = self.coeffs[0].iter().zip(b.iter()).map(|(c, b)| c * b).sum::<f32>();
48        let g = self.coeffs[1].iter().zip(b.iter()).map(|(c, b)| c * b).sum::<f32>();
49        let b_val = self.coeffs[2].iter().zip(b.iter()).map(|(c, b)| c * b).sum::<f32>();
50        Vec3::new(r.max(0.0), g.max(0.0), b_val.max(0.0))
51    }
52
53    pub fn add_sample(&mut self, dir: Vec3, color: Vec3, weight: f32) {
54        let (x, y, z) = (dir.x, dir.y, dir.z);
55        let b = [
56            0.282_095,
57            0.488_603 * y,
58            0.488_603 * z,
59            0.488_603 * x,
60            1.092_548 * x * y,
61            1.092_548 * y * z,
62            0.315_392 * (3.0 * z * z - 1.0),
63            1.092_548 * x * z,
64            0.546_274 * (x * x - y * y),
65        ];
66        for i in 0..9 {
67            self.coeffs[0][i] += color.x * b[i] * weight;
68            self.coeffs[1][i] += color.y * b[i] * weight;
69            self.coeffs[2][i] += color.z * b[i] * weight;
70        }
71    }
72
73    pub fn lerp(&self, other: &Self, t: f32) -> Self {
74        let mut result = Self::zero();
75        for ch in 0..3 {
76            for i in 0..9 {
77                result.coeffs[ch][i] = self.coeffs[ch][i] * (1.0 - t) + other.coeffs[ch][i] * t;
78            }
79        }
80        result
81    }
82
83    /// Windowed SH (reduces ringing artifacts).
84    pub fn apply_windowing(&mut self, sigma: f32) {
85        let scale = [
86            1.0_f32,
87            (-sigma).exp(),
88            (-2.0 * sigma).exp(),
89        ];
90        // L0 bands = 1 coeff, L1 = 3, L2 = 5
91        // coeff[0] is L0, [1..=3] is L1, [4..=8] is L2
92        for ch in 0..3 {
93            for i in 1..=3 {
94                self.coeffs[ch][i] *= scale[1];
95            }
96            for i in 4..=8 {
97                self.coeffs[ch][i] *= scale[2];
98            }
99        }
100        let _ = scale;
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Cubemap face / resolution
106// ---------------------------------------------------------------------------
107
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub enum CubemapFace {
110    PosX, NegX, PosY, NegY, PosZ, NegZ,
111}
112
113impl CubemapFace {
114    pub fn all() -> [CubemapFace; 6] {
115        [CubemapFace::PosX, CubemapFace::NegX, CubemapFace::PosY,
116         CubemapFace::NegY, CubemapFace::PosZ, CubemapFace::NegZ]
117    }
118
119    pub fn view_matrix(self) -> Mat4 {
120        match self {
121            CubemapFace::PosX => Mat4::look_at_rh(Vec3::ZERO, Vec3::X,    Vec3::NEG_Y),
122            CubemapFace::NegX => Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_X, Vec3::NEG_Y),
123            CubemapFace::PosY => Mat4::look_at_rh(Vec3::ZERO, Vec3::Y,    Vec3::Z),
124            CubemapFace::NegY => Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_Y, Vec3::NEG_Z),
125            CubemapFace::PosZ => Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_Z, Vec3::NEG_Y),
126            CubemapFace::NegZ => Mat4::look_at_rh(Vec3::ZERO, Vec3::Z,    Vec3::NEG_Y),
127        }
128    }
129
130    pub fn label(self) -> &'static str {
131        match self {
132            CubemapFace::PosX => "+X", CubemapFace::NegX => "-X",
133            CubemapFace::PosY => "+Y", CubemapFace::NegY => "-Y",
134            CubemapFace::PosZ => "+Z", CubemapFace::NegZ => "-Z",
135        }
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub enum CubemapResolution {
141    R16, R32, R64, R128, R256, R512, R1024, R2048,
142}
143
144impl CubemapResolution {
145    pub fn pixels(self) -> u32 {
146        match self {
147            CubemapResolution::R16   => 16,
148            CubemapResolution::R32   => 32,
149            CubemapResolution::R64   => 64,
150            CubemapResolution::R128  => 128,
151            CubemapResolution::R256  => 256,
152            CubemapResolution::R512  => 512,
153            CubemapResolution::R1024 => 1024,
154            CubemapResolution::R2048 => 2048,
155        }
156    }
157
158    pub fn bytes_hdr_f16(self) -> u64 {
159        // 6 faces * res*res * 4 channels * 2 bytes per channel
160        let res = self.pixels() as u64;
161        6 * res * res * 4 * 2
162    }
163
164    pub fn label(self) -> &'static str {
165        match self {
166            CubemapResolution::R16   => "16",
167            CubemapResolution::R32   => "32",
168            CubemapResolution::R64   => "64",
169            CubemapResolution::R128  => "128",
170            CubemapResolution::R256  => "256",
171            CubemapResolution::R512  => "512",
172            CubemapResolution::R1024 => "1024",
173            CubemapResolution::R2048 => "2048",
174        }
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Light probe
180// ---------------------------------------------------------------------------
181
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub enum ProbeType {
184    BakedSh,
185    RealtimeSh,
186    Mixed,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq)]
190pub enum ProbeBakeStatus {
191    NotBaked,
192    Baking,
193    Baked,
194    Outdated,
195    Failed,
196}
197
198#[derive(Debug, Clone)]
199pub struct LightProbe {
200    pub id: u32,
201    pub name: String,
202    pub position: Vec3,
203    pub probe_type: ProbeType,
204    pub bake_status: ProbeBakeStatus,
205    pub sh: SphericalHarmonicsL2,
206    pub influence_radius: f32,
207    pub blend_distance: f32,
208    pub importance: f32,
209    pub occlusion: f32,
210    pub custom_bounds: Option<[Vec3; 2]>,
211}
212
213impl LightProbe {
214    pub fn new(id: u32, name: impl Into<String>, position: Vec3) -> Self {
215        Self {
216            id,
217            name: name.into(),
218            position,
219            probe_type: ProbeType::BakedSh,
220            bake_status: ProbeBakeStatus::NotBaked,
221            sh: SphericalHarmonicsL2::zero(),
222            influence_radius: 10.0,
223            blend_distance: 2.0,
224            importance: 1.0,
225            occlusion: 1.0,
226            custom_bounds: None,
227        }
228    }
229
230    pub fn influence_at(&self, point: Vec3) -> f32 {
231        let dist = point.distance(self.position);
232        if dist >= self.influence_radius + self.blend_distance { return 0.0; }
233        if dist <= self.influence_radius { return self.importance; }
234        let t = 1.0 - (dist - self.influence_radius) / self.blend_distance.max(0.001);
235        t * self.importance
236    }
237
238    pub fn evaluate_irradiance(&self, normal: Vec3) -> Vec3 {
239        self.sh.evaluate(normal) * self.occlusion
240    }
241
242    /// Simulate baking by populating with a procedural sky gradient.
243    pub fn bake_synthetic(&mut self) {
244        self.sh = SphericalHarmonicsL2::zero();
245        let sample_count = 512;
246        let golden_ratio = 1.618_034;
247        for i in 0..sample_count {
248            let theta = 2.0 * std::f32::consts::PI * i as f32 / golden_ratio;
249            let phi = (1.0 - 2.0 * (i as f32 + 0.5) / sample_count as f32).acos();
250            let dir = Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
251            // Sky-like radiance: warm sun from above, blue sky ambient
252            let sky_t = (dir.y * 0.5 + 0.5).max(0.0);
253            let sky_color = Vec3::new(0.3, 0.5, 1.0).lerp(Vec3::new(1.0, 0.9, 0.7), sky_t);
254            // Sun contribution
255            let sun_dir = Vec3::new(0.4, 0.9, 0.2).normalize();
256            let sun_factor = dir.dot(sun_dir).max(0.0).powf(64.0);
257            let color = sky_color + Vec3::new(1.0, 0.95, 0.8) * sun_factor * 3.0;
258            self.sh.add_sample(dir, color, 4.0 * std::f32::consts::PI / sample_count as f32);
259        }
260        self.bake_status = ProbeBakeStatus::Baked;
261    }
262}
263
264// ---------------------------------------------------------------------------
265// Reflection capture
266// ---------------------------------------------------------------------------
267
268#[derive(Debug, Clone, Copy, PartialEq)]
269pub enum ReflectionCaptureShape {
270    Sphere,
271    Box,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq)]
275pub enum ReflectionProjectionMode {
276    SkyOnly,
277    Local,
278    WorldCoords,
279}
280
281#[derive(Debug, Clone)]
282pub struct ReflectionCapture {
283    pub id: u32,
284    pub name: String,
285    pub position: Vec3,
286    pub rotation: Quat,
287    pub shape: ReflectionCaptureShape,
288    pub influence_radius: f32,
289    pub box_half_extents: Vec3,
290    pub blend_distance: f32,
291    pub projection_mode: ReflectionProjectionMode,
292    pub projection_offset: Vec3,
293    pub resolution: CubemapResolution,
294    pub bake_status: ProbeBakeStatus,
295    pub importance: f32,
296    pub intensity: f32,
297    pub hdr_scale: f32,
298    /// GPU handle for the baked cubemap
299    pub cubemap_handle: Option<u64>,
300    pub mip_levels: u32,
301}
302
303impl ReflectionCapture {
304    pub fn new_sphere(id: u32, name: impl Into<String>, position: Vec3, radius: f32) -> Self {
305        Self {
306            id,
307            name: name.into(),
308            position,
309            rotation: Quat::IDENTITY,
310            shape: ReflectionCaptureShape::Sphere,
311            influence_radius: radius,
312            box_half_extents: Vec3::ONE,
313            blend_distance: radius * 0.1,
314            projection_mode: ReflectionProjectionMode::Local,
315            projection_offset: Vec3::ZERO,
316            resolution: CubemapResolution::R256,
317            bake_status: ProbeBakeStatus::NotBaked,
318            importance: 1.0,
319            intensity: 1.0,
320            hdr_scale: 1.0,
321            cubemap_handle: None,
322            mip_levels: 7,
323        }
324    }
325
326    pub fn new_box(id: u32, name: impl Into<String>, position: Vec3, half_extents: Vec3) -> Self {
327        let mut cap = Self::new_sphere(id, name, position, half_extents.length());
328        cap.shape = ReflectionCaptureShape::Box;
329        cap.box_half_extents = half_extents;
330        cap
331    }
332
333    pub fn influence_at(&self, point: Vec3) -> f32 {
334        let local = self.rotation.inverse().mul_vec3(point - self.position);
335        let dist = match self.shape {
336            ReflectionCaptureShape::Sphere => local.length(),
337            ReflectionCaptureShape::Box => {
338                let d = local.abs() - self.box_half_extents;
339                d.max(Vec3::ZERO).length() + d.min(Vec3::ZERO).max_element()
340            }
341        };
342        let outer = match self.shape {
343            ReflectionCaptureShape::Sphere => self.influence_radius,
344            ReflectionCaptureShape::Box => self.box_half_extents.max_element(),
345        };
346        if dist >= outer + self.blend_distance { return 0.0; }
347        if dist <= 0.0 { return self.importance; }
348        let t = 1.0 - (dist / (outer + self.blend_distance)).clamp(0.0, 1.0);
349        t * self.importance
350    }
351
352    pub fn get_reflection_direction(&self, sample_dir: Vec3, world_pos: Vec3) -> Vec3 {
353        match self.projection_mode {
354            ReflectionProjectionMode::SkyOnly | ReflectionProjectionMode::WorldCoords => sample_dir,
355            ReflectionProjectionMode::Local => {
356                // Box projection
357                let local = world_pos - (self.position + self.projection_offset);
358                match self.shape {
359                    ReflectionCaptureShape::Box => {
360                        let inv_dir = 1.0 / (sample_dir + Vec3::splat(1e-6));
361                        let t_pos = (self.box_half_extents - local) * inv_dir;
362                        let t_neg = (-self.box_half_extents - local) * inv_dir;
363                        let t = t_pos.max(t_neg).min_element();
364                        let hit = local + sample_dir * t;
365                        (hit - local).normalize()
366                    }
367                    ReflectionCaptureShape::Sphere => sample_dir,
368                }
369            }
370        }
371    }
372
373    pub fn face_view_matrices(&self) -> [Mat4; 6] {
374        CubemapFace::all().map(|f| {
375            let local_view = f.view_matrix();
376            let rotation_mat = Mat4::from_quat(self.rotation.inverse());
377            let translation = Mat4::from_translation(-self.position);
378            local_view * rotation_mat * translation
379        })
380    }
381
382    pub fn memory_bytes(&self) -> u64 {
383        self.resolution.bytes_hdr_f16()
384    }
385}
386
387// ---------------------------------------------------------------------------
388// Probe group / grid
389// ---------------------------------------------------------------------------
390
391#[derive(Debug, Clone)]
392pub struct LightProbeGroup {
393    pub name: String,
394    pub probes: Vec<LightProbe>,
395    pub grid_min: Vec3,
396    pub grid_max: Vec3,
397    pub grid_dims: [u32; 3], // x, y, z count
398    pub use_tetrahedral_interpolation: bool,
399}
400
401impl LightProbeGroup {
402    pub fn new(name: impl Into<String>) -> Self {
403        Self {
404            name: name.into(),
405            probes: Vec::new(),
406            grid_min: Vec3::new(-5.0, 0.0, -5.0),
407            grid_max: Vec3::new(5.0, 3.0, 5.0),
408            grid_dims: [3, 2, 3],
409            use_tetrahedral_interpolation: true,
410        }
411    }
412
413    pub fn generate_grid_positions(&mut self) {
414        self.probes.clear();
415        let [nx, ny, nz] = self.grid_dims;
416        let mut id = 1u32;
417        for iz in 0..nz {
418            for iy in 0..ny {
419                for ix in 0..nx {
420                    let t = Vec3::new(
421                        ix as f32 / (nx.max(2) - 1) as f32,
422                        iy as f32 / (ny.max(2) - 1) as f32,
423                        iz as f32 / (nz.max(2) - 1) as f32,
424                    );
425                    let pos = Vec3::new(
426                        self.grid_min.x + (self.grid_max.x - self.grid_min.x) * t.x,
427                        self.grid_min.y + (self.grid_max.y - self.grid_min.y) * t.y,
428                        self.grid_min.z + (self.grid_max.z - self.grid_min.z) * t.z,
429                    );
430                    let name = format!("Probe_{}_{}_{}",  ix, iy, iz);
431                    self.probes.push(LightProbe::new(id, name, pos));
432                    id += 1;
433                }
434            }
435        }
436    }
437
438    pub fn probe_count(&self) -> usize {
439        self.probes.len()
440    }
441
442    /// Find the 4 nearest probes and return weights for tetrahedral interpolation.
443    pub fn find_nearest_probes(&self, point: Vec3, count: usize) -> Vec<(usize, f32)> {
444        let mut distances: Vec<(usize, f32)> = self.probes.iter()
445            .enumerate()
446            .filter(|(_, p)| p.bake_status == ProbeBakeStatus::Baked)
447            .map(|(i, p)| (i, p.position.distance_squared(point)))
448            .collect();
449        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
450        distances.truncate(count);
451        // Inverse distance weighting
452        let inv_dists: Vec<f32> = distances.iter().map(|(_, d)| 1.0 / (d.sqrt() + 0.001)).collect();
453        let total: f32 = inv_dists.iter().sum();
454        distances.iter().zip(inv_dists.iter())
455            .map(|((i, _), inv_d)| (*i, inv_d / total))
456            .collect()
457    }
458
459    pub fn interpolate_irradiance(&self, point: Vec3, normal: Vec3) -> Vec3 {
460        let weights = self.find_nearest_probes(point, 4);
461        if weights.is_empty() { return Vec3::ZERO; }
462        let mut result = Vec3::ZERO;
463        for (i, w) in &weights {
464            result += self.probes[*i].evaluate_irradiance(normal) * *w;
465        }
466        result
467    }
468
469    pub fn bake_all_synthetic(&mut self) {
470        for probe in self.probes.iter_mut() {
471            probe.bake_synthetic();
472        }
473    }
474}
475
476// ---------------------------------------------------------------------------
477// Baker settings
478// ---------------------------------------------------------------------------
479
480#[derive(Debug, Clone)]
481pub struct LightBakerSettings {
482    pub ray_count_per_probe: u32,
483    pub bounces: u32,
484    pub sky_intensity: f32,
485    pub sky_color: Vec3,
486    pub sun_direction: Vec3,
487    pub sun_color: Vec3,
488    pub sun_intensity: f32,
489    pub probe_resolution: CubemapResolution,
490    pub reflection_resolution: CubemapResolution,
491    pub use_gpu_baking: bool,
492    pub compress_to_bc6h: bool,
493    pub generate_mipmaps: bool,
494    pub max_probe_iterations: u32,
495    pub convergence_threshold: f32,
496}
497
498impl Default for LightBakerSettings {
499    fn default() -> Self {
500        Self {
501            ray_count_per_probe: 1024,
502            bounces: 3,
503            sky_intensity: 1.0,
504            sky_color: Vec3::new(0.4, 0.6, 1.0),
505            sun_direction: Vec3::new(0.4, 0.9, 0.2).normalize(),
506            sun_color: Vec3::new(1.0, 0.95, 0.8),
507            sun_intensity: 5.0,
508            probe_resolution: CubemapResolution::R128,
509            reflection_resolution: CubemapResolution::R256,
510            use_gpu_baking: true,
511            compress_to_bc6h: true,
512            generate_mipmaps: true,
513            max_probe_iterations: 4,
514            convergence_threshold: 0.001,
515        }
516    }
517}
518
519// ---------------------------------------------------------------------------
520// Light probe editor
521// ---------------------------------------------------------------------------
522
523#[derive(Debug, Clone, Copy, PartialEq)]
524pub enum LightProbeEditorPanel {
525    ProbeList,
526    ReflectionCaptureList,
527    BakerSettings,
528    DebugVisualization,
529}
530
531#[derive(Debug, Clone, Copy, PartialEq)]
532pub enum ProbeDebugMode {
533    None,
534    ShInfluence,
535    ReflectionCaptures,
536    ProbeGrid,
537    IrradianceOverlay,
538}
539
540#[derive(Debug, Clone)]
541pub struct LightProbeEditor {
542    pub probe_groups: Vec<LightProbeGroup>,
543    pub reflection_captures: Vec<ReflectionCapture>,
544    pub baker_settings: LightBakerSettings,
545    pub active_panel: LightProbeEditorPanel,
546    pub debug_mode: ProbeDebugMode,
547    pub selected_group: Option<usize>,
548    pub selected_probe_id: Option<u32>,
549    pub selected_capture_id: Option<u32>,
550    pub bake_progress: f32,
551    pub is_baking: bool,
552    pub show_probe_gizmos: bool,
553    pub show_capture_gizmos: bool,
554    pub next_capture_id: u32,
555}
556
557impl LightProbeEditor {
558    pub fn new() -> Self {
559        let mut ed = Self {
560            probe_groups: Vec::new(),
561            reflection_captures: Vec::new(),
562            baker_settings: LightBakerSettings::default(),
563            active_panel: LightProbeEditorPanel::ProbeList,
564            debug_mode: ProbeDebugMode::None,
565            selected_group: None,
566            selected_probe_id: None,
567            selected_capture_id: None,
568            bake_progress: 0.0,
569            is_baking: false,
570            show_probe_gizmos: true,
571            show_capture_gizmos: true,
572            next_capture_id: 1,
573        };
574        // Default group
575        let mut group = LightProbeGroup::new("MainProbeGroup");
576        group.grid_dims = [4, 2, 4];
577        group.grid_min = Vec3::new(-15.0, 0.0, -15.0);
578        group.grid_max = Vec3::new(15.0, 4.0, 15.0);
579        group.generate_grid_positions();
580        ed.probe_groups.push(group);
581
582        // Some reflection captures
583        ed.add_capture(ReflectionCapture::new_sphere(ed.next_capture_id, "ReflCapture_Main", Vec3::new(0.0, 2.0, 0.0), 20.0));
584        ed.add_capture(ReflectionCapture::new_box(ed.next_capture_id, "ReflCapture_Room", Vec3::ZERO, Vec3::new(8.0, 3.0, 8.0)));
585        ed
586    }
587
588    pub fn add_capture(&mut self, capture: ReflectionCapture) {
589        self.next_capture_id += 1;
590        self.reflection_captures.push(capture);
591    }
592
593    pub fn add_probe_group(&mut self, name: impl Into<String>) -> usize {
594        let mut group = LightProbeGroup::new(name);
595        group.generate_grid_positions();
596        let idx = self.probe_groups.len();
597        self.probe_groups.push(group);
598        idx
599    }
600
601    pub fn start_bake(&mut self) {
602        self.is_baking = true;
603        self.bake_progress = 0.0;
604    }
605
606    pub fn update(&mut self, dt: f32) {
607        if self.is_baking {
608            self.bake_progress += dt * 0.2; // 5-second synthetic bake
609            if self.bake_progress >= 1.0 {
610                self.bake_progress = 1.0;
611                self.is_baking = false;
612                // Mark all probes baked
613                for group in &mut self.probe_groups {
614                    group.bake_all_synthetic();
615                }
616                for cap in &mut self.reflection_captures {
617                    cap.bake_status = ProbeBakeStatus::Baked;
618                    cap.cubemap_handle = Some(cap.id as u64 * 1000);
619                }
620            }
621        }
622    }
623
624    pub fn total_probe_count(&self) -> usize {
625        self.probe_groups.iter().map(|g| g.probe_count()).sum()
626    }
627
628    pub fn total_reflection_memory_bytes(&self) -> u64 {
629        self.reflection_captures.iter()
630            .filter(|c| c.bake_status == ProbeBakeStatus::Baked)
631            .map(|c| c.memory_bytes())
632            .sum()
633    }
634
635    pub fn query_irradiance(&self, point: Vec3, normal: Vec3) -> Vec3 {
636        // Average across all active groups
637        let mut irradiance = Vec3::ZERO;
638        let mut count = 0;
639        for group in &self.probe_groups {
640            let i = group.interpolate_irradiance(point, normal);
641            if i.length() > 0.0 {
642                irradiance += i;
643                count += 1;
644            }
645        }
646        if count > 0 { irradiance / count as f32 } else { Vec3::splat(0.1) }
647    }
648
649    pub fn find_best_reflection_capture(&self, point: Vec3) -> Option<&ReflectionCapture> {
650        self.reflection_captures.iter()
651            .filter(|c| c.bake_status == ProbeBakeStatus::Baked)
652            .max_by(|a, b| {
653                a.influence_at(point).partial_cmp(&b.influence_at(point)).unwrap_or(std::cmp::Ordering::Equal)
654            })
655            .filter(|c| c.influence_at(point) > 0.0)
656    }
657
658    pub fn memory_report(&self) -> String {
659        let probe_bytes = self.total_probe_count() as u64 * std::mem::size_of::<SphericalHarmonicsL2>() as u64;
660        let reflect_bytes = self.total_reflection_memory_bytes();
661        format!("Probes: {} ({} KB SH data), Reflection: {} KB cubemap data",
662            self.total_probe_count(),
663            probe_bytes / 1024,
664            reflect_bytes / 1024)
665    }
666}
667
668// ---------------------------------------------------------------------------
669// Tests
670// ---------------------------------------------------------------------------
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn test_sh_evaluate() {
677        let sh = SphericalHarmonicsL2::ambient(Vec3::new(0.5, 0.5, 0.5));
678        let v = sh.evaluate(Vec3::Y);
679        assert!(v.x > 0.0);
680    }
681
682    #[test]
683    fn test_sh_bake_synthetic() {
684        let mut probe = LightProbe::new(1, "test", Vec3::ZERO);
685        probe.bake_synthetic();
686        assert_eq!(probe.bake_status, ProbeBakeStatus::Baked);
687        let v = probe.evaluate_irradiance(Vec3::Y);
688        assert!(v.length() > 0.0);
689    }
690
691    #[test]
692    fn test_probe_group_grid() {
693        let mut g = LightProbeGroup::new("test");
694        g.grid_dims = [2, 2, 2];
695        g.generate_grid_positions();
696        assert_eq!(g.probe_count(), 8);
697    }
698
699    #[test]
700    fn test_reflection_capture_influence() {
701        let cap = ReflectionCapture::new_sphere(1, "test", Vec3::ZERO, 10.0);
702        assert!((cap.influence_at(Vec3::ZERO) - 1.0).abs() < 1e-5);
703        assert_eq!(cap.influence_at(Vec3::new(20.0, 0.0, 0.0)), 0.0);
704    }
705
706    #[test]
707    fn test_editor_update() {
708        let mut ed = LightProbeEditor::new();
709        ed.start_bake();
710        for _ in 0..60 {
711            ed.update(0.1);
712        }
713        assert!(!ed.is_baking);
714    }
715}