1
2use glam::{Vec2, Vec3, Vec4, Mat4, Quat};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Copy)]
13pub struct SphericalHarmonicsL2 {
14 pub coeffs: [[f32; 9]; 3], }
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 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 pub fn evaluate(&self, dir: Vec3) -> Vec3 {
34 let (x, y, z) = (dir.x, dir.y, dir.z);
35 let b = [
37 0.282_095, 0.488_603 * y, 0.488_603 * z, 0.488_603 * x, 1.092_548 * x * y, 1.092_548 * y * z, 0.315_392 * (3.0 * z * z - 1.0), 1.092_548 * x * z, 0.546_274 * (x * x - y * y), ];
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 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 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#[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 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#[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 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 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 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#[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 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 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#[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], 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 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 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#[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#[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 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 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; if self.bake_progress >= 1.0 {
610 self.bake_progress = 1.0;
611 self.is_baking = false;
612 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 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#[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}