1pub mod brdf;
8pub mod atmosphere;
9pub mod probe;
10
11use glam::{Vec2, Vec3, Vec4};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct TextureHandle(pub u32);
22
23impl TextureHandle {
24 #[inline]
26 pub const fn new(id: u32) -> Self {
27 Self(id)
28 }
29
30 #[inline]
32 pub const fn id(self) -> u32 {
33 self.0
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
43pub enum AlphaMode {
44 Opaque,
46 Mask(f32),
49 Blend,
51}
52
53impl Default for AlphaMode {
54 fn default() -> Self {
55 AlphaMode::Opaque
56 }
57}
58
59impl AlphaMode {
60 pub fn cutoff(self) -> Option<f32> {
62 match self {
63 AlphaMode::Mask(c) => Some(c),
64 _ => None,
65 }
66 }
67
68 pub fn needs_sorting(self) -> bool {
70 matches!(self, AlphaMode::Blend)
71 }
72
73 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#[derive(Debug, Clone)]
93pub struct PbrMaterial {
94 pub albedo: Vec4,
97 pub albedo_texture: Option<TextureHandle>,
99
100 pub metallic: f32,
103 pub metallic_texture: Option<TextureHandle>,
105
106 pub roughness: f32,
108 pub roughness_texture: Option<TextureHandle>,
110
111 pub normal_texture: Option<TextureHandle>,
114 pub occlusion_texture: Option<TextureHandle>,
116
117 pub emission: Vec3,
120 pub emission_scale: f32,
122 pub emission_texture: Option<TextureHandle>,
124
125 pub alpha_mode: AlphaMode,
128 pub alpha_cutoff: f32,
131
132 pub double_sided: bool,
136
137 pub ior: f32,
140
141 pub clearcoat: f32,
144 pub clearcoat_roughness: f32,
146
147 pub anisotropy: f32,
150 pub anisotropy_direction: Vec2,
153
154 pub subsurface_scattering: f32,
157 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 pub fn new() -> Self {
192 Self::default()
193 }
194
195 pub fn with_albedo(mut self, albedo: Vec4) -> Self {
197 self.albedo = albedo;
198 self
199 }
200
201 pub fn with_metallic(mut self, m: f32) -> Self {
203 self.metallic = m.clamp(0.0, 1.0);
204 self
205 }
206
207 pub fn with_roughness(mut self, r: f32) -> Self {
209 self.roughness = r.clamp(0.0, 1.0);
210 self
211 }
212
213 pub fn with_ior(mut self, ior: f32) -> Self {
215 self.ior = ior.max(1.0);
216 self
217 }
218
219 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 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 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 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 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 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 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 pub fn is_transparent(&self) -> bool {
270 self.alpha_mode.needs_sorting() || self.albedo.w < 1.0
271 }
272
273 pub fn has_clearcoat(&self) -> bool {
275 self.clearcoat > 1e-5
276 }
277
278 pub fn has_anisotropy(&self) -> bool {
280 self.anisotropy > 1e-5
281 }
282
283 pub fn has_sss(&self) -> bool {
285 self.subsurface_scattering > 1e-5
286 }
287
288 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 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
334pub struct MaterialPreset;
342
343impl MaterialPreset {
344 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) }
352
353 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
494pub struct MaterialKey(u64);
495
496impl MaterialKey {
497 pub fn from_material(m: &PbrMaterial) -> Self {
501 let mut h: u64 = 0xcbf2_9ce4_8422_2325; 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 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#[derive(Debug)]
597struct CacheEntry {
598 material: PbrMaterial,
599 last_used: u64,
601}
602
603pub struct MaterialCache {
608 entries: HashMap<MaterialKey, CacheEntry>,
609 capacity: usize,
610 clock: u64,
611}
612
613impl MaterialCache {
614 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 pub fn insert(&mut self, material: PbrMaterial) -> MaterialKey {
627 let key = MaterialKey::from_material(&material);
628
629 if self.entries.contains_key(&key) {
630 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 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 pub fn peek(&self, key: MaterialKey) -> Option<&PbrMaterial> {
667 self.entries.get(&key).map(|e| &e.material)
668 }
669
670 pub fn remove(&mut self, key: MaterialKey) -> bool {
672 self.entries.remove(&key).is_some()
673 }
674
675 pub fn len(&self) -> usize {
677 self.entries.len()
678 }
679
680 pub fn is_empty(&self) -> bool {
682 self.entries.is_empty()
683 }
684
685 pub fn capacity(&self) -> usize {
687 self.capacity
688 }
689
690 pub fn clear(&mut self) {
692 self.entries.clear();
693 self.clock = 0;
694 }
695
696 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 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 pub fn iter(&self) -> impl Iterator<Item = (MaterialKey, &PbrMaterial)> {
717 self.entries.iter().map(|(k, e)| (*k, &e.material))
718 }
719}
720
721pub struct GlslMaterialBlock;
728
729impl GlslMaterialBlock {
730 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 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 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 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#[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 assert!((0.0..=1.0).contains(&p.metallic));
984 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 let _ = cache.get(k1);
1014 let _k3 = cache.insert(MaterialPreset::copper());
1016 assert_eq!(cache.len(), 2);
1017 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 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}