Skip to main content

proof_engine/render/
lighting.rs

1//! Advanced lighting system.
2//!
3//! Provides a full real-time lighting model for Proof Engine:
4//!
5//! - `PointLight`        — omnidirectional point source with attenuation
6//! - `SpotLight`         — cone-shaped light with inner/outer angle
7//! - `DirectionalLight`  — infinite-distance parallel light (sun/moon)
8//! - `AmbientLight`      — global fill light with optional gradient sky
9//! - `LightProbe`        — pre-sampled spherical environment light at a point
10//! - `ShadowMap`         — depth buffer parameters for shadow rendering
11//! - `LightCuller`       — tile-based forward+ light culling
12//! - `VolumetricConfig`  — god-ray / light shaft parameters
13//! - `LightManager`      — owns all lights, updates, culls
14//!
15//! All attenuation models accept a custom `MathFunction` falloff for
16//! mathematical attenuation curves beyond linear/quadratic.
17
18use glam::{Vec2, Vec3, Vec4, Mat4};
19use std::collections::HashMap;
20use crate::math::MathFunction;
21
22// ── LightId ───────────────────────────────────────────────────────────────────
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct LightId(pub u32);
26
27impl LightId {
28    pub fn invalid() -> Self { Self(u32::MAX) }
29    pub fn is_valid(self) -> bool { self.0 != u32::MAX }
30}
31
32// ── Attenuation ───────────────────────────────────────────────────────────────
33
34/// How a light's intensity falls off with distance.
35#[derive(Debug, Clone)]
36pub enum Attenuation {
37    /// No falloff: constant intensity regardless of distance.
38    Constant,
39    /// 1/distance linear falloff.
40    Linear,
41    /// 1/distance^2 physically-based inverse square.
42    InverseSquare,
43    /// Windowed inverse square (smoothly cuts off at max_range): UE4 style.
44    WindowedInverseSquare { range: f32 },
45    /// Custom MathFunction falloff evaluated at normalized distance [0,1].
46    Math(MathFunction),
47    /// Polynomial: constant + linear*d + quadratic*d^2.
48    Polynomial { constant: f32, linear: f32, quadratic: f32 },
49}
50
51impl Attenuation {
52    /// Evaluate attenuation factor at `distance` with `max_range` hint.
53    pub fn evaluate(&self, distance: f32, max_range: f32) -> f32 {
54        let d = distance.max(1e-4);
55        match self {
56            Self::Constant => 1.0,
57            Self::Linear   => (1.0 - (d / max_range.max(1e-4))).max(0.0),
58            Self::InverseSquare => 1.0 / (d * d),
59            Self::WindowedInverseSquare { range } => {
60                let r = range.max(1e-4);
61                let atten = 1.0 / (d * d);
62                let window = (1.0 - (d / r).powi(4)).max(0.0).powi(2);
63                atten * window
64            }
65            Self::Math(f) => {
66                let t = (d / max_range.max(1e-4)).clamp(0.0, 1.0);
67                f.evaluate(t, t).max(0.0)
68            }
69            Self::Polynomial { constant, linear, quadratic } => {
70                1.0 / (constant + linear * d + quadratic * d * d).max(1e-4)
71            }
72        }
73    }
74}
75
76// ── PointLight ────────────────────────────────────────────────────────────────
77
78#[derive(Debug, Clone)]
79pub struct PointLight {
80    pub id:          LightId,
81    pub position:    Vec3,
82    pub color:       Vec3,
83    pub intensity:   f32,
84    pub range:       f32,
85    pub attenuation: Attenuation,
86    pub cast_shadow: bool,
87    pub enabled:     bool,
88    /// Optional tag for bulk operations.
89    pub tag:         Option<String>,
90}
91
92impl PointLight {
93    pub fn new(position: Vec3, color: Vec3, intensity: f32, range: f32) -> Self {
94        Self {
95            id: LightId::invalid(),
96            position,
97            color,
98            intensity,
99            range,
100            attenuation: Attenuation::WindowedInverseSquare { range },
101            cast_shadow: false,
102            enabled: true,
103            tag: None,
104        }
105    }
106
107    pub fn with_shadow(mut self) -> Self { self.cast_shadow = true; self }
108    pub fn with_attenuation(mut self, a: Attenuation) -> Self { self.attenuation = a; self }
109    pub fn with_tag(mut self, t: impl Into<String>) -> Self { self.tag = Some(t.into()); self }
110
111    pub fn intensity_at(&self, p: Vec3) -> f32 {
112        let dist = (p - self.position).length();
113        if dist >= self.range { return 0.0; }
114        self.intensity * self.attenuation.evaluate(dist, self.range)
115    }
116
117    /// The combined light contribution at point `p` with normal `n`.
118    pub fn contribution(&self, p: Vec3, n: Vec3) -> Vec3 {
119        let to_light = self.position - p;
120        let dist     = to_light.length();
121        if dist >= self.range || !self.enabled { return Vec3::ZERO; }
122        let dir  = to_light / dist.max(1e-7);
123        let ndl  = n.dot(dir).max(0.0);
124        let att  = self.attenuation.evaluate(dist, self.range);
125        self.color * self.intensity * att * ndl
126    }
127}
128
129// ── SpotLight ─────────────────────────────────────────────────────────────────
130
131#[derive(Debug, Clone)]
132pub struct SpotLight {
133    pub id:           LightId,
134    pub position:     Vec3,
135    pub direction:    Vec3,
136    pub color:        Vec3,
137    pub intensity:    f32,
138    pub range:        f32,
139    /// Inner cone half-angle in radians (full brightness inside).
140    pub inner_angle:  f32,
141    /// Outer cone half-angle in radians (zero brightness outside).
142    pub outer_angle:  f32,
143    pub attenuation:  Attenuation,
144    pub cast_shadow:  bool,
145    pub enabled:      bool,
146    pub tag:          Option<String>,
147}
148
149impl SpotLight {
150    pub fn new(position: Vec3, direction: Vec3, color: Vec3, intensity: f32, range: f32) -> Self {
151        Self {
152            id: LightId::invalid(),
153            position,
154            direction: direction.normalize_or_zero(),
155            color,
156            intensity,
157            range,
158            inner_angle: 0.35,
159            outer_angle: 0.65,
160            attenuation: Attenuation::WindowedInverseSquare { range },
161            cast_shadow: false,
162            enabled: true,
163            tag: None,
164        }
165    }
166
167    pub fn with_cone(mut self, inner: f32, outer: f32) -> Self {
168        self.inner_angle = inner;
169        self.outer_angle = outer;
170        self
171    }
172
173    pub fn cone_attenuation(&self, to_light_dir: Vec3) -> f32 {
174        let cos_theta = to_light_dir.dot(-self.direction).max(0.0);
175        let cos_inner = self.inner_angle.cos();
176        let cos_outer = self.outer_angle.cos();
177        ((cos_theta - cos_outer) / (cos_inner - cos_outer + 1e-7)).clamp(0.0, 1.0).powi(2)
178    }
179
180    pub fn contribution(&self, p: Vec3, n: Vec3) -> Vec3 {
181        if !self.enabled { return Vec3::ZERO; }
182        let to_light = self.position - p;
183        let dist     = to_light.length();
184        if dist >= self.range { return Vec3::ZERO; }
185        let dir        = to_light / dist.max(1e-7);
186        let ndl        = n.dot(dir).max(0.0);
187        let dist_atten = self.attenuation.evaluate(dist, self.range);
188        let cone_atten = self.cone_attenuation(dir);
189        self.color * self.intensity * dist_atten * cone_atten * ndl
190    }
191}
192
193// ── DirectionalLight ──────────────────────────────────────────────────────────
194
195#[derive(Debug, Clone)]
196pub struct DirectionalLight {
197    pub id:          LightId,
198    pub direction:   Vec3,
199    pub color:       Vec3,
200    pub intensity:   f32,
201    pub cast_shadow: bool,
202    pub shadow_map:  Option<ShadowMapConfig>,
203    pub enabled:     bool,
204    /// Optional angular diameter for soft shadows (degrees).
205    pub angular_size: f32,
206}
207
208impl DirectionalLight {
209    pub fn sun(direction: Vec3, color: Vec3, intensity: f32) -> Self {
210        Self {
211            id: LightId::invalid(),
212            direction: direction.normalize_or_zero(),
213            color,
214            intensity,
215            cast_shadow: false,
216            shadow_map: None,
217            enabled: true,
218            angular_size: 0.5,
219        }
220    }
221
222    pub fn with_shadow(mut self, cfg: ShadowMapConfig) -> Self {
223        self.cast_shadow = true;
224        self.shadow_map  = Some(cfg);
225        self
226    }
227
228    pub fn contribution(&self, n: Vec3) -> Vec3 {
229        if !self.enabled { return Vec3::ZERO; }
230        let ndl = n.dot(-self.direction).max(0.0);
231        self.color * self.intensity * ndl
232    }
233
234    /// Build the view-projection matrix for this light's shadow pass.
235    pub fn shadow_view_proj(&self, scene_center: Vec3, scene_radius: f32) -> Mat4 {
236        let eye = scene_center - self.direction * scene_radius * 2.0;
237        let up  = if self.direction.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
238        let view = Mat4::look_at_rh(eye, scene_center, up);
239        let proj = Mat4::orthographic_rh(
240            -scene_radius, scene_radius,
241            -scene_radius, scene_radius,
242            0.1, scene_radius * 4.0,
243        );
244        proj * view
245    }
246}
247
248// ── AmbientLight ──────────────────────────────────────────────────────────────
249
250#[derive(Debug, Clone)]
251pub struct AmbientLight {
252    /// Sky hemisphere color (top).
253    pub sky_color:    Vec3,
254    /// Ground hemisphere color (bottom).
255    pub ground_color: Vec3,
256    pub intensity:    f32,
257}
258
259impl AmbientLight {
260    pub fn uniform(color: Vec3, intensity: f32) -> Self {
261        Self { sky_color: color, ground_color: color, intensity }
262    }
263
264    pub fn hemisphere(sky: Vec3, ground: Vec3, intensity: f32) -> Self {
265        Self { sky_color: sky, ground_color: ground, intensity }
266    }
267
268    /// Evaluate ambient for a surface with given world-space normal.
269    pub fn evaluate(&self, normal: Vec3) -> Vec3 {
270        let t = (normal.dot(Vec3::Y) * 0.5 + 0.5).clamp(0.0, 1.0);
271        (self.sky_color * t + self.ground_color * (1.0 - t)) * self.intensity
272    }
273}
274
275impl Default for AmbientLight {
276    fn default() -> Self {
277        Self::uniform(Vec3::new(0.1, 0.1, 0.15), 0.5)
278    }
279}
280
281// ── ShadowMapConfig ───────────────────────────────────────────────────────────
282
283#[derive(Debug, Clone)]
284pub struct ShadowMapConfig {
285    pub resolution:     u32,
286    pub bias:           f32,
287    pub normal_bias:    f32,
288    /// Number of PCF (percentage closer filter) samples.
289    pub pcf_samples:    u32,
290    /// PCF kernel radius in texels.
291    pub pcf_radius:     f32,
292    /// Number of cascades for CSM (cascaded shadow maps).
293    pub cascade_count:  u32,
294    pub cascade_splits: Vec<f32>,
295}
296
297impl Default for ShadowMapConfig {
298    fn default() -> Self {
299        Self {
300            resolution:     2048,
301            bias:           0.005,
302            normal_bias:    0.01,
303            pcf_samples:    16,
304            pcf_radius:     1.5,
305            cascade_count:  3,
306            cascade_splits: vec![0.05, 0.15, 0.4, 1.0],
307        }
308    }
309}
310
311impl ShadowMapConfig {
312    pub fn high_quality() -> Self {
313        Self { resolution: 4096, pcf_samples: 32, pcf_radius: 2.0, ..Default::default() }
314    }
315
316    pub fn performance() -> Self {
317        Self { resolution: 1024, pcf_samples: 4, pcf_radius: 1.0, cascade_count: 1, cascade_splits: vec![1.0], ..Default::default() }
318    }
319}
320
321// ── LightProbe ────────────────────────────────────────────────────────────────
322
323/// Pre-sampled spherical environment light at a world-space position.
324///
325/// Stores 9 spherical harmonic coefficients for fast ambient lighting.
326#[derive(Debug, Clone)]
327pub struct LightProbe {
328    pub id:         LightId,
329    pub position:   Vec3,
330    pub radius:     f32,
331    /// L0 + L1 + L2 spherical harmonic coefficients (9 Vec3 values).
332    pub sh_coeffs:  [Vec3; 9],
333    pub weight:     f32,
334    pub enabled:    bool,
335}
336
337impl LightProbe {
338    pub fn new(position: Vec3, radius: f32) -> Self {
339        Self {
340            id: LightId::invalid(),
341            position,
342            radius,
343            sh_coeffs: [Vec3::ZERO; 9],
344            weight: 1.0,
345            enabled: true,
346        }
347    }
348
349    /// Evaluate the probe's ambient contribution for a given surface normal.
350    /// Uses first-order SH approximation (L0 + L1 only: 4 coefficients).
351    pub fn evaluate_sh(&self, normal: Vec3) -> Vec3 {
352        // L0 basis
353        let c0 = 0.282_095_f32;
354        // L1 basis
355        let c1 = 0.488_603_f32;
356        let sh0 = self.sh_coeffs[0] * c0;
357        let sh1 = self.sh_coeffs[1] * c1 * normal.y;
358        let sh2 = self.sh_coeffs[2] * c1 * normal.z;
359        let sh3 = self.sh_coeffs[3] * c1 * normal.x;
360        (sh0 + sh1 + sh2 + sh3).max(Vec3::ZERO) * self.weight
361    }
362
363    /// Encode a uniform color as SH coefficients.
364    pub fn from_uniform_color(position: Vec3, radius: f32, color: Vec3) -> Self {
365        let mut probe = Self::new(position, radius);
366        // L0 coefficient encodes average color
367        probe.sh_coeffs[0] = color * (1.0 / 0.282_095_f32);
368        probe
369    }
370
371    /// Set SH from a sky/ground hemisphere (fast approximation).
372    pub fn from_hemisphere(position: Vec3, radius: f32, sky: Vec3, ground: Vec3) -> Self {
373        let mut probe = Self::new(position, radius);
374        probe.sh_coeffs[0] = (sky + ground) * 0.5 * (1.0 / 0.282_095_f32);
375        probe.sh_coeffs[2] = (sky - ground) * (1.0 / 0.488_603_f32);
376        probe
377    }
378}
379
380// ── SSAO Config ───────────────────────────────────────────────────────────────
381
382/// Screen-space ambient occlusion parameters.
383#[derive(Debug, Clone)]
384pub struct SsaoConfig {
385    pub enabled:        bool,
386    pub sample_count:   u32,
387    pub radius:         f32,
388    pub bias:           f32,
389    /// Scale factor for SSAO intensity.
390    pub intensity:      f32,
391    /// Number of blur passes for noise reduction.
392    pub blur_passes:    u32,
393    pub blur_radius:    f32,
394    /// Render SSAO at this fraction of full resolution (e.g., 0.5 = half-res).
395    pub resolution_scale: f32,
396}
397
398impl Default for SsaoConfig {
399    fn default() -> Self {
400        Self {
401            enabled: true,
402            sample_count: 32,
403            radius: 0.5,
404            bias: 0.025,
405            intensity: 1.0,
406            blur_passes: 2,
407            blur_radius: 2.0,
408            resolution_scale: 0.5,
409        }
410    }
411}
412
413impl SsaoConfig {
414    pub fn high_quality() -> Self {
415        Self { sample_count: 64, blur_passes: 4, resolution_scale: 1.0, ..Default::default() }
416    }
417    pub fn performance() -> Self {
418        Self { sample_count: 8, blur_passes: 1, resolution_scale: 0.25, ..Default::default() }
419    }
420    pub fn disabled() -> Self { Self { enabled: false, ..Default::default() } }
421}
422
423// ── VolumetricConfig ──────────────────────────────────────────────────────────
424
425/// Volumetric light shaft / god ray parameters.
426#[derive(Debug, Clone)]
427pub struct VolumetricConfig {
428    pub enabled:       bool,
429    pub sample_count:  u32,
430    pub density:       f32,
431    pub scattering:    f32,
432    pub absorption:    f32,
433    /// How much the directional light contributes to volumetrics.
434    pub sun_intensity: f32,
435    /// Color of the fog/atmosphere.
436    pub fog_color:     Vec3,
437    pub fog_density:   f32,
438    /// Height at which fog dissipates (exponential height fog).
439    pub fog_height:    f32,
440    pub fog_falloff:   f32,
441    pub resolution_scale: f32,
442}
443
444impl Default for VolumetricConfig {
445    fn default() -> Self {
446        Self {
447            enabled: false,
448            sample_count: 64,
449            density: 0.05,
450            scattering: 0.5,
451            absorption: 0.02,
452            sun_intensity: 1.0,
453            fog_color: Vec3::new(0.8, 0.85, 1.0),
454            fog_density: 0.002,
455            fog_height: 50.0,
456            fog_falloff: 0.1,
457            resolution_scale: 0.5,
458        }
459    }
460}
461
462// ── TileGrid ──────────────────────────────────────────────────────────────────
463
464/// Tile descriptor for tile-based forward+ light culling.
465#[derive(Debug, Clone)]
466pub struct LightTile {
467    /// Indices into the LightManager's point_lights/spot_lights arrays.
468    pub point_light_indices: Vec<u32>,
469    pub spot_light_indices:  Vec<u32>,
470    /// Minimum and maximum depth values seen in this tile.
471    pub depth_min: f32,
472    pub depth_max: f32,
473}
474
475impl LightTile {
476    pub fn new() -> Self {
477        Self {
478            point_light_indices: Vec::new(),
479            spot_light_indices: Vec::new(),
480            depth_min: 0.0,
481            depth_max: 1.0,
482        }
483    }
484
485    pub fn total_lights(&self) -> usize {
486        self.point_light_indices.len() + self.spot_light_indices.len()
487    }
488}
489
490// ── LightCuller ───────────────────────────────────────────────────────────────
491
492/// Tile-based light culler: divides the screen into NxM tiles and
493/// assigns only the visible lights to each tile.
494pub struct LightCuller {
495    pub tile_size_x:    u32,
496    pub tile_size_y:    u32,
497    pub screen_width:   u32,
498    pub screen_height:  u32,
499    pub tiles:          Vec<LightTile>,
500    pub max_lights_per_tile: usize,
501}
502
503impl LightCuller {
504    pub fn new(screen_w: u32, screen_h: u32, tile_size: u32) -> Self {
505        let tx = (screen_w + tile_size - 1) / tile_size;
506        let ty = (screen_h + tile_size - 1) / tile_size;
507        let n  = (tx * ty) as usize;
508        Self {
509            tile_size_x: tile_size,
510            tile_size_y: tile_size,
511            screen_width: screen_w,
512            screen_height: screen_h,
513            tiles: (0..n).map(|_| LightTile::new()).collect(),
514            max_lights_per_tile: 256,
515        }
516    }
517
518    pub fn tile_count_x(&self) -> u32 { (self.screen_width  + self.tile_size_x - 1) / self.tile_size_x }
519    pub fn tile_count_y(&self) -> u32 { (self.screen_height + self.tile_size_y - 1) / self.tile_size_y }
520
521    pub fn tile_index(&self, tx: u32, ty: u32) -> usize {
522        (ty * self.tile_count_x() + tx) as usize
523    }
524
525    /// Cull point lights against all tiles using screen-space bounding circles.
526    pub fn cull_point_lights(
527        &mut self,
528        lights: &[PointLight],
529        view_proj: Mat4,
530    ) {
531        for tile in &mut self.tiles { tile.point_light_indices.clear(); }
532
533        for (i, light) in lights.iter().enumerate() {
534            if !light.enabled { continue; }
535
536            // Project light center to NDC
537            let clip = view_proj * light.position.extend(1.0);
538            if clip.w.abs() < 1e-6 { continue; }
539            let ndc = clip.truncate() / clip.w;
540
541            // Rough screen-space radius estimate
542            let screen_radius = {
543                let edge = view_proj * (light.position + Vec3::X * light.range).extend(1.0);
544                let edge_ndc = if edge.w.abs() > 1e-6 { (edge.truncate() / edge.w) } else { continue };
545                ((edge_ndc - ndc).length()).abs() * 0.5
546            };
547
548            // Find overlapping tiles
549            let sx = ((ndc.x * 0.5 + 0.5) * self.screen_width as f32) as i32;
550            let sy = ((ndc.y * 0.5 + 0.5) * self.screen_height as f32) as i32;
551            let sr = (screen_radius * self.screen_width as f32) as i32 + 1;
552
553            let tx_size = self.tile_size_x as i32;
554            let ty_size = self.tile_size_y as i32;
555            let tcx     = self.tile_count_x() as i32;
556            let tcy     = self.tile_count_y() as i32;
557
558            let tx_min = ((sx - sr) / tx_size).max(0);
559            let tx_max = ((sx + sr) / tx_size + 1).min(tcx);
560            let ty_min = ((sy - sr) / ty_size).max(0);
561            let ty_max = ((sy + sr) / ty_size + 1).min(tcy);
562
563            for ty in ty_min..ty_max {
564                for tx in tx_min..tx_max {
565                    let idx = self.tile_index(tx as u32, ty as u32);
566                    if idx < self.tiles.len() {
567                        let tile = &mut self.tiles[idx];
568                        if tile.point_light_indices.len() < self.max_lights_per_tile {
569                            tile.point_light_indices.push(i as u32);
570                        }
571                    }
572                }
573            }
574        }
575    }
576
577    pub fn resize(&mut self, screen_w: u32, screen_h: u32) {
578        self.screen_width  = screen_w;
579        self.screen_height = screen_h;
580        let tx = (screen_w  + self.tile_size_x - 1) / self.tile_size_x;
581        let ty = (screen_h  + self.tile_size_y - 1) / self.tile_size_y;
582        let n  = (tx * ty) as usize;
583        self.tiles = (0..n).map(|_| LightTile::new()).collect();
584    }
585}
586
587// ── EmissiveAccumulator ───────────────────────────────────────────────────────
588
589/// Accumulates auto-light-sources from bright/emissive glyphs.
590#[derive(Debug, Clone, Default)]
591pub struct EmissiveAccumulator {
592    pub sources: Vec<EmissiveSource>,
593    /// Emission threshold: glyphs above this value generate a point light.
594    pub threshold: f32,
595    pub max_sources: usize,
596}
597
598#[derive(Debug, Clone)]
599pub struct EmissiveSource {
600    pub position:  Vec3,
601    pub color:     Vec3,
602    pub emission:  f32,
603}
604
605impl EmissiveAccumulator {
606    pub fn new() -> Self {
607        Self { sources: Vec::new(), threshold: 0.5, max_sources: 64 }
608    }
609
610    pub fn push(&mut self, position: Vec3, color: Vec3, emission: f32) {
611        if emission < self.threshold { return; }
612        if self.sources.len() >= self.max_sources { return; }
613        self.sources.push(EmissiveSource { position, color, emission });
614    }
615
616    pub fn clear(&mut self) { self.sources.clear(); }
617
618    /// Convert accumulated emissive sources into PointLights.
619    pub fn to_point_lights(&self, intensity_scale: f32) -> Vec<PointLight> {
620        self.sources.iter().map(|s| {
621            PointLight::new(
622                s.position,
623                s.color,
624                s.emission * intensity_scale,
625                s.emission * 3.0,
626            )
627        }).collect()
628    }
629}
630
631// ── LightManager ─────────────────────────────────────────────────────────────
632
633/// Central light registry. Owns all lights and manages culling.
634pub struct LightManager {
635    pub point_lights:   Vec<PointLight>,
636    pub spot_lights:    Vec<SpotLight>,
637    pub directional:    Option<DirectionalLight>,
638    pub ambient:        AmbientLight,
639    pub probes:         Vec<LightProbe>,
640    pub ssao:           SsaoConfig,
641    pub volumetric:     VolumetricConfig,
642    pub emissive:       EmissiveAccumulator,
643    pub culler:         Option<LightCuller>,
644    next_id:            u32,
645    /// Temporary PointLights from emissive glyphs (rebuilt each frame).
646    emissive_lights:    Vec<PointLight>,
647}
648
649impl LightManager {
650    pub fn new() -> Self {
651        Self {
652            point_lights:    Vec::new(),
653            spot_lights:     Vec::new(),
654            directional:     None,
655            ambient:         AmbientLight::default(),
656            probes:          Vec::new(),
657            ssao:            SsaoConfig::default(),
658            volumetric:      VolumetricConfig::default(),
659            emissive:        EmissiveAccumulator::new(),
660            culler:          None,
661            next_id:         1,
662            emissive_lights: Vec::new(),
663        }
664    }
665
666    fn next_id(&mut self) -> LightId {
667        let id = LightId(self.next_id);
668        self.next_id += 1;
669        id
670    }
671
672    pub fn add_point_light(&mut self, mut light: PointLight) -> LightId {
673        let id  = self.next_id();
674        light.id = id;
675        self.point_lights.push(light);
676        id
677    }
678
679    pub fn add_spot_light(&mut self, mut light: SpotLight) -> LightId {
680        let id = self.next_id();
681        light.id = id;
682        self.spot_lights.push(light);
683        id
684    }
685
686    pub fn set_directional(&mut self, mut light: DirectionalLight) -> LightId {
687        let id = self.next_id();
688        light.id = id;
689        self.directional = Some(light);
690        id
691    }
692
693    pub fn add_probe(&mut self, mut probe: LightProbe) -> LightId {
694        let id = self.next_id();
695        probe.id = id;
696        self.probes.push(probe);
697        id
698    }
699
700    pub fn remove(&mut self, id: LightId) {
701        self.point_lights.retain(|l| l.id != id);
702        self.spot_lights.retain(|l| l.id != id);
703        self.probes.retain(|p| p.id != id);
704        if self.directional.as_ref().map(|d| d.id) == Some(id) {
705            self.directional = None;
706        }
707    }
708
709    pub fn get_point_light_mut(&mut self, id: LightId) -> Option<&mut PointLight> {
710        self.point_lights.iter_mut().find(|l| l.id == id)
711    }
712
713    pub fn get_spot_light_mut(&mut self, id: LightId) -> Option<&mut SpotLight> {
714        self.spot_lights.iter_mut().find(|l| l.id == id)
715    }
716
717    /// Set up the tile culler for a given screen size.
718    pub fn init_culler(&mut self, screen_w: u32, screen_h: u32) {
719        self.culler = Some(LightCuller::new(screen_w, screen_h, 16));
720    }
721
722    /// Update emissive auto-lights from this frame's accumulator.
723    pub fn flush_emissive(&mut self, intensity_scale: f32) {
724        self.emissive_lights = self.emissive.to_point_lights(intensity_scale);
725        self.emissive.clear();
726    }
727
728    /// Run light culling. Call once per frame after updating light positions.
729    pub fn cull(&mut self, view_proj: Mat4) {
730        if let Some(ref mut culler) = self.culler {
731            let all_points: Vec<PointLight> = self.point_lights.iter()
732                .chain(self.emissive_lights.iter())
733                .cloned()
734                .collect();
735            culler.cull_point_lights(&all_points, view_proj);
736        }
737    }
738
739    /// Total active light count.
740    pub fn light_count(&self) -> usize {
741        self.point_lights.len()
742            + self.spot_lights.len()
743            + if self.directional.is_some() { 1 } else { 0 }
744    }
745
746    /// Evaluate the total light contribution at a world-space point with normal.
747    /// Used for CPU-side lighting (debug, probes, etc.).
748    pub fn evaluate_cpu(&self, p: Vec3, n: Vec3) -> Vec3 {
749        let mut color = self.ambient.evaluate(n);
750
751        if let Some(ref dir) = self.directional {
752            color += dir.contribution(n);
753        }
754        for light in &self.point_lights {
755            color += light.contribution(p, n);
756        }
757        for light in &self.spot_lights {
758            color += light.contribution(p, n);
759        }
760        // Add probe contributions
761        let mut total_probe_weight = 0.0_f32;
762        let mut probe_color = Vec3::ZERO;
763        for probe in &self.probes {
764            if !probe.enabled { continue; }
765            let dist = (probe.position - p).length();
766            if dist > probe.radius { continue; }
767            let w = (1.0 - dist / probe.radius).clamp(0.0, 1.0) * probe.weight;
768            probe_color += probe.evaluate_sh(n) * w;
769            total_probe_weight += w;
770        }
771        if total_probe_weight > 1e-4 {
772            color += probe_color / total_probe_weight;
773        }
774        color
775    }
776
777    /// Remove all lights with a given tag.
778    pub fn remove_by_tag(&mut self, tag: &str) {
779        self.point_lights.retain(|l| l.tag.as_deref() != Some(tag));
780        self.spot_lights.retain(|l| l.tag.as_deref() != Some(tag));
781    }
782
783    /// Enable/disable all lights with a given tag.
784    pub fn set_enabled_by_tag(&mut self, tag: &str, enabled: bool) {
785        for l in &mut self.point_lights {
786            if l.tag.as_deref() == Some(tag) { l.enabled = enabled; }
787        }
788        for l in &mut self.spot_lights {
789            if l.tag.as_deref() == Some(tag) { l.enabled = enabled; }
790        }
791    }
792
793    /// Scale the intensity of all lights by a factor (e.g., day/night cycle).
794    pub fn scale_intensity(&mut self, factor: f32) {
795        for l in &mut self.point_lights { l.intensity *= factor; }
796        for l in &mut self.spot_lights  { l.intensity *= factor; }
797        if let Some(ref mut d) = self.directional { d.intensity *= factor; }
798    }
799}
800
801impl Default for LightManager {
802    fn default() -> Self { Self::new() }
803}
804
805// ── Presets ───────────────────────────────────────────────────────────────────
806
807impl LightManager {
808    /// Bright daylight setup: sun + sky ambient.
809    pub fn preset_daylight() -> Self {
810        let mut mgr = Self::new();
811        mgr.set_directional(DirectionalLight::sun(
812            Vec3::new(-0.3, -0.9, -0.3),
813            Vec3::new(1.0, 0.95, 0.85),
814            3.0,
815        ));
816        mgr.ambient = AmbientLight::hemisphere(
817            Vec3::new(0.5, 0.65, 0.9),
818            Vec3::new(0.2, 0.2, 0.15),
819            0.4,
820        );
821        mgr
822    }
823
824    /// Low ambient dungeon lighting.
825    pub fn preset_dungeon() -> Self {
826        let mut mgr = Self::new();
827        mgr.ambient = AmbientLight::uniform(Vec3::new(0.03, 0.03, 0.05), 0.1);
828        mgr
829    }
830
831    /// Void / deep space: only emissive sources, no ambient.
832    pub fn preset_void() -> Self {
833        let mut mgr = Self::new();
834        mgr.ambient = AmbientLight::uniform(Vec3::ZERO, 0.0);
835        mgr
836    }
837
838    /// Combat arena: red-tinted overhead fill + rim lights.
839    pub fn preset_combat_arena(center: Vec3) -> Self {
840        let mut mgr = Self::preset_dungeon();
841        mgr.add_point_light(
842            PointLight::new(center + Vec3::new(0.0, 8.0, 0.0), Vec3::new(1.0, 0.2, 0.1), 4.0, 20.0)
843                .with_tag("arena"),
844        );
845        mgr.add_point_light(
846            PointLight::new(center + Vec3::new(5.0, 3.0, 0.0), Vec3::new(0.3, 0.3, 1.0), 2.0, 12.0)
847                .with_tag("arena"),
848        );
849        mgr.add_point_light(
850            PointLight::new(center + Vec3::new(-5.0, 3.0, 0.0), Vec3::new(0.3, 0.3, 1.0), 2.0, 12.0)
851                .with_tag("arena"),
852        );
853        mgr
854    }
855
856    /// Warm interior room lighting.
857    pub fn preset_interior(center: Vec3) -> Self {
858        let mut mgr = Self::new();
859        mgr.ambient = AmbientLight::hemisphere(
860            Vec3::new(0.9, 0.85, 0.7),
861            Vec3::new(0.3, 0.25, 0.2),
862            0.3,
863        );
864        mgr.add_point_light(
865            PointLight::new(center + Vec3::new(0.0, 3.0, 0.0), Vec3::new(1.0, 0.9, 0.7), 5.0, 10.0)
866                .with_shadow()
867                .with_tag("ceiling"),
868        );
869        mgr
870    }
871
872    /// Moonlit outdoor scene.
873    pub fn preset_moonlight() -> Self {
874        let mut mgr = Self::new();
875        mgr.set_directional(DirectionalLight::sun(
876            Vec3::new(-0.2, -0.8, -0.5),
877            Vec3::new(0.6, 0.65, 0.9),
878            0.8,
879        ));
880        mgr.ambient = AmbientLight::hemisphere(
881            Vec3::new(0.05, 0.06, 0.15),
882            Vec3::new(0.02, 0.02, 0.04),
883            0.2,
884        );
885        mgr
886    }
887
888    /// Neon-lit cyberpunk street scene.
889    pub fn preset_neon(center: Vec3) -> Self {
890        let mut mgr = Self::new();
891        mgr.ambient = AmbientLight::uniform(Vec3::new(0.02, 0.01, 0.04), 0.15);
892        let neons = [
893            (Vec3::new(1.0, 0.1, 0.8), Vec3::new(-4.0, 2.0, 0.0)),
894            (Vec3::new(0.1, 0.9, 1.0), Vec3::new(4.0, 2.0, 0.0)),
895            (Vec3::new(1.0, 0.8, 0.0), Vec3::new(0.0, 2.0, 4.0)),
896            (Vec3::new(0.2, 1.0, 0.3), Vec3::new(0.0, 2.0, -4.0)),
897        ];
898        for (color, offset) in neons {
899            mgr.add_point_light(
900                PointLight::new(center + offset, color, 3.0, 8.0).with_tag("neon"),
901            );
902        }
903        mgr
904    }
905
906    /// Underground cavern with bioluminescent blue ambient.
907    pub fn preset_cavern() -> Self {
908        let mut mgr = Self::new();
909        mgr.ambient = AmbientLight::uniform(Vec3::new(0.0, 0.05, 0.15), 0.2);
910        mgr
911    }
912}
913
914// ── PBR Material ──────────────────────────────────────────────────────────────
915
916/// Physical material parameters for PBR lighting.
917#[derive(Debug, Clone)]
918pub struct PbrMaterial {
919    /// Base color / albedo (linear sRGB).
920    pub albedo:           Vec3,
921    /// Alpha channel (0 = fully transparent, 1 = opaque).
922    pub alpha:            f32,
923    /// Metallic factor [0,1]: 0 = dielectric, 1 = conductor.
924    pub metallic:         f32,
925    /// Roughness factor [0,1]: 0 = mirror, 1 = fully diffuse.
926    pub roughness:        f32,
927    /// Ambient occlusion factor [0,1].
928    pub ao:               f32,
929    /// Emissive color (added on top of lighting).
930    pub emissive:         Vec3,
931    /// Index of refraction (used for Fresnel, default 1.5 for dielectrics).
932    pub ior:              f32,
933    /// Anisotropy amount [-1,1]: positive = horizontal highlight stretch.
934    pub anisotropy:       f32,
935    /// Anisotropy tangent direction.
936    pub anisotropy_dir:   Vec3,
937    /// Clear-coat layer intensity [0,1].
938    pub clearcoat:        f32,
939    /// Clear-coat roughness [0,1].
940    pub clearcoat_rough:  f32,
941    /// Subsurface scattering color.
942    pub sss_color:        Vec3,
943    /// Subsurface scattering radius.
944    pub sss_radius:       f32,
945}
946
947impl PbrMaterial {
948    pub fn dielectric(albedo: Vec3, roughness: f32) -> Self {
949        Self {
950            albedo,
951            alpha: 1.0,
952            metallic: 0.0,
953            roughness: roughness.clamp(0.04, 1.0),
954            ao: 1.0,
955            emissive: Vec3::ZERO,
956            ior: 1.5,
957            anisotropy: 0.0,
958            anisotropy_dir: Vec3::X,
959            clearcoat: 0.0,
960            clearcoat_rough: 0.0,
961            sss_color: Vec3::ZERO,
962            sss_radius: 0.0,
963        }
964    }
965
966    pub fn metal(albedo: Vec3, roughness: f32) -> Self {
967        Self { metallic: 1.0, ..Self::dielectric(albedo, roughness) }
968    }
969
970    pub fn emissive_mat(albedo: Vec3, emissive: Vec3) -> Self {
971        Self { emissive, ..Self::dielectric(albedo, 0.5) }
972    }
973
974    pub fn glass(ior: f32, roughness: f32) -> Self {
975        Self {
976            albedo: Vec3::ONE,
977            alpha: 0.02,
978            ior,
979            roughness,
980            metallic: 0.0,
981            ao: 1.0,
982            emissive: Vec3::ZERO,
983            anisotropy: 0.0,
984            anisotropy_dir: Vec3::X,
985            clearcoat: 0.0,
986            clearcoat_rough: 0.0,
987            sss_color: Vec3::ZERO,
988            sss_radius: 0.0,
989        }
990    }
991
992    /// F0 (reflectance at normal incidence) for this material.
993    pub fn f0(&self) -> Vec3 {
994        let f0_dielectric = Vec3::splat(((self.ior - 1.0) / (self.ior + 1.0)).powi(2));
995        f0_dielectric.lerp(self.albedo, self.metallic)
996    }
997}
998
999impl Default for PbrMaterial {
1000    fn default() -> Self {
1001        Self::dielectric(Vec3::new(0.8, 0.8, 0.8), 0.5)
1002    }
1003}
1004
1005// ── PBR Lighting Model ────────────────────────────────────────────────────────
1006
1007/// Cook-Torrance PBR BRDF evaluated on the CPU.
1008///
1009/// All values are in linear light-space. Apply sRGB gamma after.
1010pub struct PbrLighting;
1011
1012impl PbrLighting {
1013    /// Schlick Fresnel approximation.
1014    #[inline]
1015    pub fn fresnel_schlick(cos_theta: f32, f0: Vec3) -> Vec3 {
1016        f0 + (Vec3::ONE - f0) * (1.0 - cos_theta).max(0.0).powi(5)
1017    }
1018
1019    /// Schlick-GGX geometry function (one direction).
1020    #[inline]
1021    pub fn geometry_schlick_ggx(n_dot_v: f32, roughness: f32) -> f32 {
1022        let r = roughness + 1.0;
1023        let k = (r * r) / 8.0;
1024        n_dot_v / (n_dot_v * (1.0 - k) + k)
1025    }
1026
1027    /// Smith geometry function (both view and light).
1028    #[inline]
1029    pub fn geometry_smith(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
1030        Self::geometry_schlick_ggx(n_dot_v, roughness)
1031            * Self::geometry_schlick_ggx(n_dot_l, roughness)
1032    }
1033
1034    /// GGX normal distribution function.
1035    #[inline]
1036    pub fn ndf_ggx(n_dot_h: f32, roughness: f32) -> f32 {
1037        let a  = roughness * roughness;
1038        let a2 = a * a;
1039        let n_dot_h2 = n_dot_h * n_dot_h;
1040        let denom = n_dot_h2 * (a2 - 1.0) + 1.0;
1041        a2 / (std::f32::consts::PI * denom * denom + 1e-7)
1042    }
1043
1044    /// Full Cook-Torrance BRDF for a single punctual light.
1045    pub fn brdf(
1046        normal:   Vec3,
1047        view_dir: Vec3,
1048        light_dir: Vec3,
1049        mat:      &PbrMaterial,
1050    ) -> Vec3 {
1051        let n_dot_l = normal.dot(light_dir).max(0.0);
1052        let n_dot_v = normal.dot(view_dir).max(1e-7);
1053        if n_dot_l < 1e-7 { return Vec3::ZERO; }
1054
1055        let h = (view_dir + light_dir).normalize_or_zero();
1056        let n_dot_h = normal.dot(h).clamp(0.0, 1.0);
1057        let h_dot_v = h.dot(view_dir).clamp(0.0, 1.0);
1058
1059        let f0 = mat.f0();
1060        let f  = Self::fresnel_schlick(h_dot_v, f0);
1061        let d  = Self::ndf_ggx(n_dot_h, mat.roughness.max(0.04));
1062        let g  = Self::geometry_smith(n_dot_v, n_dot_l, mat.roughness.max(0.04));
1063
1064        let specular = (d * g * f) / (4.0 * n_dot_v * n_dot_l + 1e-7);
1065
1066        // Diffuse: lambertian, attenuated by metallic
1067        let k_s = f;
1068        let k_d = (Vec3::ONE - k_s) * (1.0 - mat.metallic);
1069        let diffuse = k_d * mat.albedo / std::f32::consts::PI;
1070
1071        (diffuse + specular) * n_dot_l
1072    }
1073
1074    /// Evaluate the full PBR lighting equation at a surface point.
1075    pub fn shade(
1076        position:  Vec3,
1077        normal:    Vec3,
1078        view_pos:  Vec3,
1079        mat:       &PbrMaterial,
1080        manager:   &LightManager,
1081    ) -> Vec3 {
1082        let view_dir = (view_pos - position).normalize_or_zero();
1083        let mut lo = Vec3::ZERO;
1084
1085        // Directional light
1086        if let Some(ref dir_light) = manager.directional {
1087            if dir_light.enabled {
1088                let light_dir = (-dir_light.direction).normalize_or_zero();
1089                let radiance  = dir_light.color * dir_light.intensity;
1090                lo += Self::brdf(normal, view_dir, light_dir, mat) * radiance;
1091            }
1092        }
1093
1094        // Point lights
1095        for light in &manager.point_lights {
1096            if !light.enabled { continue; }
1097            let to_light = light.position - position;
1098            let dist     = to_light.length();
1099            if dist >= light.range { continue; }
1100            let light_dir = to_light / dist.max(1e-7);
1101            let att       = light.attenuation.evaluate(dist, light.range);
1102            let radiance  = light.color * light.intensity * att;
1103            lo += Self::brdf(normal, view_dir, light_dir, mat) * radiance;
1104        }
1105
1106        // Spot lights
1107        for light in &manager.spot_lights {
1108            if !light.enabled { continue; }
1109            let to_light = light.position - position;
1110            let dist     = to_light.length();
1111            if dist >= light.range { continue; }
1112            let light_dir  = to_light / dist.max(1e-7);
1113            let dist_atten = light.attenuation.evaluate(dist, light.range);
1114            let cone_atten = light.cone_attenuation(light_dir);
1115            let radiance   = light.color * light.intensity * dist_atten * cone_atten;
1116            lo += Self::brdf(normal, view_dir, light_dir, mat) * radiance;
1117        }
1118
1119        // Ambient (probe or hemisphere)
1120        let ambient = {
1121            let mut best_probe_w   = 0.0_f32;
1122            let mut best_probe_col = Vec3::ZERO;
1123            for probe in &manager.probes {
1124                if !probe.enabled { continue; }
1125                let dist = (probe.position - position).length();
1126                if dist > probe.radius { continue; }
1127                let w = (1.0 - dist / probe.radius).clamp(0.0, 1.0) * probe.weight;
1128                best_probe_col += probe.evaluate_sh(normal) * w;
1129                best_probe_w   += w;
1130            }
1131            if best_probe_w > 1e-4 {
1132                best_probe_col / best_probe_w * mat.albedo * mat.ao
1133            } else {
1134                manager.ambient.evaluate(normal) * mat.albedo * mat.ao
1135            }
1136        };
1137
1138        lo + ambient + mat.emissive
1139    }
1140
1141    /// Evaluate subsurface scattering contribution (simple wrapping model).
1142    pub fn shade_sss(
1143        position:  Vec3,
1144        normal:    Vec3,
1145        view_pos:  Vec3,
1146        mat:       &PbrMaterial,
1147        manager:   &LightManager,
1148    ) -> Vec3 {
1149        if mat.sss_radius < 1e-4 { return Vec3::ZERO; }
1150        let mut sss = Vec3::ZERO;
1151        let _view_dir = (view_pos - position).normalize_or_zero();
1152        // Wrap lighting model for SSS: light contribution with bent normal
1153        for light in &manager.point_lights {
1154            if !light.enabled { continue; }
1155            let to_light = light.position - position;
1156            let dist     = to_light.length();
1157            if dist >= light.range { continue; }
1158            let light_dir = to_light / dist.max(1e-7);
1159            let att       = light.attenuation.evaluate(dist, light.range);
1160            // Wrap: allow light from behind the surface
1161            let wrap = (normal.dot(light_dir) + mat.sss_radius) / (1.0 + mat.sss_radius);
1162            let wrap = wrap.max(0.0);
1163            sss += mat.sss_color * light.color * light.intensity * att * wrap;
1164        }
1165        sss
1166    }
1167}
1168
1169// ── Area Lights ───────────────────────────────────────────────────────────────
1170
1171/// Rectangular area light for soft illumination.
1172#[derive(Debug, Clone)]
1173pub struct RectLight {
1174    pub id:        LightId,
1175    pub position:  Vec3,
1176    /// Right vector (half-width extent).
1177    pub right:     Vec3,
1178    /// Up vector (half-height extent).
1179    pub up:        Vec3,
1180    pub color:     Vec3,
1181    pub intensity: f32,
1182    pub two_sided: bool,
1183    pub enabled:   bool,
1184    pub tag:       Option<String>,
1185}
1186
1187impl RectLight {
1188    pub fn new(position: Vec3, right: Vec3, up: Vec3, color: Vec3, intensity: f32) -> Self {
1189        Self {
1190            id: LightId::invalid(),
1191            position,
1192            right,
1193            up,
1194            color,
1195            intensity,
1196            two_sided: false,
1197            enabled: true,
1198            tag: None,
1199        }
1200    }
1201
1202    pub fn width(&self) -> f32  { self.right.length() * 2.0 }
1203    pub fn height(&self) -> f32 { self.up.length()    * 2.0 }
1204    pub fn area(&self)  -> f32  { self.width() * self.height() }
1205    pub fn normal(&self) -> Vec3 { self.right.normalize_or_zero().cross(self.up.normalize_or_zero()).normalize_or_zero() }
1206
1207    /// Approximate point on the rect closest to `p` for a simple irradiance estimate.
1208    pub fn nearest_point(&self, p: Vec3) -> Vec3 {
1209        let local    = p - self.position;
1210        let r_hat    = self.right.normalize_or_zero();
1211        let u_hat    = self.up.normalize_or_zero();
1212        let r_half   = self.right.length();
1213        let u_half   = self.up.length();
1214        let r_proj   = local.dot(r_hat).clamp(-r_half, r_half);
1215        let u_proj   = local.dot(u_hat).clamp(-u_half, u_half);
1216        self.position + r_hat * r_proj + u_hat * u_proj
1217    }
1218
1219    /// CPU irradiance estimate using representative point technique.
1220    pub fn irradiance_at(&self, p: Vec3, n: Vec3) -> Vec3 {
1221        if !self.enabled { return Vec3::ZERO; }
1222        let nearest   = self.nearest_point(p);
1223        let to_light  = nearest - p;
1224        let dist      = to_light.length().max(1e-4);
1225        let light_dir = to_light / dist;
1226        let n_dot_l   = n.dot(light_dir).max(0.0);
1227        let front_ok  = if self.two_sided {
1228            true
1229        } else {
1230            self.normal().dot(-light_dir) >= 0.0
1231        };
1232        if !front_ok { return Vec3::ZERO; }
1233        // Area light inverse square with area solid angle approximation
1234        let solid_angle = (self.area() / (dist * dist)).min(1.0);
1235        self.color * self.intensity * n_dot_l * solid_angle
1236    }
1237}
1238
1239/// Disk (circular) area light.
1240#[derive(Debug, Clone)]
1241pub struct DiskLight {
1242    pub id:        LightId,
1243    pub position:  Vec3,
1244    pub normal:    Vec3,
1245    pub radius:    f32,
1246    pub color:     Vec3,
1247    pub intensity: f32,
1248    pub two_sided: bool,
1249    pub enabled:   bool,
1250    pub tag:       Option<String>,
1251}
1252
1253impl DiskLight {
1254    pub fn new(position: Vec3, normal: Vec3, radius: f32, color: Vec3, intensity: f32) -> Self {
1255        Self {
1256            id: LightId::invalid(),
1257            position,
1258            normal: normal.normalize_or_zero(),
1259            radius,
1260            color,
1261            intensity,
1262            two_sided: false,
1263            enabled: true,
1264            tag: None,
1265        }
1266    }
1267
1268    pub fn area(&self) -> f32 { std::f32::consts::PI * self.radius * self.radius }
1269
1270    pub fn irradiance_at(&self, p: Vec3, n: Vec3) -> Vec3 {
1271        if !self.enabled { return Vec3::ZERO; }
1272        let to_light  = self.position - p;
1273        let dist      = to_light.length().max(1e-4);
1274        let light_dir = to_light / dist;
1275        let n_dot_l   = n.dot(light_dir).max(0.0);
1276        let solid_angle = (self.area() / (dist * dist)).min(1.0);
1277        self.color * self.intensity * n_dot_l * solid_angle
1278    }
1279}
1280
1281// ── Animated Lights ───────────────────────────────────────────────────────────
1282
1283/// How a light's intensity varies over time.
1284#[derive(Debug, Clone)]
1285pub enum LightAnimation {
1286    /// Constant: no variation.
1287    Constant,
1288    /// Sine wave: intensity oscillates at frequency Hz.
1289    Pulse { frequency: f32, min_intensity: f32, max_intensity: f32 },
1290    /// Perlin-noise flicker (torch-like).
1291    Flicker { speed: f32, depth: f32 },
1292    /// Strobe: alternates fully on/off at frequency Hz.
1293    Strobe { frequency: f32 },
1294    /// Fade from start to end over duration seconds.
1295    Fade { start: f32, end: f32, duration: f32 },
1296    /// Driven by a MathFunction: maps f(t) → [0,1] → intensity.
1297    Math { func: MathFunction, base_intensity: f32, amplitude: f32 },
1298    /// Color-shifting animation: cycles through hue over time.
1299    ColorCycle { speed: f32, saturation: f32, value: f32 },
1300    /// Heartbeat: two fast pulses per beat.
1301    Heartbeat { bpm: f32, base_intensity: f32 },
1302}
1303
1304impl LightAnimation {
1305    /// Evaluate intensity multiplier at time `t` in seconds.
1306    pub fn intensity_factor(&self, t: f32, id_seed: u32) -> f32 {
1307        let seed_offset = (id_seed as f32) * 0.317_f32;
1308        match self {
1309            Self::Constant => 1.0,
1310            Self::Pulse { frequency, min_intensity, max_intensity } => {
1311                let s = (t * frequency * std::f32::consts::TAU).sin() * 0.5 + 0.5;
1312                min_intensity + (max_intensity - min_intensity) * s
1313            }
1314            Self::Flicker { speed, depth } => {
1315                // Pseudo-random noise using sin-based hash
1316                let n1 = (t * speed + seed_offset).sin() * 43758.5453;
1317                let n2 = (t * speed * 1.7 + seed_offset * 2.1).sin() * 23421.631;
1318                let noise = (n1.fract() + n2.fract()) * 0.5;
1319                1.0 - depth * noise.abs()
1320            }
1321            Self::Strobe { frequency } => {
1322                let phase = (t * frequency).fract();
1323                if phase < 0.5 { 1.0 } else { 0.0 }
1324            }
1325            Self::Fade { start, end, duration } => {
1326                let progress = (t / duration.max(1e-4)).clamp(0.0, 1.0);
1327                start + (end - start) * progress
1328            }
1329            Self::Math { func, base_intensity, amplitude } => {
1330                let v = func.evaluate(t, 0.0).clamp(-1.0, 1.0);
1331                (base_intensity + amplitude * v).max(0.0)
1332            }
1333            Self::ColorCycle { .. } => 1.0,  // intensity unchanged, color handled separately
1334            Self::Heartbeat { bpm, base_intensity } => {
1335                let beat_t = (t * bpm / 60.0).fract();
1336                let pulse1 = (-((beat_t - 0.05) / 0.03).powi(2) * 8.0).exp();
1337                let pulse2 = (-((beat_t - 0.20) / 0.03).powi(2) * 8.0).exp();
1338                base_intensity + (pulse1 + pulse2 * 0.6) * (1.0 - base_intensity)
1339            }
1340        }
1341    }
1342
1343    /// Evaluate color at time `t`. Returns Some(color) only for color-animating modes.
1344    pub fn color_at(&self, t: f32, base_color: Vec3) -> Vec3 {
1345        match self {
1346            Self::ColorCycle { speed, saturation, value } => {
1347                let hue = (t * speed).fract();
1348                // HSV to RGB
1349                let h6 = hue * 6.0;
1350                let hi = h6 as u32;
1351                let f  = h6.fract();
1352                let p  = value * (1.0 - saturation);
1353                let q  = value * (1.0 - saturation * f);
1354                let tv = value * (1.0 - saturation * (1.0 - f));
1355                let (r, g, b) = match hi % 6 {
1356                    0 => (*value, tv, p),
1357                    1 => (q, *value, p),
1358                    2 => (p, *value, tv),
1359                    3 => (p, q, *value),
1360                    4 => (tv, p, *value),
1361                    _ => (*value, p, q),
1362                };
1363                Vec3::new(r, g, b)
1364            }
1365            _ => base_color,
1366        }
1367    }
1368}
1369
1370/// A point light with a live animation.
1371#[derive(Debug, Clone)]
1372pub struct AnimatedPointLight {
1373    pub light:     PointLight,
1374    pub animation: LightAnimation,
1375    /// Base intensity (before animation scaling).
1376    pub base_intensity: f32,
1377    /// Base color (before animation hue shift).
1378    pub base_color: Vec3,
1379}
1380
1381impl AnimatedPointLight {
1382    pub fn new(light: PointLight, animation: LightAnimation) -> Self {
1383        let base_intensity = light.intensity;
1384        let base_color     = light.color;
1385        Self { light, animation, base_intensity, base_color }
1386    }
1387
1388    pub fn update(&mut self, dt: f32, time: f32) {
1389        let factor = self.animation.intensity_factor(time, self.light.id.0);
1390        self.light.intensity = self.base_intensity * factor;
1391        self.light.color     = self.animation.color_at(time, self.base_color);
1392        let _ = dt;
1393    }
1394}
1395
1396/// A spot light with a live animation.
1397#[derive(Debug, Clone)]
1398pub struct AnimatedSpotLight {
1399    pub light:          SpotLight,
1400    pub animation:      LightAnimation,
1401    pub base_intensity: f32,
1402    pub base_color:     Vec3,
1403    /// Optional orbit: the spot light rotates around its position.
1404    pub orbit_speed:    Option<f32>,
1405    pub orbit_axis:     Vec3,
1406    orbit_angle:        f32,
1407    base_direction:     Vec3,
1408}
1409
1410impl AnimatedSpotLight {
1411    pub fn new(light: SpotLight, animation: LightAnimation) -> Self {
1412        let base_intensity = light.intensity;
1413        let base_color     = light.color;
1414        let base_direction = light.direction;
1415        Self {
1416            light,
1417            animation,
1418            base_intensity,
1419            base_color,
1420            orbit_speed: None,
1421            orbit_axis: Vec3::Y,
1422            orbit_angle: 0.0,
1423            base_direction,
1424        }
1425    }
1426
1427    pub fn with_orbit(mut self, speed_rps: f32, axis: Vec3) -> Self {
1428        self.orbit_speed = Some(speed_rps);
1429        self.orbit_axis  = axis.normalize_or_zero();
1430        self
1431    }
1432
1433    pub fn update(&mut self, dt: f32, time: f32) {
1434        let factor = self.animation.intensity_factor(time, self.light.id.0);
1435        self.light.intensity = self.base_intensity * factor;
1436        self.light.color     = self.animation.color_at(time, self.base_color);
1437
1438        if let Some(speed) = self.orbit_speed {
1439            self.orbit_angle += speed * dt * std::f32::consts::TAU;
1440            let cos_a = self.orbit_angle.cos();
1441            let sin_a = self.orbit_angle.sin();
1442            let axis  = self.orbit_axis;
1443            // Rodrigues rotation
1444            let d = self.base_direction;
1445            self.light.direction = d * cos_a
1446                + axis.cross(d) * sin_a
1447                + axis * axis.dot(d) * (1.0 - cos_a);
1448        }
1449    }
1450}
1451
1452// ── IES Light Profiles ────────────────────────────────────────────────────────
1453
1454/// A light intensity profile loaded from an IES (Illuminating Engineering Society) file.
1455///
1456/// Stores a 2D lookup table of candela values by vertical/horizontal angle.
1457#[derive(Debug, Clone)]
1458pub struct IesProfile {
1459    pub name:             String,
1460    /// Vertical angles in degrees [0°, 180°].
1461    pub vertical_angles:  Vec<f32>,
1462    /// Horizontal angles in degrees [0°, 360°].
1463    pub horizontal_angles: Vec<f32>,
1464    /// Candela data: [horizontal][vertical] indexing.
1465    pub candela:          Vec<Vec<f32>>,
1466    /// Maximum candela value for normalization.
1467    pub max_candela:      f32,
1468}
1469
1470impl IesProfile {
1471    /// Create a fake IES profile (uniform sphere — equivalent to a point light).
1472    pub fn uniform(name: impl Into<String>) -> Self {
1473        let v_angles = (0..=18).map(|i| i as f32 * 10.0).collect::<Vec<_>>();
1474        let h_angles = vec![0.0, 90.0, 180.0, 270.0, 360.0];
1475        let n_v = v_angles.len();
1476        let n_h = h_angles.len();
1477        let candela = vec![vec![1.0; n_v]; n_h];
1478        Self {
1479            name: name.into(),
1480            vertical_angles: v_angles,
1481            horizontal_angles: h_angles,
1482            candela,
1483            max_candela: 1.0,
1484        }
1485    }
1486
1487    /// Create a downward-biased profile (like a recessed ceiling fixture).
1488    pub fn downlight(name: impl Into<String>) -> Self {
1489        let v_angles = (0..=18).map(|i| i as f32 * 10.0).collect::<Vec<_>>();
1490        let h_angles = vec![0.0, 360.0];
1491        let n_v = v_angles.len();
1492        let n_h = h_angles.len();
1493        // Intensity falls off from 0° (straight down) to 90° (horizontal) and zero beyond
1494        let candela = (0..n_h).map(|_| {
1495            v_angles.iter().map(|&angle| {
1496                let t = (angle / 90.0).min(1.0);
1497                (1.0 - t * t).max(0.0)
1498            }).collect::<Vec<_>>()
1499        }).collect::<Vec<_>>();
1500        Self {
1501            name: name.into(),
1502            vertical_angles: v_angles,
1503            horizontal_angles: h_angles,
1504            candela,
1505            max_candela: 1.0,
1506        }
1507    }
1508
1509    /// Sample the profile at the given vertical and horizontal angles (degrees).
1510    pub fn sample(&self, v_angle: f32, h_angle: f32) -> f32 {
1511        let v_angle = v_angle.clamp(0.0, 180.0);
1512        let h_angle = h_angle.rem_euclid(360.0);
1513
1514        // Find surrounding vertical indices
1515        let vi = self.vertical_angles.partition_point(|&a| a <= v_angle).min(self.vertical_angles.len() - 1);
1516        let vi0 = vi.saturating_sub(1);
1517        let vi1 = vi;
1518        let vt = if vi0 == vi1 { 0.0 } else {
1519            (v_angle - self.vertical_angles[vi0]) / (self.vertical_angles[vi1] - self.vertical_angles[vi0] + 1e-7)
1520        };
1521
1522        // Find surrounding horizontal indices
1523        let hi = self.horizontal_angles.partition_point(|&a| a <= h_angle).min(self.horizontal_angles.len() - 1);
1524        let hi0 = hi.saturating_sub(1);
1525        let hi1 = hi % self.horizontal_angles.len();
1526
1527        // Bilinear interpolation
1528        let row0 = &self.candela[hi0];
1529        let row1 = &self.candela[hi1];
1530        let c00 = row0.get(vi0).copied().unwrap_or(0.0);
1531        let c01 = row0.get(vi1).copied().unwrap_or(0.0);
1532        let c10 = row1.get(vi0).copied().unwrap_or(0.0);
1533        let c11 = row1.get(vi1).copied().unwrap_or(0.0);
1534        let ht  = if hi0 == hi1 { 0.0 } else {
1535            (h_angle - self.horizontal_angles[hi0]) / (self.horizontal_angles[hi1] - self.horizontal_angles[hi0] + 1e-7)
1536        };
1537        let c0 = c00 + (c01 - c00) * vt;
1538        let c1 = c10 + (c11 - c10) * vt;
1539        (c0 + (c1 - c0) * ht) / self.max_candela.max(1e-7)
1540    }
1541
1542    /// Evaluate the profile factor for a light direction relative to the fixture.
1543    /// `light_dir` is the direction FROM the light TO the surface.
1544    pub fn evaluate_direction(&self, light_dir: Vec3, fixture_down: Vec3) -> f32 {
1545        let cos_v = fixture_down.dot(light_dir).clamp(-1.0, 1.0);
1546        let v_angle = cos_v.acos().to_degrees();
1547        self.sample(v_angle, 0.0)  // simplified: ignore horizontal angle
1548    }
1549}
1550
1551// ── Cascade Shadow Maps ───────────────────────────────────────────────────────
1552
1553/// Cascade definition for CSM (Cascaded Shadow Maps).
1554#[derive(Debug, Clone)]
1555pub struct ShadowCascade {
1556    pub near:      f32,
1557    pub far:       f32,
1558    pub resolution: u32,
1559    pub bias:      f32,
1560    /// View-projection matrix for this cascade.
1561    pub view_proj: Mat4,
1562}
1563
1564impl ShadowCascade {
1565    pub fn new(near: f32, far: f32, resolution: u32, bias: f32) -> Self {
1566        Self { near, far, resolution, bias, view_proj: Mat4::IDENTITY }
1567    }
1568
1569    /// Update the cascade's VP matrix for a given light direction and camera frustum corners.
1570    pub fn update_view_proj(
1571        &mut self,
1572        light_dir:        Vec3,
1573        camera_pos:       Vec3,
1574        camera_forward:   Vec3,
1575        camera_fov:       f32,
1576        aspect:           f32,
1577    ) {
1578        // Compute 8 frustum corners for [near, far] slice
1579        let (sin_h, cos_h) = (camera_fov * 0.5).sin_cos();
1580        let tan_h  = sin_h / cos_h.max(1e-7);
1581        let tan_v  = tan_h / aspect.max(1e-7);
1582
1583        let right = camera_forward.cross(Vec3::Y).normalize_or_zero();
1584        let up    = right.cross(camera_forward).normalize_or_zero();
1585
1586        let corners: Vec<Vec3> = [self.near, self.far].iter().flat_map(|&d| {
1587            [
1588                camera_pos + camera_forward * d + right * tan_h * d + up * tan_v * d,
1589                camera_pos + camera_forward * d - right * tan_h * d + up * tan_v * d,
1590                camera_pos + camera_forward * d + right * tan_h * d - up * tan_v * d,
1591                camera_pos + camera_forward * d - right * tan_h * d - up * tan_v * d,
1592            ]
1593        }).collect();
1594
1595        // Fit ortho box around frustum corners in light space
1596        let light_up  = if light_dir.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
1597        let center    = corners.iter().fold(Vec3::ZERO, |a, &b| a + b) / corners.len() as f32;
1598        let light_view = Mat4::look_at_rh(center - light_dir, center, light_up);
1599
1600        let mut min = Vec3::splat(f32::MAX);
1601        let mut max = Vec3::splat(f32::MIN);
1602        for c in &corners {
1603            let ls = light_view.transform_point3(*c);
1604            min = min.min(ls);
1605            max = max.max(ls);
1606        }
1607        let slack = 2.0;
1608        let proj = Mat4::orthographic_rh(
1609            min.x - slack, max.x + slack,
1610            min.y - slack, max.y + slack,
1611            -max.z - slack, -min.z + slack,
1612        );
1613        self.view_proj = proj * light_view;
1614    }
1615}
1616
1617/// Full cascaded shadow map system.
1618#[derive(Debug, Clone)]
1619pub struct CsmSystem {
1620    pub cascades:        Vec<ShadowCascade>,
1621    pub stabilize:       bool,  // texel-snap to prevent shimmer
1622    pub blend_band:      f32,   // blend region between cascades [0,1]
1623    pub debug_vis:       bool,
1624}
1625
1626impl CsmSystem {
1627    pub fn new(cascade_splits: &[f32], base_resolution: u32) -> Self {
1628        let cascades = cascade_splits.windows(2).map(|w| {
1629            ShadowCascade::new(w[0], w[1], base_resolution, 0.005)
1630        }).collect();
1631        Self {
1632            cascades,
1633            stabilize: true,
1634            blend_band: 0.1,
1635            debug_vis: false,
1636        }
1637    }
1638
1639    pub fn default_3_cascade() -> Self {
1640        Self::new(&[0.1, 8.0, 30.0, 100.0], 2048)
1641    }
1642
1643    pub fn update(
1644        &mut self,
1645        light_dir:      Vec3,
1646        camera_pos:     Vec3,
1647        camera_forward: Vec3,
1648        fov:            f32,
1649        aspect:         f32,
1650    ) {
1651        for c in &mut self.cascades {
1652            c.update_view_proj(light_dir, camera_pos, camera_forward, fov, aspect);
1653        }
1654    }
1655
1656    /// Find which cascade index should be used for a given distance from camera.
1657    pub fn cascade_for_distance(&self, dist: f32) -> Option<usize> {
1658        for (i, c) in self.cascades.iter().enumerate() {
1659            if dist >= c.near && dist < c.far {
1660                return Some(i);
1661            }
1662        }
1663        None
1664    }
1665
1666    /// Generate debug cascade color (for visualization).
1667    pub fn cascade_color(index: usize) -> Vec3 {
1668        match index % 4 {
1669            0 => Vec3::new(1.0, 0.0, 0.0),
1670            1 => Vec3::new(0.0, 1.0, 0.0),
1671            2 => Vec3::new(0.0, 0.0, 1.0),
1672            _ => Vec3::new(1.0, 1.0, 0.0),
1673        }
1674    }
1675}
1676
1677// ── IBL (Image-Based Lighting) ────────────────────────────────────────────────
1678
1679/// Prefiltered environment map for image-based lighting.
1680///
1681/// Stores irradiance and specular prefiltered maps as spherical harmonic
1682/// coefficients (irradiance) and mip-level radiance data (specular).
1683#[derive(Debug, Clone)]
1684pub struct IblEnvironment {
1685    pub name:            String,
1686    /// 9 SH coefficients for diffuse irradiance (precomputed from env map).
1687    pub irradiance_sh:   [Vec3; 9],
1688    /// Prefiltered specular mip levels: each entry is (roughness, [6*W*H] data).
1689    pub specular_mips:   Vec<(f32, Vec<Vec3>)>,
1690    pub mip_width:       u32,
1691    pub mip_height:      u32,
1692    /// BRDF integration LUT: 2D table of (NdotV, roughness) → (scale, bias).
1693    pub brdf_lut:        Vec<Vec2>,
1694    pub brdf_lut_size:   u32,
1695    pub exposure:        f32,
1696    pub rotation_y:      f32,
1697}
1698
1699impl IblEnvironment {
1700    /// Create from a uniform grey environment (good for testing).
1701    pub fn grey(name: impl Into<String>, intensity: f32) -> Self {
1702        let color = Vec3::splat(intensity / std::f32::consts::PI);
1703        let mut sh = [Vec3::ZERO; 9];
1704        sh[0] = color * (1.0 / 0.282_095_f32);
1705        let lut_size = 64u32;
1706        let lut = Self::compute_brdf_lut(lut_size);
1707        Self {
1708            name: name.into(),
1709            irradiance_sh: sh,
1710            specular_mips: Vec::new(),
1711            mip_width: 0,
1712            mip_height: 0,
1713            brdf_lut: lut,
1714            brdf_lut_size: lut_size,
1715            exposure: 1.0,
1716            rotation_y: 0.0,
1717        }
1718    }
1719
1720    /// Build a simple gradient sky IBL (blue sky + ground).
1721    pub fn sky_gradient(sky_color: Vec3, ground_color: Vec3, intensity: f32) -> Self {
1722        let mut sh = [Vec3::ZERO; 9];
1723        // L0: average
1724        sh[0] = (sky_color + ground_color) * 0.5 * intensity * (1.0 / 0.282_095_f32);
1725        // L1: difference encodes hemisphere gradient
1726        sh[2] = (sky_color - ground_color) * intensity * (1.0 / 0.488_603_f32);
1727        let lut_size = 64u32;
1728        let lut = Self::compute_brdf_lut(lut_size);
1729        Self {
1730            name: "sky_gradient".to_string(),
1731            irradiance_sh: sh,
1732            specular_mips: Vec::new(),
1733            mip_width: 0,
1734            mip_height: 0,
1735            brdf_lut: lut,
1736            brdf_lut_size: lut_size,
1737            exposure: 1.0,
1738            rotation_y: 0.0,
1739        }
1740    }
1741
1742    /// Evaluate diffuse irradiance for a given surface normal.
1743    pub fn eval_diffuse(&self, normal: Vec3) -> Vec3 {
1744        let n = normal;
1745        let c0 = 0.282_095_f32;
1746        let c1 = 0.488_603_f32;
1747        let c2 = 1.092_548_f32;
1748        let c3 = 0.315_392_f32;
1749        let c4 = 0.546_274_f32;
1750        let sh = &self.irradiance_sh;
1751        let result =
1752            sh[0] * c0
1753            + sh[1] * c1 * n.y
1754            + sh[2] * c1 * n.z
1755            + sh[3] * c1 * n.x
1756            + sh[4] * c2 * n.x * n.y
1757            + sh[5] * c2 * n.y * n.z
1758            + sh[6] * c3 * (3.0 * n.z * n.z - 1.0)
1759            + sh[7] * c2 * n.x * n.z
1760            + sh[8] * c4 * (n.x * n.x - n.y * n.y);
1761        result.max(Vec3::ZERO) * self.exposure
1762    }
1763
1764    /// Evaluate the BRDF LUT at (n_dot_v, roughness).
1765    pub fn eval_brdf_lut(&self, n_dot_v: f32, roughness: f32) -> Vec2 {
1766        if self.brdf_lut.is_empty() { return Vec2::new(1.0, 0.0); }
1767        let u = n_dot_v.clamp(0.0, 1.0);
1768        let v = roughness.clamp(0.0, 1.0);
1769        let n = self.brdf_lut_size as usize;
1770        let xi = ((u * (n - 1) as f32) as usize).min(n - 1);
1771        let yi = ((v * (n - 1) as f32) as usize).min(n - 1);
1772        self.brdf_lut.get(yi * n + xi).copied().unwrap_or(Vec2::new(1.0, 0.0))
1773    }
1774
1775    /// Precompute the BRDF integration LUT using GGX importance sampling.
1776    fn compute_brdf_lut(size: u32) -> Vec<Vec2> {
1777        let n = size as usize;
1778        let mut lut = vec![Vec2::ZERO; n * n];
1779        for yi in 0..n {
1780            let roughness = (yi as f32 + 0.5) / n as f32;
1781            for xi in 0..n {
1782                let n_dot_v = (xi as f32 + 0.5) / n as f32;
1783                let (scale, bias) = Self::integrate_brdf(n_dot_v, roughness);
1784                lut[yi * n + xi] = Vec2::new(scale, bias);
1785            }
1786        }
1787        lut
1788    }
1789
1790    fn integrate_brdf(n_dot_v: f32, roughness: f32) -> (f32, f32) {
1791        let v = Vec3::new((1.0 - n_dot_v * n_dot_v).sqrt(), 0.0, n_dot_v);
1792        let n = Vec3::Z;
1793        let mut a = 0.0_f32;
1794        let mut b = 0.0_f32;
1795        let samples = 1024u32;
1796        for i in 0..samples {
1797            let xi = Self::hammersley(i, samples);
1798            let h  = Self::importance_sample_ggx(xi, n, roughness);
1799            let l  = (2.0 * v.dot(h) * h - v).normalize_or_zero();
1800            let n_dot_l = n.dot(l).max(0.0);
1801            let n_dot_h = n.dot(h).max(0.0);
1802            let v_dot_h = v.dot(h).max(0.0);
1803            if n_dot_l > 0.0 {
1804                let g      = PbrLighting::geometry_smith(n_dot_v, n_dot_l, roughness);
1805                let g_vis  = (g * v_dot_h) / (n_dot_h * n_dot_v + 1e-7);
1806                let fc     = (1.0 - v_dot_h).powi(5);
1807                a         += (1.0 - fc) * g_vis;
1808                b         += fc * g_vis;
1809            }
1810        }
1811        (a / samples as f32, b / samples as f32)
1812    }
1813
1814    fn hammersley(i: u32, n: u32) -> Vec2 {
1815        let radical = {
1816            let mut bits = i;
1817            bits = (bits << 16) | (bits >> 16);
1818            bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
1819            bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2);
1820            bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4);
1821            bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8);
1822            bits as f32 * 2.328_306_4e-10
1823        };
1824        Vec2::new(i as f32 / n as f32, radical)
1825    }
1826
1827    fn importance_sample_ggx(xi: Vec2, n: Vec3, roughness: f32) -> Vec3 {
1828        let a   = roughness * roughness;
1829        let phi = 2.0 * std::f32::consts::PI * xi.x;
1830        let cos_theta = ((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y)).sqrt().clamp(0.0, 1.0);
1831        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
1832        let h_local = Vec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta);
1833        // TBN basis
1834        let up    = if n.z.abs() < 0.999 { Vec3::Z } else { Vec3::X };
1835        let right = up.cross(n).normalize_or_zero();
1836        let up2   = n.cross(right);
1837        (right * h_local.x + up2 * h_local.y + n * h_local.z).normalize_or_zero()
1838    }
1839
1840    /// IBL contribution for a PBR material.
1841    pub fn shade_ibl(&self, normal: Vec3, view_dir: Vec3, mat: &PbrMaterial) -> Vec3 {
1842        let n_dot_v  = normal.dot(view_dir).clamp(0.0, 1.0);
1843        let diffuse  = self.eval_diffuse(normal) * mat.albedo * (1.0 - mat.metallic);
1844        let f0       = mat.f0();
1845        let f        = PbrLighting::fresnel_schlick(n_dot_v, f0 + Vec3::splat(mat.roughness * 0.5));
1846        let brdf_lut = self.eval_brdf_lut(n_dot_v, mat.roughness);
1847        let specular = f * brdf_lut.x + Vec3::splat(brdf_lut.y);
1848        (diffuse + specular) * mat.ao
1849    }
1850}
1851
1852// ── Exposure / HDR ────────────────────────────────────────────────────────────
1853
1854/// HDR exposure and tonemapping settings.
1855#[derive(Debug, Clone)]
1856pub struct ExposureSettings {
1857    pub ev100:           f32,   // exposure value at ISO 100
1858    pub auto_exposure:   bool,
1859    pub auto_min_ev:     f32,
1860    pub auto_max_ev:     f32,
1861    pub auto_adapt_speed: f32,  // EV change per second
1862    pub tonemap_mode:    ToneMapMode,
1863    pub white_point:     f32,
1864}
1865
1866#[derive(Debug, Clone, Copy, PartialEq)]
1867pub enum ToneMapMode {
1868    Linear,
1869    Reinhard,
1870    ReinhardLuminance,
1871    Aces,
1872    AcesApprox,
1873    Uncharted2,
1874    Hejl,
1875    Custom { a: f32, b: f32, c: f32, d: f32, e: f32, f: f32 },
1876}
1877
1878impl Default for ExposureSettings {
1879    fn default() -> Self {
1880        Self {
1881            ev100: 0.0,
1882            auto_exposure: false,
1883            auto_min_ev: -4.0,
1884            auto_max_ev: 12.0,
1885            auto_adapt_speed: 2.0,
1886            tonemap_mode: ToneMapMode::AcesApprox,
1887            white_point: 1.0,
1888        }
1889    }
1890}
1891
1892impl ExposureSettings {
1893    pub fn exposure_factor(&self) -> f32 {
1894        // EV100 to linear exposure scale
1895        let iso = 100.0_f32;
1896        let n_shutter = 1.0_f32;
1897        let aperture  = 1.0_f32;
1898        let ev = self.ev100 + (iso / 100.0).log2();
1899        let lmax = (aperture * aperture / n_shutter) * (100.0 / iso) * 12.5;
1900        1.0 / (lmax * (2.0_f32).powf(ev) * std::f32::consts::PI)
1901    }
1902
1903    /// Apply the selected tone map operator to a linear HDR color.
1904    pub fn tonemap(&self, color: Vec3) -> Vec3 {
1905        let c = color * self.exposure_factor();
1906        match self.tonemap_mode {
1907            ToneMapMode::Linear              => c.clamp(Vec3::ZERO, Vec3::ONE),
1908            ToneMapMode::Reinhard            => c / (c + Vec3::ONE),
1909            ToneMapMode::ReinhardLuminance   => {
1910                let lum = c.dot(Vec3::new(0.2126, 0.7152, 0.0722));
1911                c * ((lum + 1.0) / (lum * (1.0 + lum / (self.white_point * self.white_point)) + 1.0))
1912            }
1913            ToneMapMode::Aces               => Self::aces_filmic(c),
1914            ToneMapMode::AcesApprox         => Self::aces_approx(c),
1915            ToneMapMode::Uncharted2         => Self::uncharted2(c, self.white_point),
1916            ToneMapMode::Hejl              => {
1917                let a = (c * (6.2 * c + 0.5)) / (c * (6.2 * c + 1.7) + 0.06);
1918                a.clamp(Vec3::ZERO, Vec3::ONE)
1919            }
1920            ToneMapMode::Custom { a, b, c: cc, d, e, f } => {
1921                let x = c;
1922                ((x * (a * x + Vec3::splat(b))) + Vec3::splat(d))
1923                / ((x * (a * x + Vec3::splat(cc))) + Vec3::splat(e))
1924                - Vec3::splat(f / cc)
1925            }
1926        }
1927    }
1928
1929    fn aces_filmic(x: Vec3) -> Vec3 {
1930        let a = 2.51_f32;
1931        let b = 0.03_f32;
1932        let c = 2.43_f32;
1933        let d = 0.59_f32;
1934        let e = 0.14_f32;
1935        ((x * (a * x + Vec3::splat(b))) / (x * (c * x + Vec3::splat(d)) + Vec3::splat(e)))
1936            .clamp(Vec3::ZERO, Vec3::ONE)
1937    }
1938
1939    fn aces_approx(x: Vec3) -> Vec3 {
1940        let x = x * 0.6;
1941        let a = 2.51_f32;
1942        let b = 0.03_f32;
1943        let c = 2.43_f32;
1944        let d = 0.59_f32;
1945        let e = 0.14_f32;
1946        ((x * (a * x + Vec3::splat(b))) / (x * (c * x + Vec3::splat(d)) + Vec3::splat(e)))
1947            .clamp(Vec3::ZERO, Vec3::ONE)
1948    }
1949
1950    fn uncharted2(x: Vec3, white: f32) -> Vec3 {
1951        fn curve(v: Vec3) -> Vec3 {
1952            let a = 0.15_f32; let b = 0.50_f32; let c = 0.10_f32;
1953            let d = 0.20_f32; let e = 0.02_f32; let f = 0.30_f32;
1954            (v * (a * v + Vec3::splat(c * b)) + Vec3::splat(d * e))
1955            / (v * (a * v + Vec3::splat(b)) + Vec3::splat(d * f))
1956            - Vec3::splat(e / f)
1957        }
1958        let curr     = curve(x * 2.0);
1959        let white_sc = curve(Vec3::splat(white));
1960        (curr / white_sc).clamp(Vec3::ZERO, Vec3::ONE)
1961    }
1962
1963    /// Adapt EV100 toward a new measured scene luminance over `dt` seconds.
1964    pub fn auto_expose(&mut self, scene_luminance: f32, dt: f32) {
1965        if !self.auto_exposure { return; }
1966        let target_ev = scene_luminance.max(1e-7).log2() + 3.0;
1967        let target_ev = target_ev.clamp(self.auto_min_ev, self.auto_max_ev);
1968        let delta     = (target_ev - self.ev100).clamp(-self.auto_adapt_speed * dt, self.auto_adapt_speed * dt);
1969        self.ev100   += delta;
1970    }
1971}
1972
1973// ── Light Baker ───────────────────────────────────────────────────────────────
1974
1975/// CPU-side light baking utility.
1976///
1977/// Casts shadow rays and computes irradiance at sample points, storing
1978/// results in a `LightMap` for static illumination.
1979pub struct LightBaker {
1980    pub sample_count:  u32,
1981    pub hemisphere_samples: Vec<Vec3>,
1982}
1983
1984/// A baked light map storing per-texel irradiance.
1985#[derive(Debug, Clone)]
1986pub struct LightMap {
1987    pub width:    u32,
1988    pub height:   u32,
1989    pub texels:   Vec<Vec3>,
1990}
1991
1992impl LightMap {
1993    pub fn new(width: u32, height: u32) -> Self {
1994        Self { width, height, texels: vec![Vec3::ZERO; (width * height) as usize] }
1995    }
1996
1997    pub fn set(&mut self, x: u32, y: u32, color: Vec3) {
1998        let idx = (y * self.width + x) as usize;
1999        if idx < self.texels.len() {
2000            self.texels[idx] = color;
2001        }
2002    }
2003
2004    pub fn get(&self, x: u32, y: u32) -> Vec3 {
2005        self.texels.get((y * self.width + x) as usize).copied().unwrap_or(Vec3::ZERO)
2006    }
2007
2008    pub fn sample_bilinear(&self, u: f32, v: f32) -> Vec3 {
2009        let px = (u * self.width  as f32 - 0.5).max(0.0);
2010        let py = (v * self.height as f32 - 0.5).max(0.0);
2011        let x0 = px as u32;
2012        let y0 = py as u32;
2013        let x1 = (x0 + 1).min(self.width  - 1);
2014        let y1 = (y0 + 1).min(self.height - 1);
2015        let fx  = px.fract();
2016        let fy  = py.fract();
2017        let c00 = self.get(x0, y0);
2018        let c10 = self.get(x1, y0);
2019        let c01 = self.get(x0, y1);
2020        let c11 = self.get(x1, y1);
2021        let cx0 = c00.lerp(c10, fx);
2022        let cx1 = c01.lerp(c11, fx);
2023        cx0.lerp(cx1, fy)
2024    }
2025
2026    /// Apply a simple box blur to reduce noise.
2027    pub fn blur(&self, radius: u32) -> LightMap {
2028        let mut out = LightMap::new(self.width, self.height);
2029        let r = radius as i32;
2030        for y in 0..self.height {
2031            for x in 0..self.width {
2032                let mut sum  = Vec3::ZERO;
2033                let mut count = 0_u32;
2034                for dy in -r..=r {
2035                    for dx in -r..=r {
2036                        let nx = x as i32 + dx;
2037                        let ny = y as i32 + dy;
2038                        if nx >= 0 && nx < self.width as i32 && ny >= 0 && ny < self.height as i32 {
2039                            sum   += self.get(nx as u32, ny as u32);
2040                            count += 1;
2041                        }
2042                    }
2043                }
2044                out.set(x, y, if count > 0 { sum / count as f32 } else { Vec3::ZERO });
2045            }
2046        }
2047        out
2048    }
2049}
2050
2051impl LightBaker {
2052    pub fn new(sample_count: u32) -> Self {
2053        let hemisphere_samples = Self::generate_hemisphere_samples(sample_count);
2054        Self { sample_count, hemisphere_samples }
2055    }
2056
2057    fn generate_hemisphere_samples(n: u32) -> Vec<Vec3> {
2058        (0..n).map(|i| {
2059            let xi0 = (i as f32 + 0.5) / n as f32;
2060            let xi1 = {
2061                let mut bits = i;
2062                bits = (bits << 16) | (bits >> 16);
2063                bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
2064                bits as f32 * 2.328_306_4e-10
2065            };
2066            let phi       = 2.0 * std::f32::consts::PI * xi1;
2067            let cos_theta = xi0.sqrt();
2068            let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
2069            Vec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta)
2070        }).collect()
2071    }
2072
2073    /// Bake indirect irradiance at a sample point from the sky environment.
2074    pub fn bake_point_ibl(&self, position: Vec3, normal: Vec3, env: &IblEnvironment) -> Vec3 {
2075        env.eval_diffuse(normal)
2076    }
2077
2078    /// Bake direct irradiance from all active lights (no shadow ray casting).
2079    pub fn bake_point_direct(&self, position: Vec3, normal: Vec3, manager: &LightManager) -> Vec3 {
2080        manager.evaluate_cpu(position, normal)
2081    }
2082
2083    /// Full bake: direct + IBL for a set of sample points.
2084    pub fn bake_samples(
2085        &self,
2086        samples: &[(Vec3, Vec3)],  // (position, normal)
2087        manager: &LightManager,
2088        env:     &IblEnvironment,
2089    ) -> Vec<Vec3> {
2090        samples.iter().map(|&(pos, nor)| {
2091            self.bake_point_direct(pos, nor, manager)
2092            + self.bake_point_ibl(pos, nor, env)
2093        }).collect()
2094    }
2095
2096    /// Bake a 2D lightmap for a planar surface.
2097    pub fn bake_plane(
2098        &self,
2099        width:   u32,
2100        height:  u32,
2101        origin:  Vec3,
2102        u_axis:  Vec3,  // full width vector
2103        v_axis:  Vec3,  // full height vector
2104        normal:  Vec3,
2105        manager: &LightManager,
2106        env:     &IblEnvironment,
2107    ) -> LightMap {
2108        let mut map = LightMap::new(width, height);
2109        for y in 0..height {
2110            let tv = (y as f32 + 0.5) / height as f32;
2111            for x in 0..width {
2112                let tu  = (x as f32 + 0.5) / width as f32;
2113                let pos = origin + u_axis * tu + v_axis * tv;
2114                let irr = self.bake_point_direct(pos, normal, manager)
2115                        + self.bake_point_ibl(pos, normal, env);
2116                map.set(x, y, irr);
2117            }
2118        }
2119        map
2120    }
2121}
2122
2123// ── Extended LightManager ─────────────────────────────────────────────────────
2124
2125impl LightManager {
2126    /// Update all animated lights. Call once per frame.
2127    pub fn update_animated(&mut self, _animated_points: &mut [AnimatedPointLight], _animated_spots: &mut [AnimatedSpotLight], time: f32, dt: f32) {
2128        for ap in _animated_points.iter_mut() {
2129            ap.update(dt, time);
2130        }
2131        for asp in _animated_spots.iter_mut() {
2132            asp.update(dt, time);
2133        }
2134    }
2135
2136    /// Generate GLSL uniform data for the light manager (for shader injection).
2137    pub fn generate_glsl_uniforms(&self) -> String {
2138        let mut s = String::new();
2139        s.push_str(&format!(
2140            "uniform int u_num_point_lights;\n\
2141             uniform int u_num_spot_lights;\n\
2142             uniform int u_has_directional;\n"
2143        ));
2144        for (i, l) in self.point_lights.iter().take(64).enumerate() {
2145            s.push_str(&format!(
2146                "uniform vec3  u_point_pos[{i}];\n\
2147                 uniform vec3  u_point_color[{i}];\n\
2148                 uniform float u_point_intensity[{i}];\n\
2149                 uniform float u_point_range[{i}];\n"
2150            ));
2151        }
2152        s
2153    }
2154
2155    /// Count shadow-casting lights.
2156    pub fn shadow_caster_count(&self) -> usize {
2157        self.point_lights.iter().filter(|l| l.cast_shadow).count()
2158        + self.spot_lights.iter().filter(|l| l.cast_shadow).count()
2159        + if self.directional.as_ref().map(|d| d.cast_shadow).unwrap_or(false) { 1 } else { 0 }
2160    }
2161
2162    /// Serialize all lights to a compact binary format.
2163    pub fn serialize_compact(&self) -> Vec<u8> {
2164        let mut buf = Vec::new();
2165        let push_f32 = |buf: &mut Vec<u8>, v: f32| buf.extend_from_slice(&v.to_le_bytes());
2166        let push_v3  = |buf: &mut Vec<u8>, v: Vec3| {
2167            buf.extend_from_slice(&v.x.to_le_bytes());
2168            buf.extend_from_slice(&v.y.to_le_bytes());
2169            buf.extend_from_slice(&v.z.to_le_bytes());
2170        };
2171        // Header
2172        buf.extend_from_slice(&(self.point_lights.len() as u32).to_le_bytes());
2173        buf.extend_from_slice(&(self.spot_lights.len()  as u32).to_le_bytes());
2174        for l in &self.point_lights {
2175            push_v3(&mut buf, l.position);
2176            push_v3(&mut buf, l.color);
2177            push_f32(&mut buf, l.intensity);
2178            push_f32(&mut buf, l.range);
2179        }
2180        for l in &self.spot_lights {
2181            push_v3(&mut buf, l.position);
2182            push_v3(&mut buf, l.direction);
2183            push_v3(&mut buf, l.color);
2184            push_f32(&mut buf, l.intensity);
2185            push_f32(&mut buf, l.range);
2186            push_f32(&mut buf, l.inner_angle);
2187            push_f32(&mut buf, l.outer_angle);
2188        }
2189        buf
2190    }
2191
2192    /// Add a flickering torch light.
2193    pub fn add_torch(&mut self, position: Vec3) -> LightId {
2194        self.add_point_light(
2195            PointLight::new(position, Vec3::new(1.0, 0.5, 0.2), 2.5, 6.0)
2196                .with_tag("torch"),
2197        )
2198    }
2199
2200    /// Add a cold fluorescent tube.
2201    pub fn add_fluorescent(&mut self, position: Vec3) -> LightId {
2202        self.add_point_light(
2203            PointLight::new(position, Vec3::new(0.85, 0.9, 1.0), 3.5, 12.0)
2204                .with_tag("fluorescent"),
2205        )
2206    }
2207
2208    /// Add a candle.
2209    pub fn add_candle(&mut self, position: Vec3) -> LightId {
2210        self.add_point_light(
2211            PointLight::new(position, Vec3::new(1.0, 0.65, 0.3), 0.8, 3.0)
2212                .with_attenuation(Attenuation::InverseSquare)
2213                .with_tag("candle"),
2214        )
2215    }
2216
2217    /// Add an LED strip across a line segment.
2218    pub fn add_led_strip(&mut self, from: Vec3, to: Vec3, color: Vec3, segment_count: u32) -> Vec<LightId> {
2219        (0..segment_count).map(|i| {
2220            let t = (i as f32 + 0.5) / segment_count as f32;
2221            let p = from.lerp(to, t);
2222            self.add_point_light(
2223                PointLight::new(p, color, 1.5, 2.0).with_tag("led_strip"),
2224            )
2225        }).collect()
2226    }
2227}
2228
2229// ── Tests ─────────────────────────────────────────────────────────────────────
2230
2231#[cfg(test)]
2232mod tests {
2233    use super::*;
2234
2235    #[test]
2236    fn test_attenuation_falloff() {
2237        let a = Attenuation::InverseSquare;
2238        assert!((a.evaluate(1.0, 10.0) - 1.0).abs() < 1e-4);
2239        assert!((a.evaluate(2.0, 10.0) - 0.25).abs() < 1e-4);
2240    }
2241
2242    #[test]
2243    fn test_point_light_contribution() {
2244        let light = PointLight::new(Vec3::new(0.0, 5.0, 0.0), Vec3::ONE, 1.0, 20.0);
2245        let surface = Vec3::ZERO;
2246        let normal  = Vec3::Y;
2247        let contrib = light.contribution(surface, normal);
2248        assert!(contrib.length() > 0.0);
2249    }
2250
2251    #[test]
2252    fn test_spot_light_cone() {
2253        let mut light = SpotLight::new(Vec3::new(0.0, 5.0, 0.0), -Vec3::Y, Vec3::ONE, 1.0, 20.0);
2254        light.inner_angle = 0.2;
2255        light.outer_angle = 0.5;
2256        let directly_below = Vec3::ZERO;
2257        let c = light.contribution(directly_below, Vec3::Y);
2258        assert!(c.length() > 0.0);
2259    }
2260
2261    #[test]
2262    fn test_pbr_material_f0() {
2263        let mat = PbrMaterial::dielectric(Vec3::ONE, 0.5);
2264        let f0  = mat.f0();
2265        assert!(f0.x > 0.0 && f0.x < 1.0);
2266        let metal = PbrMaterial::metal(Vec3::new(0.8, 0.7, 0.1), 0.2);
2267        // Metal F0 = albedo
2268        assert!((metal.f0().x - 0.8).abs() < 1e-5);
2269    }
2270
2271    #[test]
2272    fn test_pbr_brdf_zero_behind() {
2273        let mat = PbrMaterial::dielectric(Vec3::ONE, 0.5);
2274        // Light from behind should contribute nothing
2275        let result = PbrLighting::brdf(Vec3::Y, Vec3::Y, -Vec3::Y, &mat);
2276        assert_eq!(result, Vec3::ZERO);
2277    }
2278
2279    #[test]
2280    fn test_light_manager_presets() {
2281        let m = LightManager::preset_daylight();
2282        assert!(m.directional.is_some());
2283        let m = LightManager::preset_dungeon();
2284        assert!(m.directional.is_none());
2285    }
2286
2287    #[test]
2288    fn test_ambient_hemisphere() {
2289        let amb = AmbientLight::hemisphere(Vec3::new(0.5, 0.6, 0.9), Vec3::new(0.2, 0.2, 0.1), 1.0);
2290        let top    = amb.evaluate(Vec3::Y);
2291        let bottom = amb.evaluate(-Vec3::Y);
2292        assert!(top.x > bottom.x || top.z > bottom.z);
2293    }
2294
2295    #[test]
2296    fn test_sh_probe() {
2297        let probe = LightProbe::from_uniform_color(Vec3::ZERO, 5.0, Vec3::ONE);
2298        let result = probe.evaluate_sh(Vec3::Y);
2299        assert!(result.length() > 0.0);
2300    }
2301
2302    #[test]
2303    fn test_ibl_diffuse_grey() {
2304        let ibl = IblEnvironment::grey("test", 1.0);
2305        let result = ibl.eval_diffuse(Vec3::Y);
2306        assert!(result.length() > 0.0 && result.length() < 10.0);
2307    }
2308
2309    #[test]
2310    fn test_tonemap_modes() {
2311        let settings = ExposureSettings { ev100: 0.0, ..Default::default() };
2312        let hdr = Vec3::new(2.0, 1.5, 0.8);
2313        let mapped = settings.tonemap(hdr);
2314        assert!(mapped.x <= 1.0 && mapped.y <= 1.0 && mapped.z <= 1.0);
2315    }
2316
2317    #[test]
2318    fn test_lightmap_blur() {
2319        let mut map = LightMap::new(8, 8);
2320        map.set(4, 4, Vec3::ONE);
2321        let blurred = map.blur(1);
2322        assert!(blurred.get(3, 4).length() > 0.0);
2323    }
2324
2325    #[test]
2326    fn test_csm_cascade_find() {
2327        let csm = CsmSystem::default_3_cascade();
2328        assert_eq!(csm.cascade_for_distance(5.0), Some(0));
2329        assert_eq!(csm.cascade_for_distance(200.0), None);
2330    }
2331
2332    #[test]
2333    fn test_animated_light_flicker() {
2334        let anim = LightAnimation::Flicker { speed: 10.0, depth: 0.3 };
2335        let f0 = anim.intensity_factor(0.0, 0);
2336        let f1 = anim.intensity_factor(0.1, 0);
2337        // Should be between 0.7 and 1.0
2338        assert!(f0 >= 0.7 && f0 <= 1.0);
2339        assert!(f1 >= 0.7 && f1 <= 1.0);
2340    }
2341
2342    #[test]
2343    fn test_ies_profile_downlight() {
2344        let ies = IesProfile::downlight("test");
2345        // Straight down (0°) should be bright
2346        let v0 = ies.sample(0.0, 0.0);
2347        // Horizontal (90°) should be dim
2348        let v90 = ies.sample(90.0, 0.0);
2349        assert!(v0 > v90);
2350    }
2351
2352    #[test]
2353    fn test_rect_light_irradiance() {
2354        let rl = RectLight::new(
2355            Vec3::new(0.0, 5.0, 0.0),
2356            Vec3::new(2.0, 0.0, 0.0),
2357            Vec3::new(0.0, 0.0, 2.0),
2358            Vec3::ONE, 5.0,
2359        );
2360        let irr = rl.irradiance_at(Vec3::ZERO, Vec3::Y);
2361        assert!(irr.length() > 0.0);
2362    }
2363
2364    #[test]
2365    fn test_light_baker_plane() {
2366        let manager = LightManager::preset_daylight();
2367        let env     = IblEnvironment::grey("grey", 0.5);
2368        let baker   = LightBaker::new(64);
2369        let map = baker.bake_plane(
2370            4, 4,
2371            Vec3::ZERO, Vec3::X * 4.0, Vec3::Z * 4.0, Vec3::Y,
2372            &manager, &env,
2373        );
2374        assert_eq!(map.texels.len(), 16);
2375        assert!(map.texels.iter().any(|c| c.length() > 0.0));
2376    }
2377
2378    #[test]
2379    fn test_manager_serialize() {
2380        let mut m = LightManager::new();
2381        m.add_point_light(PointLight::new(Vec3::ZERO, Vec3::ONE, 1.0, 5.0));
2382        let bytes = m.serialize_compact();
2383        assert!(!bytes.is_empty());
2384    }
2385
2386    #[test]
2387    fn test_exposure_auto_adapt() {
2388        let mut settings = ExposureSettings {
2389            auto_exposure: true,
2390            auto_min_ev: -4.0,
2391            auto_max_ev: 12.0,
2392            auto_adapt_speed: 10.0,
2393            ev100: 0.0,
2394            ..Default::default()
2395        };
2396        settings.auto_expose(100.0, 1.0);
2397        assert!(settings.ev100 != 0.0);
2398    }
2399}