Skip to main content

proof_engine/render/pbr/
mod.rs

1//! Physically Based Rendering (PBR) material system.
2//!
3//! Provides `PbrMaterial`, material presets, a material cache, and GLSL uniform
4//! block generation.  Sub-modules contain the full BRDF library, atmospheric
5//! rendering math, and environment probe / global-illumination helpers.
6
7pub mod brdf;
8pub mod atmosphere;
9pub mod probe;
10
11use glam::{Vec2, Vec3, Vec4};
12use std::collections::HashMap;
13
14// ─────────────────────────────────────────────────────────────────────────────
15// TextureHandle
16// ─────────────────────────────────────────────────────────────────────────────
17
18/// Opaque handle to a GPU texture resource.  The renderer back-end maps this
19/// integer to an actual texture object.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct TextureHandle(pub u32);
22
23impl TextureHandle {
24    /// Create a handle from a raw id.
25    #[inline]
26    pub const fn new(id: u32) -> Self {
27        Self(id)
28    }
29
30    /// Return the raw integer id.
31    #[inline]
32    pub const fn id(self) -> u32 {
33        self.0
34    }
35}
36
37// ─────────────────────────────────────────────────────────────────────────────
38// AlphaMode
39// ─────────────────────────────────────────────────────────────────────────────
40
41/// Controls how the alpha component of `albedo` is interpreted.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub enum AlphaMode {
44    /// Alpha is ignored; the surface is fully opaque.
45    Opaque,
46    /// Pixels whose alpha is below the stored cutoff are discarded (`discard`
47    /// in GLSL).  The inner `f32` is the cutoff threshold in [0, 1].
48    Mask(f32),
49    /// Standard alpha blending — the surface is rendered with transparency.
50    Blend,
51}
52
53impl Default for AlphaMode {
54    fn default() -> Self {
55        AlphaMode::Opaque
56    }
57}
58
59impl AlphaMode {
60    /// Returns the alpha cutoff if the mode is `Mask`, otherwise `None`.
61    pub fn cutoff(self) -> Option<f32> {
62        match self {
63            AlphaMode::Mask(c) => Some(c),
64            _ => None,
65        }
66    }
67
68    /// Returns `true` when the material requires depth-sorted rendering.
69    pub fn needs_sorting(self) -> bool {
70        matches!(self, AlphaMode::Blend)
71    }
72
73    /// Returns a short string tag suitable for shader variant selection.
74    pub fn variant_tag(self) -> &'static str {
75        match self {
76            AlphaMode::Opaque => "OPAQUE",
77            AlphaMode::Mask(_) => "MASK",
78            AlphaMode::Blend => "BLEND",
79        }
80    }
81}
82
83// ─────────────────────────────────────────────────────────────────────────────
84// PbrMaterial
85// ─────────────────────────────────────────────────────────────────────────────
86
87/// Complete physically based rendering material definition.
88///
89/// Each parameter follows the *metallic-roughness* workflow used by glTF 2.0.
90/// When both a scalar value and a texture are present the texture is
91/// multiplied by the scalar at evaluation time.
92#[derive(Debug, Clone)]
93pub struct PbrMaterial {
94    // ── Base colour ──────────────────────────────────────────────────────────
95    /// Base colour (linear sRGB + alpha).  Multiplied with `albedo_texture`.
96    pub albedo: Vec4,
97    /// Optional base colour / albedo texture.
98    pub albedo_texture: Option<TextureHandle>,
99
100    // ── Metallic / roughness ─────────────────────────────────────────────────
101    /// Metallic factor in [0, 1].
102    pub metallic: f32,
103    /// Optional metallic texture (samples the B channel per glTF convention).
104    pub metallic_texture: Option<TextureHandle>,
105
106    /// Perceptual roughness in [0, 1].
107    pub roughness: f32,
108    /// Optional roughness texture (samples the G channel per glTF convention).
109    pub roughness_texture: Option<TextureHandle>,
110
111    // ── Surface detail ───────────────────────────────────────────────────────
112    /// Tangent-space normal map.
113    pub normal_texture: Option<TextureHandle>,
114    /// Ambient occlusion map (samples the R channel).
115    pub occlusion_texture: Option<TextureHandle>,
116
117    // ── Emission ─────────────────────────────────────────────────────────────
118    /// Emissive tint (linear sRGB).
119    pub emission: Vec3,
120    /// Multiplier applied on top of `emission`.  Values > 1 allow HDR emission.
121    pub emission_scale: f32,
122    /// Optional emissive texture.
123    pub emission_texture: Option<TextureHandle>,
124
125    // ── Alpha ────────────────────────────────────────────────────────────────
126    /// How the alpha channel is interpreted.
127    pub alpha_mode: AlphaMode,
128    /// Alpha cutoff used when `alpha_mode == AlphaMode::Mask`.  Stored here for
129    /// convenience even though `AlphaMode::Mask` also carries the value.
130    pub alpha_cutoff: f32,
131
132    // ── Two-sidedness ────────────────────────────────────────────────────────
133    /// When `true` back-face culling is disabled and back-faces receive a
134    /// flipped normal.
135    pub double_sided: bool,
136
137    // ── Index of refraction ──────────────────────────────────────────────────
138    /// IOR used for dielectric Fresnel calculations.  Default: 1.5.
139    pub ior: f32,
140
141    // ── Clearcoat extension ──────────────────────────────────────────────────
142    /// Clearcoat layer strength in [0, 1].
143    pub clearcoat: f32,
144    /// Roughness of the clearcoat layer.
145    pub clearcoat_roughness: f32,
146
147    // ── Anisotropy extension ─────────────────────────────────────────────────
148    /// Anisotropy strength in [0, 1].
149    pub anisotropy: f32,
150    /// Direction of the anisotropy in tangent space (not normalised — length
151    /// encodes strength when anisotropy == 1).
152    pub anisotropy_direction: Vec2,
153
154    // ── Subsurface scattering ────────────────────────────────────────────────
155    /// Subsurface scattering weight in [0, 1].
156    pub subsurface_scattering: f32,
157    /// Mean-free-path colour (linear sRGB) for SSS.
158    pub subsurface_color: Vec3,
159}
160
161impl Default for PbrMaterial {
162    fn default() -> Self {
163        Self {
164            albedo: Vec4::new(0.8, 0.8, 0.8, 1.0),
165            albedo_texture: None,
166            metallic: 0.0,
167            metallic_texture: None,
168            roughness: 0.5,
169            roughness_texture: None,
170            normal_texture: None,
171            occlusion_texture: None,
172            emission: Vec3::ZERO,
173            emission_scale: 1.0,
174            emission_texture: None,
175            alpha_mode: AlphaMode::Opaque,
176            alpha_cutoff: 0.5,
177            double_sided: false,
178            ior: 1.5,
179            clearcoat: 0.0,
180            clearcoat_roughness: 0.0,
181            anisotropy: 0.0,
182            anisotropy_direction: Vec2::X,
183            subsurface_scattering: 0.0,
184            subsurface_color: Vec3::ONE,
185        }
186    }
187}
188
189impl PbrMaterial {
190    /// Create a new default (grey, dielectric) material.
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Builder: set base colour.
196    pub fn with_albedo(mut self, albedo: Vec4) -> Self {
197        self.albedo = albedo;
198        self
199    }
200
201    /// Builder: set metallic factor.
202    pub fn with_metallic(mut self, m: f32) -> Self {
203        self.metallic = m.clamp(0.0, 1.0);
204        self
205    }
206
207    /// Builder: set roughness.
208    pub fn with_roughness(mut self, r: f32) -> Self {
209        self.roughness = r.clamp(0.0, 1.0);
210        self
211    }
212
213    /// Builder: set IOR.
214    pub fn with_ior(mut self, ior: f32) -> Self {
215        self.ior = ior.max(1.0);
216        self
217    }
218
219    /// Builder: enable emission.
220    pub fn with_emission(mut self, color: Vec3, scale: f32) -> Self {
221        self.emission = color;
222        self.emission_scale = scale;
223        self
224    }
225
226    /// Builder: set alpha mode.
227    pub fn with_alpha(mut self, mode: AlphaMode) -> Self {
228        if let AlphaMode::Mask(c) = mode {
229            self.alpha_cutoff = c;
230        }
231        self.alpha_mode = mode;
232        self
233    }
234
235    /// Builder: set clearcoat parameters.
236    pub fn with_clearcoat(mut self, strength: f32, roughness: f32) -> Self {
237        self.clearcoat = strength.clamp(0.0, 1.0);
238        self.clearcoat_roughness = roughness.clamp(0.0, 1.0);
239        self
240    }
241
242    /// Builder: set anisotropy.
243    pub fn with_anisotropy(mut self, strength: f32, direction: Vec2) -> Self {
244        self.anisotropy = strength.clamp(0.0, 1.0);
245        self.anisotropy_direction = direction;
246        self
247    }
248
249    /// Builder: set subsurface scattering.
250    pub fn with_sss(mut self, weight: f32, color: Vec3) -> Self {
251        self.subsurface_scattering = weight.clamp(0.0, 1.0);
252        self.subsurface_color = color;
253        self
254    }
255
256    /// Compute F0 (specular reflectance at normal incidence) from IOR.
257    ///
258    /// For metals the full albedo tints the specular response; for dielectrics
259    /// the achromatic F0 derived from IOR is used.
260    pub fn f0(&self) -> Vec3 {
261        let f0_dielectric = brdf::fresnel::f0_from_ior(self.ior);
262        let f0_vec = Vec3::splat(f0_dielectric);
263        // Lerp between dielectric F0 and albedo.rgb for metals.
264        let albedo_rgb = Vec3::new(self.albedo.x, self.albedo.y, self.albedo.z);
265        f0_vec.lerp(albedo_rgb, self.metallic)
266    }
267
268    /// Returns `true` when the material has any translucency / transparency.
269    pub fn is_transparent(&self) -> bool {
270        self.alpha_mode.needs_sorting() || self.albedo.w < 1.0
271    }
272
273    /// Returns `true` when the material uses the clearcoat extension.
274    pub fn has_clearcoat(&self) -> bool {
275        self.clearcoat > 1e-5
276    }
277
278    /// Returns `true` when anisotropy is non-negligible.
279    pub fn has_anisotropy(&self) -> bool {
280        self.anisotropy > 1e-5
281    }
282
283    /// Returns `true` when subsurface scattering is enabled.
284    pub fn has_sss(&self) -> bool {
285        self.subsurface_scattering > 1e-5
286    }
287
288    /// Count how many textures are bound.
289    pub fn texture_count(&self) -> usize {
290        [
291            self.albedo_texture,
292            self.metallic_texture,
293            self.roughness_texture,
294            self.normal_texture,
295            self.occlusion_texture,
296            self.emission_texture,
297        ]
298        .iter()
299        .filter(|t| t.is_some())
300        .count()
301    }
302
303    /// Validate the material, returning a list of warnings.
304    pub fn validate(&self) -> Vec<String> {
305        let mut warnings = Vec::new();
306
307        if self.roughness < 0.04 {
308            warnings.push(format!(
309                "roughness={:.4} is very low; may produce specular aliasing",
310                self.roughness
311            ));
312        }
313        if self.metallic > 0.0 && self.metallic < 1.0 && self.metallic != 0.0 {
314            if !(0.0..=1.0).contains(&self.metallic) {
315                warnings.push(format!(
316                    "metallic={:.4} is out of [0,1] range",
317                    self.metallic
318                ));
319            }
320        }
321        if self.emission_scale > 100.0 {
322            warnings.push(format!(
323                "emission_scale={:.1} is extremely high; check for HDR overflow",
324                self.emission_scale
325            ));
326        }
327        if self.ior < 1.0 {
328            warnings.push(format!("ior={:.3} is below 1.0 which is unphysical", self.ior));
329        }
330        warnings
331    }
332}
333
334// ─────────────────────────────────────────────────────────────────────────────
335// MaterialPreset
336// ─────────────────────────────────────────────────────────────────────────────
337
338/// Factory methods that return physically plausible `PbrMaterial` presets for
339/// common real-world materials.  All values are based on measured data where
340/// available.
341pub struct MaterialPreset;
342
343impl MaterialPreset {
344    /// 24-carat gold — high metallic, warm reflectance, low roughness.
345    pub fn gold() -> PbrMaterial {
346        PbrMaterial::new()
347            .with_albedo(Vec4::new(1.000, 0.766, 0.336, 1.0))
348            .with_metallic(1.0)
349            .with_roughness(0.1)
350            .with_ior(0.47) // gold IOR at 589 nm
351    }
352
353    /// Polished silver — bright, slightly cold specular.
354    pub fn silver() -> PbrMaterial {
355        PbrMaterial::new()
356            .with_albedo(Vec4::new(0.972, 0.960, 0.915, 1.0))
357            .with_metallic(1.0)
358            .with_roughness(0.05)
359            .with_ior(0.15)
360    }
361
362    /// Copper — reddish warm metal.
363    pub fn copper() -> PbrMaterial {
364        PbrMaterial::new()
365            .with_albedo(Vec4::new(0.955, 0.637, 0.538, 1.0))
366            .with_metallic(1.0)
367            .with_roughness(0.15)
368            .with_ior(0.62)
369    }
370
371    /// Iron / steel — neutral grey metal, moderate roughness.
372    pub fn iron() -> PbrMaterial {
373        PbrMaterial::new()
374            .with_albedo(Vec4::new(0.560, 0.570, 0.580, 1.0))
375            .with_metallic(1.0)
376            .with_roughness(0.3)
377            .with_ior(2.95)
378    }
379
380    /// Natural rubber — matte black dielectric.
381    pub fn rubber() -> PbrMaterial {
382        PbrMaterial::new()
383            .with_albedo(Vec4::new(0.02, 0.02, 0.02, 1.0))
384            .with_metallic(0.0)
385            .with_roughness(0.9)
386            .with_ior(1.5)
387    }
388
389    /// Glossy plastic — bright coloured dielectric with low roughness.
390    pub fn plastic_glossy() -> PbrMaterial {
391        PbrMaterial::new()
392            .with_albedo(Vec4::new(0.8, 0.1, 0.1, 1.0))
393            .with_metallic(0.0)
394            .with_roughness(0.1)
395            .with_ior(1.5)
396    }
397
398    /// Matte plastic — diffuse-dominant dielectric.
399    pub fn plastic_matte() -> PbrMaterial {
400        PbrMaterial::new()
401            .with_albedo(Vec4::new(0.6, 0.6, 0.8, 1.0))
402            .with_metallic(0.0)
403            .with_roughness(0.7)
404            .with_ior(1.5)
405    }
406
407    /// Clear glass — fully transparent dielectric with strong Fresnel.
408    pub fn glass() -> PbrMaterial {
409        PbrMaterial {
410            albedo: Vec4::new(0.95, 0.98, 1.0, 0.05),
411            metallic: 0.0,
412            roughness: 0.0,
413            ior: 1.52,
414            alpha_mode: AlphaMode::Blend,
415            alpha_cutoff: 0.0,
416            ..Default::default()
417        }
418    }
419
420    /// Human skin — warm SSS, slightly specular.
421    pub fn skin() -> PbrMaterial {
422        PbrMaterial {
423            albedo: Vec4::new(0.847, 0.651, 0.510, 1.0),
424            metallic: 0.0,
425            roughness: 0.6,
426            ior: 1.4,
427            subsurface_scattering: 0.7,
428            subsurface_color: Vec3::new(1.0, 0.4, 0.2),
429            ..Default::default()
430        }
431    }
432
433    /// Still water surface — highly transparent, strong Fresnel at grazing angles.
434    pub fn water() -> PbrMaterial {
435        PbrMaterial {
436            albedo: Vec4::new(0.1, 0.35, 0.55, 0.85),
437            metallic: 0.0,
438            roughness: 0.02,
439            ior: 1.333,
440            alpha_mode: AlphaMode::Blend,
441            alpha_cutoff: 0.0,
442            ..Default::default()
443        }
444    }
445
446    /// Generic stone — grey, rough, dielectric.
447    pub fn stone() -> PbrMaterial {
448        PbrMaterial::new()
449            .with_albedo(Vec4::new(0.45, 0.42, 0.38, 1.0))
450            .with_metallic(0.0)
451            .with_roughness(0.85)
452            .with_ior(1.6)
453    }
454
455    /// Poured concrete — very rough, slightly darker than stone.
456    pub fn concrete() -> PbrMaterial {
457        PbrMaterial::new()
458            .with_albedo(Vec4::new(0.60, 0.59, 0.57, 1.0))
459            .with_metallic(0.0)
460            .with_roughness(0.95)
461            .with_ior(1.55)
462    }
463
464    /// Natural wood — warm, anisotropic grain.
465    pub fn wood() -> PbrMaterial {
466        PbrMaterial {
467            albedo: Vec4::new(0.52, 0.37, 0.22, 1.0),
468            metallic: 0.0,
469            roughness: 0.75,
470            ior: 1.5,
471            anisotropy: 0.6,
472            anisotropy_direction: Vec2::X,
473            ..Default::default()
474        }
475    }
476
477    /// Woven fabric — very diffuse, soft surface.
478    pub fn fabric() -> PbrMaterial {
479        PbrMaterial::new()
480            .with_albedo(Vec4::new(0.3, 0.2, 0.6, 1.0))
481            .with_metallic(0.0)
482            .with_roughness(0.95)
483            .with_ior(1.45)
484    }
485}
486
487// ─────────────────────────────────────────────────────────────────────────────
488// MaterialKey — content-addressed hash for deduplication
489// ─────────────────────────────────────────────────────────────────────────────
490
491/// A hash key derived from the content of a `PbrMaterial`.  Two materials that
492/// are structurally identical produce the same key.
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
494pub struct MaterialKey(u64);
495
496impl MaterialKey {
497    /// Derive the key from a material by hashing its fields with a simple FNV-1a
498    /// accumulator.  This is not cryptographic — it is only used for cache
499    /// lookup.
500    pub fn from_material(m: &PbrMaterial) -> Self {
501        let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis
502
503        macro_rules! mix_f32 {
504            ($v:expr) => {
505                h = fnv1a_mix(h, ($v).to_bits() as u64);
506            };
507        }
508        macro_rules! mix_u32 {
509            ($v:expr) => {
510                h = fnv1a_mix(h, $v as u64);
511            };
512        }
513        macro_rules! mix_opt {
514            ($v:expr) => {
515                match $v {
516                    Some(TextureHandle(id)) => {
517                        mix_u32!(1u32);
518                        mix_u32!(id);
519                    }
520                    None => {
521                        mix_u32!(0u32);
522                    }
523                }
524            };
525        }
526
527        mix_f32!(m.albedo.x);
528        mix_f32!(m.albedo.y);
529        mix_f32!(m.albedo.z);
530        mix_f32!(m.albedo.w);
531        mix_opt!(m.albedo_texture);
532
533        mix_f32!(m.metallic);
534        mix_opt!(m.metallic_texture);
535        mix_f32!(m.roughness);
536        mix_opt!(m.roughness_texture);
537        mix_opt!(m.normal_texture);
538        mix_opt!(m.occlusion_texture);
539
540        mix_f32!(m.emission.x);
541        mix_f32!(m.emission.y);
542        mix_f32!(m.emission.z);
543        mix_f32!(m.emission_scale);
544        mix_opt!(m.emission_texture);
545
546        let alpha_disc: u32 = match m.alpha_mode {
547            AlphaMode::Opaque => 0,
548            AlphaMode::Mask(_) => 1,
549            AlphaMode::Blend => 2,
550        };
551        mix_u32!(alpha_disc);
552        mix_f32!(m.alpha_cutoff);
553        mix_u32!(m.double_sided as u32);
554        mix_f32!(m.ior);
555        mix_f32!(m.clearcoat);
556        mix_f32!(m.clearcoat_roughness);
557        mix_f32!(m.anisotropy);
558        mix_f32!(m.anisotropy_direction.x);
559        mix_f32!(m.anisotropy_direction.y);
560        mix_f32!(m.subsurface_scattering);
561        mix_f32!(m.subsurface_color.x);
562        mix_f32!(m.subsurface_color.y);
563        mix_f32!(m.subsurface_color.z);
564
565        Self(h)
566    }
567}
568
569#[inline(always)]
570fn fnv1a_mix(mut hash: u64, val: u64) -> u64 {
571    // FNV-1a, 64-bit prime
572    const PRIME: u64 = 0x0000_0100_0000_01B3;
573    hash ^= val & 0xFF;
574    hash = hash.wrapping_mul(PRIME);
575    hash ^= (val >> 8) & 0xFF;
576    hash = hash.wrapping_mul(PRIME);
577    hash ^= (val >> 16) & 0xFF;
578    hash = hash.wrapping_mul(PRIME);
579    hash ^= (val >> 24) & 0xFF;
580    hash = hash.wrapping_mul(PRIME);
581    hash ^= (val >> 32) & 0xFF;
582    hash = hash.wrapping_mul(PRIME);
583    hash ^= (val >> 40) & 0xFF;
584    hash = hash.wrapping_mul(PRIME);
585    hash ^= (val >> 48) & 0xFF;
586    hash = hash.wrapping_mul(PRIME);
587    hash ^= (val >> 56) & 0xFF;
588    hash.wrapping_mul(PRIME)
589}
590
591// ─────────────────────────────────────────────────────────────────────────────
592// MaterialCache — deduplication + LRU eviction
593// ─────────────────────────────────────────────────────────────────────────────
594
595/// A slot in the material cache.
596#[derive(Debug)]
597struct CacheEntry {
598    material: PbrMaterial,
599    /// LRU generation counter — higher means more recently used.
600    last_used: u64,
601}
602
603/// Content-addressable, LRU-evicting cache for `PbrMaterial` values.
604///
605/// Materials are stored by their structural hash (`MaterialKey`).  When the
606/// cache is full the least-recently-used entry is evicted.
607pub struct MaterialCache {
608    entries: HashMap<MaterialKey, CacheEntry>,
609    capacity: usize,
610    clock: u64,
611}
612
613impl MaterialCache {
614    /// Create a new cache with the given maximum capacity.
615    pub fn new(capacity: usize) -> Self {
616        assert!(capacity > 0, "MaterialCache capacity must be at least 1");
617        Self {
618            entries: HashMap::with_capacity(capacity),
619            capacity,
620            clock: 0,
621        }
622    }
623
624    /// Insert (or refresh) a material.  Returns the `MaterialKey` for later
625    /// retrieval.  If the cache is full the LRU entry is evicted first.
626    pub fn insert(&mut self, material: PbrMaterial) -> MaterialKey {
627        let key = MaterialKey::from_material(&material);
628
629        if self.entries.contains_key(&key) {
630            // Refresh LRU timestamp.
631            self.clock += 1;
632            if let Some(e) = self.entries.get_mut(&key) {
633                e.last_used = self.clock;
634            }
635            return key;
636        }
637
638        if self.entries.len() >= self.capacity {
639            self.evict_lru();
640        }
641
642        self.clock += 1;
643        self.entries.insert(
644            key,
645            CacheEntry {
646                material,
647                last_used: self.clock,
648            },
649        );
650        key
651    }
652
653    /// Retrieve a previously inserted material by key.
654    pub fn get(&mut self, key: MaterialKey) -> Option<&PbrMaterial> {
655        self.clock += 1;
656        let clock = self.clock;
657        if let Some(e) = self.entries.get_mut(&key) {
658            e.last_used = clock;
659            Some(&e.material)
660        } else {
661            None
662        }
663    }
664
665    /// Peek at a material without updating the LRU counter.
666    pub fn peek(&self, key: MaterialKey) -> Option<&PbrMaterial> {
667        self.entries.get(&key).map(|e| &e.material)
668    }
669
670    /// Remove a material from the cache.
671    pub fn remove(&mut self, key: MaterialKey) -> bool {
672        self.entries.remove(&key).is_some()
673    }
674
675    /// Number of materials currently in the cache.
676    pub fn len(&self) -> usize {
677        self.entries.len()
678    }
679
680    /// Returns `true` when the cache contains no entries.
681    pub fn is_empty(&self) -> bool {
682        self.entries.is_empty()
683    }
684
685    /// Maximum number of entries the cache can hold.
686    pub fn capacity(&self) -> usize {
687        self.capacity
688    }
689
690    /// Remove all entries from the cache.
691    pub fn clear(&mut self) {
692        self.entries.clear();
693        self.clock = 0;
694    }
695
696    /// Evict the single least-recently-used entry.
697    fn evict_lru(&mut self) {
698        if self.entries.is_empty() {
699            return;
700        }
701        let lru_key = self
702            .entries
703            .iter()
704            .min_by_key(|(_, e)| e.last_used)
705            .map(|(k, _)| *k)
706            .unwrap();
707        self.entries.remove(&lru_key);
708    }
709
710    /// Pre-populate the cache with a slice of materials.  Returns the keys.
711    pub fn insert_batch(&mut self, materials: impl IntoIterator<Item = PbrMaterial>) -> Vec<MaterialKey> {
712        materials.into_iter().map(|m| self.insert(m)).collect()
713    }
714
715    /// Iterate over all cached materials in arbitrary order.
716    pub fn iter(&self) -> impl Iterator<Item = (MaterialKey, &PbrMaterial)> {
717        self.entries.iter().map(|(k, e)| (*k, &e.material))
718    }
719}
720
721// ─────────────────────────────────────────────────────────────────────────────
722// GlslMaterialBlock — GLSL uniform struct generator
723// ─────────────────────────────────────────────────────────────────────────────
724
725/// Generates GLSL source code for a `uniform` block that matches the layout of
726/// a `PbrMaterial`.  Use this to keep CPU-side structs and shaders in sync.
727pub struct GlslMaterialBlock;
728
729impl GlslMaterialBlock {
730    /// Returns a GLSL `uniform` block declaration string that mirrors
731    /// `PbrMaterial`.  The `binding` parameter sets the UBO binding point.
732    pub fn uniform_block(binding: u32) -> String {
733        format!(
734            r#"layout(std140, binding = {binding}) uniform PbrMaterialBlock {{
735    vec4  u_Albedo;
736    float u_Metallic;
737    float u_Roughness;
738    float u_EmissionScale;
739    float u_AlphaCutoff;
740    vec3  u_Emission;
741    float u_Ior;
742    float u_Clearcoat;
743    float u_ClearcoatRoughness;
744    float u_Anisotropy;
745    float u_AnisotropyDirectionX;
746    vec2  u_AnisotropyDirection;
747    float u_SubsurfaceScattering;
748    vec3  u_SubsurfaceColor;
749    // alpha_mode: 0=Opaque, 1=Mask, 2=Blend
750    int   u_AlphaMode;
751    // Texture presence flags (1 = bound, 0 = not bound)
752    int   u_HasAlbedoTex;
753    int   u_HasMetallicTex;
754    int   u_HasRoughnessTex;
755    int   u_HasNormalTex;
756    int   u_HasOcclusionTex;
757    int   u_HasEmissionTex;
758    int   u_DoubleSided;
759}};
760"#,
761            binding = binding
762        )
763    }
764
765    /// Returns GLSL sampler uniform declarations for all optional PBR textures.
766    pub fn sampler_uniforms(base_binding: u32) -> String {
767        let mut out = String::new();
768        let samplers = [
769            ("u_AlbedoTex", 0u32),
770            ("u_MetallicTex", 1),
771            ("u_RoughnessTex", 2),
772            ("u_NormalTex", 3),
773            ("u_OcclusionTex", 4),
774            ("u_EmissionTex", 5),
775        ];
776        for (name, offset) in samplers {
777            out.push_str(&format!(
778                "layout(binding = {}) uniform sampler2D {};\n",
779                base_binding + offset,
780                name
781            ));
782        }
783        out
784    }
785
786    /// Returns a GLSL function that reads all PBR inputs from the uniforms and
787    /// textures above, and returns them in local variables with the given name
788    /// prefix.
789    pub fn read_material_fn() -> &'static str {
790        r#"
791// Auto-generated by GlslMaterialBlock::read_material_fn()
792struct PbrInputs {
793    vec4  albedo;
794    float metallic;
795    float roughness;
796    vec3  emission;
797    vec3  normal;        // world-space, from normal map or vertex normal
798    float ao;            // ambient occlusion [0,1]
799    float alpha;
800    int   alphaMode;     // 0,1,2
801    float alphaCutoff;
802    float ior;
803    float clearcoat;
804    float clearcoatRoughness;
805    float anisotropy;
806    vec2  anisotropyDir;
807    float sss;
808    vec3  sssColor;
809};
810
811PbrInputs readPbrMaterial(vec2 uv, mat3 TBN) {
812    PbrInputs p;
813
814    // Albedo
815    p.albedo = u_Albedo;
816    if (u_HasAlbedoTex != 0) {
817        vec4 s = texture(u_AlbedoTex, uv);
818        // convert from sRGB to linear
819        s.rgb = pow(s.rgb, vec3(2.2));
820        p.albedo *= s;
821    }
822
823    // Metallic / roughness (packed: G=roughness, B=metallic)
824    p.metallic  = u_Metallic;
825    p.roughness = u_Roughness;
826    if (u_HasMetallicTex != 0) {
827        p.metallic  *= texture(u_MetallicTex,  uv).b;
828    }
829    if (u_HasRoughnessTex != 0) {
830        p.roughness *= texture(u_RoughnessTex, uv).g;
831    }
832    p.roughness = max(p.roughness, 0.04); // clamp for stability
833
834    // Normal map
835    p.normal = TBN[2]; // default to vertex normal
836    if (u_HasNormalTex != 0) {
837        vec3 n = texture(u_NormalTex, uv).rgb * 2.0 - 1.0;
838        p.normal = normalize(TBN * n);
839    }
840
841    // Ambient occlusion
842    p.ao = 1.0;
843    if (u_HasOcclusionTex != 0) {
844        p.ao = texture(u_OcclusionTex, uv).r;
845    }
846
847    // Emission
848    p.emission = u_Emission * u_EmissionScale;
849    if (u_HasEmissionTex != 0) {
850        vec3 e = texture(u_EmissionTex, uv).rgb;
851        e = pow(e, vec3(2.2));
852        p.emission *= e;
853    }
854
855    // Alpha
856    p.alpha       = p.albedo.a;
857    p.alphaMode   = u_AlphaMode;
858    p.alphaCutoff = u_AlphaCutoff;
859    if (p.alphaMode == 1 && p.alpha < p.alphaCutoff) discard;
860
861    // Extensions
862    p.ior                = u_Ior;
863    p.clearcoat          = u_Clearcoat;
864    p.clearcoatRoughness = u_ClearcoatRoughness;
865    p.anisotropy         = u_Anisotropy;
866    p.anisotropyDir      = u_AnisotropyDirection;
867    p.sss                = u_SubsurfaceScattering;
868    p.sssColor           = u_SubsurfaceColor;
869
870    return p;
871}
872"#
873    }
874
875    /// Generate a complete minimal PBR fragment shader source that uses all of
876    /// the blocks defined above.
877    pub fn fragment_shader_source(ubo_binding: u32, tex_base: u32) -> String {
878        let block = Self::uniform_block(ubo_binding);
879        let samplers = Self::sampler_uniforms(tex_base);
880        let read_fn = Self::read_material_fn();
881        format!(
882            r#"#version 460 core
883
884{block}
885{samplers}
886{read_fn}
887
888in  vec3 v_WorldPos;
889in  vec3 v_Normal;
890in  vec2 v_TexCoord;
891in  mat3 v_TBN;
892
893out vec4 FragColor;
894
895// Forward declaration — implemented in brdf.glsl
896vec3 evaluatePbr(PbrInputs p, vec3 worldPos, vec3 viewDir, vec3 lightDir, vec3 lightColor);
897
898void main() {{
899    PbrInputs p = readPbrMaterial(v_TexCoord, v_TBN);
900
901    vec3 viewDir  = normalize(-v_WorldPos); // assumes view at origin
902    vec3 lightDir = normalize(vec3(1.0, 2.0, 1.0));
903    vec3 lightCol = vec3(3.0);
904
905    vec3 color = evaluatePbr(p, v_WorldPos, viewDir, lightDir, lightCol);
906    color += p.emission;
907
908    // Reinhard tone-mapping + gamma
909    color = color / (color + vec3(1.0));
910    color = pow(color, vec3(1.0 / 2.2));
911
912    FragColor = vec4(color, p.alpha);
913}}
914"#
915        )
916    }
917}
918
919// ─────────────────────────────────────────────────────────────────────────────
920// Tests
921// ─────────────────────────────────────────────────────────────────────────────
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn default_material_is_grey_dielectric() {
929        let m = PbrMaterial::default();
930        assert_eq!(m.metallic, 0.0);
931        assert!((m.roughness - 0.5).abs() < 1e-6);
932        assert_eq!(m.alpha_mode, AlphaMode::Opaque);
933    }
934
935    #[test]
936    fn builder_methods_clamp_values() {
937        let m = PbrMaterial::new()
938            .with_metallic(1.5)
939            .with_roughness(-0.3);
940        assert_eq!(m.metallic, 1.0);
941        assert_eq!(m.roughness, 0.0);
942    }
943
944    #[test]
945    fn alpha_mode_variant_tags() {
946        assert_eq!(AlphaMode::Opaque.variant_tag(), "OPAQUE");
947        assert_eq!(AlphaMode::Mask(0.5).variant_tag(), "MASK");
948        assert_eq!(AlphaMode::Blend.variant_tag(), "BLEND");
949    }
950
951    #[test]
952    fn alpha_mode_needs_sorting() {
953        assert!(!AlphaMode::Opaque.needs_sorting());
954        assert!(!AlphaMode::Mask(0.5).needs_sorting());
955        assert!(AlphaMode::Blend.needs_sorting());
956    }
957
958    #[test]
959    fn texture_handle_round_trip() {
960        let h = TextureHandle::new(42);
961        assert_eq!(h.id(), 42);
962    }
963
964    #[test]
965    fn material_presets_are_valid() {
966        let presets: Vec<PbrMaterial> = vec![
967            MaterialPreset::gold(),
968            MaterialPreset::silver(),
969            MaterialPreset::copper(),
970            MaterialPreset::iron(),
971            MaterialPreset::rubber(),
972            MaterialPreset::plastic_glossy(),
973            MaterialPreset::glass(),
974            MaterialPreset::skin(),
975            MaterialPreset::water(),
976            MaterialPreset::stone(),
977            MaterialPreset::concrete(),
978            MaterialPreset::wood(),
979            MaterialPreset::fabric(),
980        ];
981        for p in &presets {
982            // Metallic must be in [0,1].
983            assert!((0.0..=1.0).contains(&p.metallic));
984            // Roughness must be in [0,1].
985            assert!((0.0..=1.0).contains(&p.roughness));
986        }
987    }
988
989    #[test]
990    fn material_cache_basic_insert_get() {
991        let mut cache = MaterialCache::new(4);
992        let mat = MaterialPreset::gold();
993        let key = cache.insert(mat);
994        assert!(cache.get(key).is_some());
995        assert_eq!(cache.len(), 1);
996    }
997
998    #[test]
999    fn material_cache_deduplication() {
1000        let mut cache = MaterialCache::new(8);
1001        let k1 = cache.insert(MaterialPreset::gold());
1002        let k2 = cache.insert(MaterialPreset::gold());
1003        assert_eq!(k1, k2);
1004        assert_eq!(cache.len(), 1);
1005    }
1006
1007    #[test]
1008    fn material_cache_lru_eviction() {
1009        let mut cache = MaterialCache::new(2);
1010        let k1 = cache.insert(MaterialPreset::gold());
1011        let _k2 = cache.insert(MaterialPreset::silver());
1012        // Access k1 to make it MRU — k2 becomes LRU.
1013        let _ = cache.get(k1);
1014        // Insert a third material; k2 should be evicted.
1015        let _k3 = cache.insert(MaterialPreset::copper());
1016        assert_eq!(cache.len(), 2);
1017        // k1 should still be present.
1018        assert!(cache.peek(k1).is_some());
1019    }
1020
1021    #[test]
1022    fn material_key_deterministic() {
1023        let m = MaterialPreset::stone();
1024        let k1 = MaterialKey::from_material(&m);
1025        let k2 = MaterialKey::from_material(&m);
1026        assert_eq!(k1, k2);
1027    }
1028
1029    #[test]
1030    fn material_key_differs_for_different_materials() {
1031        let k1 = MaterialKey::from_material(&MaterialPreset::gold());
1032        let k2 = MaterialKey::from_material(&MaterialPreset::silver());
1033        assert_ne!(k1, k2);
1034    }
1035
1036    #[test]
1037    fn glsl_block_contains_key_fields() {
1038        let src = GlslMaterialBlock::uniform_block(0);
1039        assert!(src.contains("u_Albedo"));
1040        assert!(src.contains("u_Metallic"));
1041        assert!(src.contains("u_Roughness"));
1042        assert!(src.contains("u_Ior"));
1043    }
1044
1045    #[test]
1046    fn glsl_fragment_shader_compiles_to_non_empty_string() {
1047        let src = GlslMaterialBlock::fragment_shader_source(0, 1);
1048        assert!(src.contains("#version 460 core"));
1049        assert!(src.len() > 500);
1050    }
1051
1052    #[test]
1053    fn f0_from_ior_dielectric() {
1054        let m = PbrMaterial::new().with_ior(1.5);
1055        let f0 = m.f0();
1056        // For pure dielectric (metallic=0) all channels should equal brdf result.
1057        let expected = brdf::fresnel::f0_from_ior(1.5);
1058        assert!((f0.x - expected).abs() < 1e-6);
1059    }
1060
1061    #[test]
1062    fn material_validate_warns_low_roughness() {
1063        let m = PbrMaterial::new().with_roughness(0.01);
1064        let warnings = m.validate();
1065        assert!(!warnings.is_empty());
1066    }
1067
1068    #[test]
1069    fn skin_preset_has_sss() {
1070        let m = MaterialPreset::skin();
1071        assert!(m.has_sss());
1072    }
1073
1074    #[test]
1075    fn glass_is_transparent() {
1076        let m = MaterialPreset::glass();
1077        assert!(m.is_transparent());
1078    }
1079}