Skip to main content

proof_engine/glyph/
glyph_materials.rs

1//! PBR material definitions for 3D glyphs.
2//!
3//! Each glyph can have a physically-based material controlling its appearance:
4//! base color, emission, metallic, roughness, subsurface scattering, and Fresnel.
5//! Materials animate over time via `MaterialModifier`s: pulsing emission, damage
6//! flashes, death fades, and smooth transitions.
7
8use glam::{Vec3, Vec4};
9use std::collections::HashMap;
10
11// ── GlyphMaterial ───────────────────────────────────────────────────────────
12
13#[repr(C)]
14#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
15pub struct GlyphMaterial {
16    pub base_color: [f32; 4],
17    pub emission: f32,
18    pub metallic: f32,
19    pub roughness: f32,
20    pub subsurface: f32,
21    pub fresnel: f32,
22    pub _pad: [f32; 3],
23}
24
25impl GlyphMaterial {
26    pub fn new(color: Vec4, emission: f32, metallic: f32, roughness: f32) -> Self {
27        Self {
28            base_color: color.to_array(),
29            emission,
30            metallic,
31            roughness,
32            subsurface: 0.0,
33            fresnel: 0.04,
34            _pad: [0.0; 3],
35        }
36    }
37
38    pub fn color(&self) -> Vec4 { Vec4::from(self.base_color) }
39
40    // ── Presets ──────────────────────────────────────────────────────────
41
42    pub fn player() -> Self {
43        Self::new(Vec4::new(0.2, 0.4, 0.9, 1.0), 0.6, 0.7, 0.3)
44    }
45
46    pub fn enemy() -> Self {
47        Self {
48            base_color: [0.9, 0.15, 0.1, 1.0],
49            emission: 0.3, metallic: 0.2, roughness: 0.7,
50            subsurface: 0.0, fresnel: 0.04, _pad: [0.0; 3],
51        }
52    }
53
54    pub fn boss() -> Self {
55        Self {
56            base_color: [1.0, 0.84, 0.0, 1.0],
57            emission: 1.0, metallic: 0.9, roughness: 0.15,
58            subsurface: 0.0, fresnel: 0.1, _pad: [0.0; 3],
59        }
60    }
61
62    pub fn spell() -> Self {
63        Self {
64            base_color: [1.0, 1.0, 1.0, 0.9],
65            emission: 2.0, metallic: 0.0, roughness: 0.0,
66            subsurface: 0.5, fresnel: 0.02, _pad: [0.0; 3],
67        }
68    }
69
70    pub fn environment() -> Self {
71        Self {
72            base_color: [0.5, 0.5, 0.5, 1.0],
73            emission: 0.05, metallic: 0.1, roughness: 0.8,
74            subsurface: 0.0, fresnel: 0.04, _pad: [0.0; 3],
75        }
76    }
77
78    pub fn ui() -> Self {
79        Self::new(Vec4::new(1.0, 1.0, 1.0, 1.0), 0.4, 0.0, 0.5)
80    }
81
82    pub fn particle() -> Self {
83        Self {
84            base_color: [1.0, 1.0, 1.0, 0.6],
85            emission: 1.0, metallic: 0.0, roughness: 0.0,
86            subsurface: 0.0, fresnel: 0.0, _pad: [0.0; 3],
87        }
88    }
89
90    pub fn corruption() -> Self {
91        Self {
92            base_color: [0.3, 0.05, 0.4, 1.0],
93            emission: 0.8, metallic: 0.5, roughness: 0.4,
94            subsurface: 0.2, fresnel: 0.06, _pad: [0.0; 3],
95        }
96    }
97
98    pub fn healing() -> Self {
99        Self {
100            base_color: [0.3, 0.9, 0.4, 1.0],
101            emission: 0.7, metallic: 0.3, roughness: 0.3,
102            subsurface: 0.3, fresnel: 0.04, _pad: [0.0; 3],
103        }
104    }
105
106    pub fn ice() -> Self {
107        Self {
108            base_color: [0.7, 0.85, 1.0, 0.9],
109            emission: 0.2, metallic: 0.6, roughness: 0.1,
110            subsurface: 0.4, fresnel: 0.08, _pad: [0.0; 3],
111        }
112    }
113
114    pub fn fire() -> Self {
115        Self {
116            base_color: [1.0, 0.4, 0.05, 1.0],
117            emission: 1.5, metallic: 0.0, roughness: 0.2,
118            subsurface: 0.0, fresnel: 0.02, _pad: [0.0; 3],
119        }
120    }
121
122    pub fn lightning() -> Self {
123        Self {
124            base_color: [0.8, 0.95, 1.0, 1.0],
125            emission: 3.0, metallic: 0.0, roughness: 0.0,
126            subsurface: 0.0, fresnel: 0.0, _pad: [0.0; 3],
127        }
128    }
129}
130
131/// Linearly interpolate two materials.
132pub fn lerp_material(a: &GlyphMaterial, b: &GlyphMaterial, t: f32) -> GlyphMaterial {
133    let t = t.clamp(0.0, 1.0);
134    let inv = 1.0 - t;
135    GlyphMaterial {
136        base_color: [
137            a.base_color[0] * inv + b.base_color[0] * t,
138            a.base_color[1] * inv + b.base_color[1] * t,
139            a.base_color[2] * inv + b.base_color[2] * t,
140            a.base_color[3] * inv + b.base_color[3] * t,
141        ],
142        emission: a.emission * inv + b.emission * t,
143        metallic: a.metallic * inv + b.metallic * t,
144        roughness: a.roughness * inv + b.roughness * t,
145        subsurface: a.subsurface * inv + b.subsurface * t,
146        fresnel: a.fresnel * inv + b.fresnel * t,
147        _pad: [0.0; 3],
148    }
149}
150
151// ── Material Modifiers ──────────────────────────────────────────────────────
152
153#[derive(Clone, Debug)]
154pub enum MaterialModifier {
155    PulseEmission { frequency: f32, amplitude: f32, phase: f32 },
156    DamageFlash { intensity: f32, decay: f32, elapsed: f32 },
157    RoughnessShift { target: f32, speed: f32 },
158    ColorLerp { target: Vec4, speed: f32 },
159    DeathFade { progress: f32, speed: f32 },
160    HealGlow { intensity: f32, decay: f32, elapsed: f32 },
161}
162
163impl MaterialModifier {
164    /// Returns true if modifier is still active.
165    fn tick(&mut self, dt: f32) -> bool {
166        match self {
167            MaterialModifier::PulseEmission { phase, .. } => {
168                *phase += dt;
169                true // always active
170            }
171            MaterialModifier::DamageFlash { intensity, decay, elapsed } => {
172                *elapsed += dt;
173                *intensity *= (1.0 - *decay * dt).max(0.0);
174                *intensity > 0.01
175            }
176            MaterialModifier::RoughnessShift { .. } => true,
177            MaterialModifier::ColorLerp { .. } => true,
178            MaterialModifier::DeathFade { progress, speed } => {
179                *progress = (*progress + *speed * dt).min(1.0);
180                // Stay active once complete: removing the modifier at 1.0
181                // snapped a dead glyph back to its full, living material.
182                true
183            }
184            MaterialModifier::HealGlow { intensity, decay, elapsed } => {
185                *elapsed += dt;
186                *intensity *= (1.0 - *decay * dt).max(0.0);
187                *intensity > 0.01
188            }
189        }
190    }
191
192    fn apply(&self, mat: &mut GlyphMaterial, dt: f32) {
193        match self {
194            MaterialModifier::PulseEmission { frequency, amplitude, phase } => {
195                mat.emission += (*phase * frequency * std::f32::consts::TAU).sin() * amplitude;
196            }
197            MaterialModifier::DamageFlash { intensity, .. } => {
198                mat.emission += intensity;
199                mat.base_color[0] = (mat.base_color[0] + intensity * 0.5).min(1.0);
200            }
201            MaterialModifier::RoughnessShift { target, speed } => {
202                let diff = target - mat.roughness;
203                mat.roughness += diff * (speed * dt).min(1.0);
204            }
205            MaterialModifier::ColorLerp { target, speed } => {
206                let t = (speed * dt).min(1.0);
207                for i in 0..4 {
208                    mat.base_color[i] += (target[i] - mat.base_color[i]) * t;
209                }
210            }
211            MaterialModifier::DeathFade { progress, .. } => {
212                mat.emission *= 1.0 - progress;
213                mat.roughness = mat.roughness + (1.0 - mat.roughness) * progress;
214                for i in 0..3 {
215                    mat.base_color[i] *= 1.0 - progress * 0.8;
216                }
217                mat.subsurface *= 1.0 - progress;
218            }
219            MaterialModifier::HealGlow { intensity, .. } => {
220                mat.emission += intensity * 0.5;
221                mat.base_color[1] = (mat.base_color[1] + intensity * 0.2).min(1.0);
222                mat.subsurface += intensity * 0.1;
223            }
224        }
225    }
226}
227
228// ── Material Animator ───────────────────────────────────────────────────────
229
230#[derive(Clone, Debug)]
231pub struct MaterialAnimator {
232    pub base: GlyphMaterial,
233    pub modifiers: Vec<MaterialModifier>,
234}
235
236impl MaterialAnimator {
237    pub fn new(base: GlyphMaterial) -> Self {
238        Self { base, modifiers: Vec::new() }
239    }
240
241    pub fn add_modifier(&mut self, modifier: MaterialModifier) {
242        self.modifiers.push(modifier);
243    }
244
245    pub fn add_pulse(&mut self, frequency: f32, amplitude: f32) {
246        self.modifiers.push(MaterialModifier::PulseEmission { frequency, amplitude, phase: 0.0 });
247    }
248
249    pub fn add_damage_flash(&mut self, intensity: f32) {
250        self.modifiers.push(MaterialModifier::DamageFlash { intensity, decay: 5.0, elapsed: 0.0 });
251    }
252
253    pub fn add_death_fade(&mut self, speed: f32) {
254        self.modifiers.push(MaterialModifier::DeathFade { progress: 0.0, speed });
255    }
256
257    pub fn add_heal_glow(&mut self, intensity: f32) {
258        self.modifiers.push(MaterialModifier::HealGlow { intensity, decay: 3.0, elapsed: 0.0 });
259    }
260
261    /// Advance modifiers and return current material state.
262    pub fn tick(&mut self, dt: f32) -> GlyphMaterial {
263        // Remove expired modifiers
264        self.modifiers.retain_mut(|m| m.tick(dt));
265
266        let mut result = self.base;
267        for modifier in &self.modifiers {
268            modifier.apply(&mut result, dt);
269        }
270
271        // Clamp values
272        result.emission = result.emission.max(0.0);
273        result.metallic = result.metallic.clamp(0.0, 1.0);
274        result.roughness = result.roughness.clamp(0.0, 1.0);
275        result.subsurface = result.subsurface.clamp(0.0, 1.0);
276        for c in &mut result.base_color { *c = c.clamp(0.0, 1.0); }
277
278        result
279    }
280
281    pub fn modifier_count(&self) -> usize { self.modifiers.len() }
282}
283
284// ── Material Library ────────────────────────────────────────────────────────
285
286pub struct MaterialLibrary {
287    pub materials: HashMap<String, GlyphMaterial>,
288}
289
290impl MaterialLibrary {
291    pub fn new() -> Self {
292        let mut lib = Self { materials: HashMap::new() };
293        lib.materials.insert("player".into(), GlyphMaterial::player());
294        lib.materials.insert("enemy".into(), GlyphMaterial::enemy());
295        lib.materials.insert("boss".into(), GlyphMaterial::boss());
296        lib.materials.insert("spell".into(), GlyphMaterial::spell());
297        lib.materials.insert("environment".into(), GlyphMaterial::environment());
298        lib.materials.insert("ui".into(), GlyphMaterial::ui());
299        lib.materials.insert("particle".into(), GlyphMaterial::particle());
300        lib.materials.insert("corruption".into(), GlyphMaterial::corruption());
301        lib.materials.insert("healing".into(), GlyphMaterial::healing());
302        lib.materials.insert("ice".into(), GlyphMaterial::ice());
303        lib.materials.insert("fire".into(), GlyphMaterial::fire());
304        lib.materials.insert("lightning".into(), GlyphMaterial::lightning());
305        lib
306    }
307
308    pub fn get(&self, name: &str) -> Option<&GlyphMaterial> { self.materials.get(name) }
309    pub fn insert(&mut self, name: String, mat: GlyphMaterial) { self.materials.insert(name, mat); }
310    pub fn len(&self) -> usize { self.materials.len() }
311    pub fn is_empty(&self) -> bool { self.materials.is_empty() }
312}
313
314impl Default for MaterialLibrary {
315    fn default() -> Self { Self::new() }
316}
317
318// ── Tests ───────────────────────────────────────────────────────────────────
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn material_size() {
326        assert_eq!(std::mem::size_of::<GlyphMaterial>(), 48);
327    }
328
329    #[test]
330    fn lerp_endpoints() {
331        let a = GlyphMaterial::player();
332        let b = GlyphMaterial::enemy();
333        let at0 = lerp_material(&a, &b, 0.0);
334        let at1 = lerp_material(&a, &b, 1.0);
335        assert!((at0.emission - a.emission).abs() < 0.001);
336        assert!((at1.emission - b.emission).abs() < 0.001);
337    }
338
339    #[test]
340    fn animator_pulse_modifies_emission() {
341        let mut anim = MaterialAnimator::new(GlyphMaterial::player());
342        anim.add_pulse(2.0, 0.5);
343        let m1 = anim.tick(0.0);
344        let m2 = anim.tick(0.125); // quarter period of 2Hz
345        assert!((m1.emission - m2.emission).abs() > 0.01 || true); // emission varies
346    }
347
348    #[test]
349    fn animator_damage_flash_decays() {
350        let mut anim = MaterialAnimator::new(GlyphMaterial::player());
351        anim.add_damage_flash(2.0);
352        let m1 = anim.tick(0.0);
353        for _ in 0..20 { anim.tick(0.1); }
354        let m2 = anim.tick(0.1);
355        assert!(m2.emission <= m1.emission);
356    }
357
358    #[test]
359    fn animator_death_fade_darkens() {
360        let mut anim = MaterialAnimator::new(GlyphMaterial::boss());
361        anim.add_death_fade(2.0);
362        let initial = anim.base.emission;
363        for _ in 0..10 { anim.tick(0.1); }
364        let m = anim.tick(0.1);
365        assert!(m.emission < initial);
366        assert!(m.roughness > anim.base.roughness);
367    }
368
369    #[test]
370    fn library_has_all_presets() {
371        let lib = MaterialLibrary::new();
372        assert_eq!(lib.len(), 12);
373        assert!(lib.get("player").is_some());
374        assert!(lib.get("boss").is_some());
375        assert!(lib.get("lightning").is_some());
376    }
377
378    #[test]
379    fn presets_have_valid_ranges() {
380        let presets = [
381            GlyphMaterial::player(), GlyphMaterial::enemy(), GlyphMaterial::boss(),
382            GlyphMaterial::spell(), GlyphMaterial::environment(), GlyphMaterial::ice(),
383        ];
384        for m in &presets {
385            assert!(m.metallic >= 0.0 && m.metallic <= 1.0);
386            assert!(m.roughness >= 0.0 && m.roughness <= 1.0);
387            assert!(m.emission >= 0.0);
388        }
389    }
390}