Skip to main content

oxihuman_viewer/
material.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! PBR material definitions — metallic/roughness workflow.
5
6// ── Color4 ────────────────────────────────────────────────────────────────────
7
8/// RGBA colour with linear floating-point components.
9#[allow(dead_code)]
10#[derive(Debug, Clone, PartialEq)]
11pub struct Color4 {
12    pub r: f32,
13    pub g: f32,
14    pub b: f32,
15    pub a: f32,
16}
17
18impl Color4 {
19    /// Create a colour from explicit RGBA components.
20    #[allow(dead_code)]
21    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
22        Self { r, g, b, a }
23    }
24
25    /// Opaque white.
26    #[allow(dead_code)]
27    pub fn white() -> Self {
28        Self::new(1.0, 1.0, 1.0, 1.0)
29    }
30
31    /// Opaque black.
32    #[allow(dead_code)]
33    pub fn black() -> Self {
34        Self::new(0.0, 0.0, 0.0, 1.0)
35    }
36
37    /// Convert to a `[r, g, b, a]` array.
38    #[allow(dead_code)]
39    pub fn to_array(&self) -> [f32; 4] {
40        [self.r, self.g, self.b, self.a]
41    }
42}
43
44// ── PbrMaterial ───────────────────────────────────────────────────────────────
45
46/// PBR material with metallic/roughness workflow.
47#[allow(dead_code)]
48#[derive(Debug, Clone)]
49pub struct PbrMaterial {
50    pub name: String,
51    /// Base/albedo colour.
52    pub base_color: Color4,
53    /// Metallic factor in [0, 1].
54    pub metallic: f32,
55    /// Roughness factor in [0, 1].
56    pub roughness: f32,
57    /// HDR emissive colour (RGB, can exceed 1.0).
58    pub emissive: [f32; 3],
59    /// `None` = fully opaque; `Some(t)` = alpha-test with threshold `t`.
60    pub alpha_cutoff: Option<f32>,
61    /// Whether both faces should be rendered.
62    pub double_sided: bool,
63}
64
65impl PbrMaterial {
66    /// Realistic human skin (pinkish, low metallic, medium roughness).
67    #[allow(dead_code)]
68    pub fn default_skin() -> Self {
69        PbrMaterial {
70            name: "skin".to_string(),
71            base_color: Color4::new(0.85, 0.65, 0.55, 1.0),
72            metallic: 0.0,
73            roughness: 0.6,
74            emissive: [0.0, 0.0, 0.0],
75            alpha_cutoff: None,
76            double_sided: false,
77        }
78    }
79
80    /// Generic fabric (mid-grey, non-metallic, rough).
81    #[allow(dead_code)]
82    pub fn default_cloth() -> Self {
83        PbrMaterial {
84            name: "cloth".to_string(),
85            base_color: Color4::new(0.5, 0.5, 0.5, 1.0),
86            metallic: 0.0,
87            roughness: 0.9,
88            emissive: [0.0, 0.0, 0.0],
89            alpha_cutoff: None,
90            double_sided: false,
91        }
92    }
93
94    /// Metal surface (metallic=1, low roughness).
95    #[allow(dead_code)]
96    pub fn default_metal() -> Self {
97        PbrMaterial {
98            name: "metal".to_string(),
99            base_color: Color4::new(0.8, 0.8, 0.8, 1.0),
100            metallic: 1.0,
101            roughness: 0.2,
102            emissive: [0.0, 0.0, 0.0],
103            alpha_cutoff: None,
104            double_sided: false,
105        }
106    }
107
108    /// Glass (transparent, very smooth).
109    #[allow(dead_code)]
110    pub fn default_glass() -> Self {
111        PbrMaterial {
112            name: "glass".to_string(),
113            base_color: Color4::new(0.9, 0.95, 1.0, 0.15),
114            metallic: 0.0,
115            roughness: 0.05,
116            emissive: [0.0, 0.0, 0.0],
117            alpha_cutoff: Some(0.01),
118            double_sided: true,
119        }
120    }
121}
122
123// ── MaterialLibrary ───────────────────────────────────────────────────────────
124
125/// A flat list of [`PbrMaterial`] entries indexed by position.
126#[allow(dead_code)]
127#[derive(Debug, Clone, Default)]
128pub struct MaterialLibrary {
129    pub materials: Vec<PbrMaterial>,
130}
131
132impl MaterialLibrary {
133    /// Create an empty library.
134    #[allow(dead_code)]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Append a material and return its index.
140    #[allow(dead_code)]
141    pub fn add(&mut self, mat: PbrMaterial) -> usize {
142        let idx = self.materials.len();
143        self.materials.push(mat);
144        idx
145    }
146
147    /// Retrieve a material by index.
148    #[allow(dead_code)]
149    pub fn get(&self, idx: usize) -> Option<&PbrMaterial> {
150        self.materials.get(idx)
151    }
152
153    /// Find the first material whose name matches `name`.
154    #[allow(dead_code)]
155    pub fn by_name(&self, name: &str) -> Option<&PbrMaterial> {
156        self.materials.iter().find(|m| m.name == name)
157    }
158}
159
160// ── Free functions ────────────────────────────────────────────────────────────
161
162/// Serialize a material to a minimal GLTF `pbrMetallicRoughness` JSON snippet.
163#[allow(dead_code)]
164pub fn material_to_gltf_json(mat: &PbrMaterial) -> String {
165    let c = &mat.base_color;
166    let alpha_mode = if mat.alpha_cutoff.is_some() {
167        "\"MASK\""
168    } else if c.a < 1.0 {
169        "\"BLEND\""
170    } else {
171        "\"OPAQUE\""
172    };
173    let cutoff_fragment = mat
174        .alpha_cutoff
175        .map(|t| format!(", \"alphaCutoff\": {:.4}", t))
176        .unwrap_or_default();
177    format!(
178        r#"{{"name": "{name}", "pbrMetallicRoughness": {{"baseColorFactor": [{r:.4}, {g:.4}, {b:.4}, {a:.4}], "metallicFactor": {m:.4}, "roughnessFactor": {ro:.4}}}, "emissiveFactor": [{er:.4}, {eg:.4}, {eb:.4}], "alphaMode": {am}, "doubleSided": {ds}{cutoff}}}"#,
179        name = mat.name,
180        r = c.r,
181        g = c.g,
182        b = c.b,
183        a = c.a,
184        m = mat.metallic,
185        ro = mat.roughness,
186        er = mat.emissive[0],
187        eg = mat.emissive[1],
188        eb = mat.emissive[2],
189        am = alpha_mode,
190        ds = mat.double_sided,
191        cutoff = cutoff_fragment,
192    )
193}
194
195/// Convert a [`Color4`] to an `"#RRGGBBAA"` hex string.
196#[allow(dead_code)]
197pub fn color_to_hex(c: &Color4) -> String {
198    let r = (c.r.clamp(0.0, 1.0) * 255.0).round() as u8;
199    let g = (c.g.clamp(0.0, 1.0) * 255.0).round() as u8;
200    let b = (c.b.clamp(0.0, 1.0) * 255.0).round() as u8;
201    let a = (c.a.clamp(0.0, 1.0) * 255.0).round() as u8;
202    format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
203}
204
205/// Linearly interpolate between two materials component by component.
206///
207/// String fields (`name`, `double_sided`, `alpha_cutoff`) are taken from `a`.
208#[allow(dead_code)]
209pub fn lerp_material(a: &PbrMaterial, b: &PbrMaterial, t: f32) -> PbrMaterial {
210    let lerp_f32 = |x: f32, y: f32| x + (y - x) * t;
211    let lerp_color = |ca: &Color4, cb: &Color4| Color4 {
212        r: lerp_f32(ca.r, cb.r),
213        g: lerp_f32(ca.g, cb.g),
214        b: lerp_f32(ca.b, cb.b),
215        a: lerp_f32(ca.a, cb.a),
216    };
217    PbrMaterial {
218        name: a.name.clone(),
219        base_color: lerp_color(&a.base_color, &b.base_color),
220        metallic: lerp_f32(a.metallic, b.metallic),
221        roughness: lerp_f32(a.roughness, b.roughness),
222        emissive: [
223            lerp_f32(a.emissive[0], b.emissive[0]),
224            lerp_f32(a.emissive[1], b.emissive[1]),
225            lerp_f32(a.emissive[2], b.emissive[2]),
226        ],
227        alpha_cutoff: a.alpha_cutoff,
228        double_sided: a.double_sided,
229    }
230}
231
232// ── Tests ─────────────────────────────────────────────────────────────────────
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn color4_white() {
240        let c = Color4::white();
241        assert!((c.r - 1.0).abs() < 1e-6);
242        assert!((c.g - 1.0).abs() < 1e-6);
243        assert!((c.b - 1.0).abs() < 1e-6);
244        assert!((c.a - 1.0).abs() < 1e-6);
245    }
246
247    #[test]
248    fn color4_to_array() {
249        let c = Color4::new(0.1, 0.2, 0.3, 0.4);
250        let arr = c.to_array();
251        assert_eq!(arr, [0.1, 0.2, 0.3, 0.4]);
252    }
253
254    #[test]
255    fn color_to_hex_white() {
256        assert_eq!(color_to_hex(&Color4::white()), "#FFFFFFFF");
257    }
258
259    #[test]
260    fn color_to_hex_black() {
261        assert_eq!(color_to_hex(&Color4::black()), "#000000FF");
262    }
263
264    #[test]
265    fn default_skin_low_metallic() {
266        let skin = PbrMaterial::default_skin();
267        assert!(skin.metallic < 0.1);
268    }
269
270    #[test]
271    fn default_cloth_not_metallic() {
272        let cloth = PbrMaterial::default_cloth();
273        assert!((cloth.metallic - 0.0).abs() < 1e-6);
274    }
275
276    #[test]
277    fn default_metal_metallic_one() {
278        let metal = PbrMaterial::default_metal();
279        assert!((metal.metallic - 1.0).abs() < 1e-6);
280    }
281
282    #[test]
283    fn default_glass_has_alpha() {
284        let glass = PbrMaterial::default_glass();
285        assert!(glass.base_color.a < 1.0, "glass should be transparent");
286    }
287
288    #[test]
289    fn default_glass_has_alpha_cutoff() {
290        let glass = PbrMaterial::default_glass();
291        assert!(glass.alpha_cutoff.is_some());
292    }
293
294    #[test]
295    fn material_to_gltf_json_contains_pbr_key() {
296        let mat = PbrMaterial::default_skin();
297        let json = material_to_gltf_json(&mat);
298        assert!(json.contains("pbrMetallicRoughness"));
299    }
300
301    #[test]
302    fn library_add_and_get() {
303        let mut lib = MaterialLibrary::new();
304        let idx = lib.add(PbrMaterial::default_skin());
305        assert_eq!(idx, 0);
306        assert!(lib.get(0).is_some());
307    }
308
309    #[test]
310    fn library_by_name() {
311        let mut lib = MaterialLibrary::new();
312        lib.add(PbrMaterial::default_skin());
313        assert!(lib.by_name("skin").is_some());
314        assert!(lib.by_name("nonexistent").is_none());
315    }
316
317    #[test]
318    fn library_get_out_of_bounds() {
319        let lib = MaterialLibrary::new();
320        assert!(lib.get(99).is_none());
321    }
322
323    #[test]
324    fn lerp_material_midpoint() {
325        let a = PbrMaterial::default_skin(); // metallic=0.0
326        let b = PbrMaterial::default_metal(); // metallic=1.0
327        let mid = lerp_material(&a, &b, 0.5);
328        assert!((mid.metallic - 0.5).abs() < 1e-5);
329    }
330
331    #[test]
332    fn lerp_material_at_t0_matches_a() {
333        let a = PbrMaterial::default_cloth();
334        let b = PbrMaterial::default_metal();
335        let result = lerp_material(&a, &b, 0.0);
336        assert!((result.metallic - a.metallic).abs() < 1e-6);
337        assert!((result.roughness - a.roughness).abs() < 1e-6);
338    }
339
340    #[test]
341    fn lerp_material_at_t1_matches_b() {
342        let a = PbrMaterial::default_cloth();
343        let b = PbrMaterial::default_metal();
344        let result = lerp_material(&a, &b, 1.0);
345        assert!((result.metallic - b.metallic).abs() < 1e-6);
346        assert!((result.roughness - b.roughness).abs() < 1e-6);
347    }
348}