Skip to main content

concinnity_asset/
material.rs

1// Surface-material schema.
2
3use crate::{AssetId, ShaderHandle, TextureHandle, de_opt_shader_handle, de_opt_texture_handle};
4
5/// A Material bundles the surface parameters that control how a [Prop](#prop) is
6/// lit and shaded.
7///
8/// Reference it from a [Prop](#prop)'s `material` field. The `material` field takes
9/// precedence over the older `texture` field.
10///
11/// ```rust
12/// # use concinnity_asset::Material;
13/// Material {
14///     roughness: 0.85,
15///     metallic: 0.0,
16///     ..Default::default()
17/// };
18/// ```
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20#[serde(default)]
21pub struct Material {
22    /// Asset identity; injected via `inject_name`. Not part of `args`.
23    #[serde(skip)]
24    pub asset_id: AssetId,
25    /// The [Texture](#texture) asset used as the base colour (albedo) map.
26    #[serde(deserialize_with = "de_opt_texture_handle")]
27    pub albedo: Option<TextureHandle>,
28    /// The [Texture](#texture) asset used as a tangent-space normal map.
29    #[serde(deserialize_with = "de_opt_texture_handle")]
30    pub normal_map: Option<TextureHandle>,
31    /// The [Texture](#texture) asset used as an emissive map. Multiplied by
32    /// `emissive_factor` to drive the glow; when omitted, only the scalar
33    /// `emissive_factor` is used. Pair a textured emissive with an
34    /// `emissive_factor` above 1 to make the bright parts bloom.
35    #[serde(deserialize_with = "de_opt_texture_handle")]
36    pub emissive_map: Option<TextureHandle>,
37    /// The [Texture](#texture) asset used as a packed surface map: green =
38    /// roughness, blue = metalness. When present it overrides the scalar
39    /// `roughness` and `metallic` per-texel; when omitted those scalars are
40    /// used. The red channel is reserved and not read as ambient occlusion:
41    /// packed maps in the wild (glTF metallic-roughness, FBX specular maps)
42    /// leave red empty, so treating it as occlusion would darken indirect
43    /// light to black. Ambient occlusion comes from the screen-space pass.
44    #[serde(deserialize_with = "de_opt_texture_handle")]
45    pub orm_map: Option<TextureHandle>,
46    /// Perceptual roughness in [0, 1]. 0 = mirror, 1 = fully diffuse.
47    /// Controls the width of the specular highlight.
48    pub roughness: f32,
49    /// Metallic factor in [0, 1]. 0 = dielectric (plastic/stone), 1 = metal.
50    /// Metallic surfaces tint their reflections with the albedo colour and show
51    /// almost no diffuse; dielectrics keep a neutral, dim reflection.
52    pub metallic: f32,
53    /// Linear-space RGB multiplier applied to the albedo sample. Useful for
54    /// tinting a shared texture without a separate asset (e.g. coloured brick).
55    pub tint: [f32; 3],
56    /// Additive emission colour in linear space. Non-zero values make the
57    /// surface appear to glow independently of the scene lighting.
58    pub emissive_factor: [f32; 3],
59    /// Macro-variation strength in [0, 1]. When non-zero, a large-scale,
60    /// world-space noise modulates the albedo so a tiled texture on a big
61    /// surface (terrain, floors) stops reading as an obvious repeating grid.
62    /// 0 disables it.
63    pub macro_variation: f32,
64    /// Terrain-shading blend in [0, 1]. When non-zero, the albedo and normal
65    /// are sampled by a world-space projection blended from the three world
66    /// axes (instead of a single UV lookup), and the surface shifts toward a
67    /// darker rocky tint on steep slopes. This removes the obvious UV-stretch
68    /// banding that heightfield ground shows when stretched across a big mesh,
69    /// and gives "grass on top, rock on the cliffs" variation for free.
70    /// 0 disables it.
71    pub terrain_blend: f32,
72    /// Optional second albedo [Texture](#texture) for the slope-based terrain
73    /// blend. When present, the steep / cliff regions sample this texture and
74    /// blend with the primary `albedo` over the flat regions, using the
75    /// surface's up-facing component (softened by a per-pixel noise so the
76    /// transition doesn't read as a clean line). Without it, a rocky-tint
77    /// multiplier is applied to the primary texture instead. Only used when
78    /// `terrain_blend > 0`.
79    #[serde(deserialize_with = "de_opt_texture_handle")]
80    pub albedo_secondary: Option<TextureHandle>,
81    /// Tangent-space normal map paired with `albedo_secondary`. Only used when
82    /// both that field and `terrain_blend` are set.
83    #[serde(deserialize_with = "de_opt_texture_handle")]
84    pub normal_secondary: Option<TextureHandle>,
85    /// Sharpness of the slope-based blend in [0, 1]. 0 = wide soft
86    /// gradient between the two layers; 1 = nearly hard cliff edge.
87    /// Default `0.5` matches the "smooth but visible" transition AAA
88    /// terrain materials typically tune to.
89    pub secondary_blend_sharpness: f32,
90    /// Alpha-cutout threshold in [0, 1]. When non-zero, a texel whose `albedo`
91    /// alpha falls below it is discarded outright, punching a hole in the
92    /// surface: this is how foliage, chain-link, and decal cards are drawn as
93    /// one opaque quad. 0 (the default) disables the test and keeps every texel.
94    /// Cutout is not glass: the surface still renders in the opaque pass, so
95    /// leave `transparent` and `see_through` off.
96    pub alpha_cutoff: f32,
97    /// Surface opacity in [0, 1]. 1 = fully opaque (the default). Only
98    /// meaningful when `transparent` is set: it drives how much of the scene
99    /// behind the surface shows through the glass.
100    pub opacity: f32,
101    /// When true, the surface is a translucent dielectric (glass): it renders
102    /// in the engine's transparent pass instead of the opaque pass, refracting
103    /// and reflecting the scene rather than writing solid colour + depth. The
104    /// importer sets this for materials it detects as glass; authored materials
105    /// can opt in directly. Defaults to false (opaque).
106    pub transparent: bool,
107    /// When true, the glass is rendered as genuinely see-through: the scene
108    /// behind it shows through with a sharp per-pixel reflection (requires a
109    /// ray-tracing-capable GPU). When false (the default), a `transparent`
110    /// surface still renders as low-roughness reflective glass that hides
111    /// whatever is behind it. See-through only looks right when the space behind
112    /// the glass is actually modelled, so it is opt-in per material. Setting it
113    /// implies `transparent`.
114    pub see_through: bool,
115    /// The [Shader](#shader) asset that shades surfaces using this material.
116    /// When omitted, the world's default shader is used. Referencing a shader
117    /// from a material ties that shader's lifetime to the material's: a shader
118    /// referenced only by scene-exclusive materials loads and unloads with the
119    /// scene.
120    #[serde(deserialize_with = "de_opt_shader_handle")]
121    pub shader: Option<ShaderHandle>,
122}
123
124impl Default for Material {
125    fn default() -> Self {
126        Self {
127            asset_id: AssetId::default(),
128            albedo: None,
129            normal_map: None,
130            emissive_map: None,
131            orm_map: None,
132            roughness: 0.8,
133            metallic: 0.0,
134            tint: [1.0, 1.0, 1.0],
135            emissive_factor: [0.0, 0.0, 0.0],
136            macro_variation: 0.0,
137            terrain_blend: 0.0,
138            albedo_secondary: None,
139            normal_secondary: None,
140            secondary_blend_sharpness: 0.5,
141            alpha_cutoff: 0.0,
142            opacity: 1.0,
143            transparent: false,
144            see_through: false,
145            shader: None,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn a_blank_material_is_an_opaque_untextured_dielectric() {
156        let m = Material::default();
157        assert_eq!(m.roughness, 0.8);
158        assert_eq!(m.metallic, 0.0);
159        assert_eq!(m.tint, [1.0, 1.0, 1.0]);
160        assert_eq!(m.emissive_factor, [0.0, 0.0, 0.0]);
161        assert_eq!(m.opacity, 1.0);
162        assert!(!m.transparent);
163        assert!(!m.see_through);
164        // Zero alpha cutoff means "no cutout", not "discard everything".
165        assert_eq!(m.alpha_cutoff, 0.0);
166        assert_eq!(m.macro_variation, 0.0);
167        assert_eq!(m.terrain_blend, 0.0);
168        assert_eq!(m.secondary_blend_sharpness, 0.5);
169        for map in [&m.albedo, &m.normal_map, &m.emissive_map, &m.orm_map] {
170            assert!(map.is_none());
171        }
172        // No shader means the engine's own main-pass program draws it.
173        assert!(m.shader.is_none());
174    }
175
176    #[test]
177    fn every_texture_slot_resolves_through_its_own_reference() {
178        crate::test_support::install_resolvers();
179        let m: Material = serde_json::from_str(
180            r#"{"albedo":"tex_a","normal_map":"tex_nm","emissive_map":"tex_em",
181                "orm_map":"tex_orm","albedo_secondary":"tex_b","normal_secondary":"tex_nb",
182                "shader":"water_shader"}"#,
183        )
184        .unwrap();
185        assert_eq!(m.albedo, Some(TextureHandle(5)));
186        assert_eq!(m.normal_map, Some(TextureHandle(6)));
187        assert_eq!(m.emissive_map, Some(TextureHandle(6)));
188        assert_eq!(m.orm_map, Some(TextureHandle(7)));
189        assert_eq!(m.albedo_secondary, Some(TextureHandle(5)));
190        assert_eq!(m.normal_secondary, Some(TextureHandle(6)));
191        assert_eq!(m.shader, Some(ShaderHandle(12)));
192    }
193
194    #[test]
195    fn a_glass_material_round_trips_through_postcard() {
196        let m: Material = serde_json::from_str(
197            r#"{"roughness":0.05,"metallic":1,"tint":[0.8,0.9,1],"emissive_factor":[2,2,2],
198                "alpha_cutoff":0.5,"opacity":0.3,"transparent":true,"see_through":true,
199                "macro_variation":0.4,"terrain_blend":0.6,"secondary_blend_sharpness":0.9}"#,
200        )
201        .unwrap();
202        let bytes = postcard::to_allocvec(&m).unwrap();
203        let back: Material = postcard::from_bytes(&bytes).unwrap();
204        assert_eq!(back.roughness, 0.05);
205        assert_eq!(back.metallic, 1.0);
206        assert_eq!(back.tint, [0.8, 0.9, 1.0]);
207        assert_eq!(back.emissive_factor, [2.0, 2.0, 2.0]);
208        assert_eq!(back.alpha_cutoff, 0.5);
209        assert_eq!(back.opacity, 0.3);
210        assert!(back.transparent);
211        assert!(back.see_through);
212        assert_eq!(back.macro_variation, 0.4);
213        assert_eq!(back.terrain_blend, 0.6);
214        assert_eq!(back.secondary_blend_sharpness, 0.9);
215        assert_eq!(back.asset_id, AssetId::default());
216    }
217}