Skip to main content

kvas_engine/
materials.rs

1//! PBR material sets: finds the maps that belong to a model or texture by file name, and builds the
2//! texture keys the renderer understands.
3//!
4//! Supported naming (the part before the suffix is the shared stem):
5//! * Unity / HDRP packs: `<stem>_AlbedoTransparency.png`, `_MetallicSmoothness.png` (R = metallic,
6//!   A = smoothness), `_Normal.png`.
7//! * Substance / generic: `_BaseColor`, `_Normal` (or `_NormalGL`), `_Roughness`, `_Metallic`,
8//!   `_AmbientOcclusion` / `_AO`.
9
10use crate::assets::Assets;
11
12/// Texture key prefix: a packed occlusion / roughness / metallic texture composed from separate maps.
13pub const ORM_PREFIX: &str = "orm:";
14
15/// Discovered texture maps for one physically based material.
16#[derive(Clone, Debug, Default, PartialEq)]
17pub struct PbrSet {
18    /// Base-colour texture asset path, or empty if none was discovered.
19    pub albedo: String,
20    /// OpenGL tangent-space normal map path, or empty if none was discovered.
21    pub normal: String,
22    /// Key for `MeshRenderer.orm_map` (empty if the set has no roughness/metal/AO information).
23    pub orm: String,
24}
25
26/// Builds an `orm:` key. Each part is an asset path; `smooth` is a Unity MetallicSmoothness map
27/// (metal from red, smoothness from alpha) that supplies both metal and roughness.
28pub fn orm_key(ao: &str, rough: &str, metal: &str, smooth: &str) -> String {
29    if ao.is_empty() && rough.is_empty() && metal.is_empty() && smooth.is_empty() {
30        return String::new();
31    }
32    format!("{ORM_PREFIX}ao={ao}|rough={rough}|metal={metal}|smooth={smooth}")
33}
34
35/// Splits an `orm:` key back into `(ao, rough, metal, smooth)`.
36pub fn parse_orm_key(key: &str) -> Option<[String; 4]> {
37    let body = key.strip_prefix(ORM_PREFIX)?;
38    let mut out: [String; 4] = Default::default();
39    for part in body.split('|') {
40        let (name, value) = part.split_once('=')?;
41        match name {
42            "ao" => out[0] = value.into(),
43            "rough" => out[1] = value.into(),
44            "metal" => out[2] = value.into(),
45            "smooth" => out[3] = value.into(),
46            _ => return None,
47        }
48    }
49    Some(out)
50}
51
52fn lower(s: &str) -> String {
53    s.to_ascii_lowercase()
54}
55
56/// Maps found next to `path` (a model or any texture of the set).
57pub fn discover(assets: &Assets, path: &str) -> PbrSet {
58    let (dir, file) = path.rsplit_once('/').map_or(("", path), |(d, f)| (d, f));
59    let stem = file.rsplit_once('.').map_or(file, |(s, _)| s);
60    // Strip a map suffix so any member of a set finds the others.
61    let stem = [
62        "_albedotransparency",
63        "_basecolor",
64        "_albedo",
65        "_diffuse",
66        "_normal",
67        "_metallicsmoothness",
68        "_roughness",
69        "_metallic",
70        "_ambientocclusion",
71        "_ao",
72        "_height",
73    ]
74    .iter()
75    .find_map(|suffix| lower(stem).strip_suffix(suffix).map(|s| stem[..s.len()].to_string()))
76    .unwrap_or_else(|| stem.to_string());
77    let stem_l = lower(&stem);
78    let siblings: Vec<String> = assets.list().into_iter().filter(|p| p.rsplit_once('/').map_or("", |(d, _)| d) == dir).collect();
79    let find = |suffixes: &[&str]| -> String {
80        for s in suffixes {
81            let want = format!("{stem_l}{s}");
82            let hit = siblings.iter().find(|p| {
83                let f = lower(p.rsplit('/').next().unwrap_or(p));
84                f.rsplit_once('.').is_some_and(|(name, ext)| name == want && matches!(ext, "png" | "jpg" | "jpeg"))
85            });
86            if let Some(h) = hit {
87                return h.clone();
88            }
89        }
90        String::new()
91    };
92    let albedo = find(&["_albedotransparency", "_basecolor", "_albedo", "_diffuse"]);
93    let normal = find(&["_normal", "_normalgl"]);
94    let (smooth, rough, metal, ao) =
95        (find(&["_metallicsmoothness"]), find(&["_roughness"]), find(&["_metallic"]), find(&["_ambientocclusion", "_ao"]));
96    PbrSet { albedo, normal, orm: orm_key(&ao, &rough, &metal, &smooth) }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn orm_keys_roundtrip() {
105        let key = orm_key("a b/ao.png", "r.png", "", "");
106        assert_eq!(parse_orm_key(&key), Some(["a b/ao.png".into(), "r.png".into(), "".into(), "".into()]));
107        assert_eq!(orm_key("", "", "", ""), "");
108    }
109
110    #[test]
111    fn discovers_unity_and_substance_sets() {
112        let dir = std::env::temp_dir().join(format!("kvas_pbr_{}", std::process::id()));
113        std::fs::create_dir_all(dir.join("m")).unwrap();
114        for f in [
115            "trn_Barrel.fbx",
116            "trn_Barrel_AlbedoTransparency.png",
117            "trn_Barrel_Normal.png",
118            "trn_Barrel_MetallicSmoothness.png",
119            "Brick_BaseColor.png",
120            "Brick_Roughness.png",
121            "Brick_AmbientOcclusion.png",
122            "Brick_Normal.png",
123        ] {
124            std::fs::write(dir.join("m").join(f), b"x").unwrap();
125        }
126        let assets = Assets::Dir(dir.clone());
127        let unity = discover(&assets, "m/trn_Barrel.fbx");
128        assert_eq!((unity.albedo.as_str(), unity.normal.as_str()), ("m/trn_Barrel_AlbedoTransparency.png", "m/trn_Barrel_Normal.png"));
129        assert!(unity.orm.contains("smooth=m/trn_Barrel_MetallicSmoothness.png"));
130        let sub = discover(&assets, "m/Brick_Normal.png");
131        assert_eq!(sub.albedo, "m/Brick_BaseColor.png");
132        assert!(sub.orm.contains("rough=m/Brick_Roughness.png") && sub.orm.contains("ao=m/Brick_AmbientOcclusion.png"));
133        std::fs::remove_dir_all(dir).ok();
134    }
135}