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