khora_core/asset/materials/standard.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Defines the standard PBR material with metallic-roughness workflow.
16
17use crate::{
18 asset::{Asset, AssetUUID, Material},
19 math::LinearRgba,
20};
21
22use super::AlphaMode;
23
24/// A physically-based rendering (PBR) material using the metallic-roughness workflow.
25///
26/// This is the primary material type for realistic 3D objects in Khora. It implements
27/// the standard PBR metallic-roughness model, which is widely used in modern game engines
28/// and 3D content creation tools (e.g., glTF 2.0 standard).
29///
30/// # PBR Properties
31///
32/// - **Base Color**: The surface's base color (albedo). For metals, this represents
33/// the reflectance; for dielectrics, this is the diffuse color.
34/// - **Metallic**: Controls whether the surface behaves like a metal (1.0) or a
35/// dielectric/non-metal (0.0). Intermediate values create unrealistic results.
36/// - **Roughness**: Controls how smooth (0.0) or rough (1.0) the surface appears.
37/// This affects specular reflections.
38///
39/// # Texture Maps
40///
41/// Supports all common PBR texture maps:
42/// - Base color/albedo
43/// - Metallic-roughness combined (metallic in B channel, roughness in G channel)
44/// - Normal map for surface detail
45/// - Ambient occlusion for subtle shadows
46/// - Emissive for self-illuminating areas
47///
48/// # Examples
49///
50/// ```
51/// use khora_core::asset::StandardMaterial;
52/// use khora_core::math::LinearRgba;
53///
54/// // Create a rough, non-metallic surface (e.g., concrete)
55/// let concrete = StandardMaterial {
56/// base_color: LinearRgba::new(0.5, 0.5, 0.5, 1.0),
57/// metallic: 0.0,
58/// roughness: 0.9,
59/// ..Default::default()
60/// };
61///
62/// // Create a smooth, metallic surface (e.g., polished gold)
63/// let gold = StandardMaterial {
64/// base_color: LinearRgba::new(1.0, 0.766, 0.336, 1.0),
65/// metallic: 1.0,
66/// roughness: 0.2,
67/// ..Default::default()
68/// };
69/// ```
70#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, bincode::Encode, bincode::Decode)]
71pub struct StandardMaterial {
72 /// The base color (albedo) of the material.
73 ///
74 /// For metals, this is the reflectance color at normal incidence.
75 /// For dielectrics, this is the diffuse color.
76 pub base_color: LinearRgba,
77
78 /// Optional texture for the base color.
79 ///
80 /// If present, this texture's RGB values are multiplied with `base_color`.
81 /// The alpha channel can be used for transparency when combined with appropriate `alpha_mode`.
82 pub base_color_texture: Option<AssetUUID>,
83
84 /// The metallic factor (0.0 = dielectric, 1.0 = metal).
85 ///
86 /// This value should typically be either 0.0 or 1.0 for physically accurate results.
87 /// Intermediate values can be used for artistic effects but are not physically based.
88 pub metallic: f32,
89
90 /// The roughness factor (0.0 = smooth, 1.0 = rough).
91 ///
92 /// Controls the microsurface detail of the material, affecting specular reflection.
93 /// Lower values produce sharp, mirror-like reflections; higher values produce
94 /// more diffuse reflections.
95 pub roughness: f32,
96
97 /// Optional texture for metallic and roughness values.
98 ///
99 /// **glTF 2.0 convention**: Blue channel = metallic, Green channel = roughness.
100 /// If present, the texture values are multiplied with the `metallic` and `roughness` factors.
101 pub metallic_roughness_texture: Option<AssetUUID>,
102
103 /// Optional normal map for adding surface detail.
104 ///
105 /// Normal maps perturb the surface normal to create the illusion of fine geometric
106 /// detail without adding actual geometry. Stored in tangent space.
107 pub normal_map: Option<AssetUUID>,
108
109 /// Optional ambient occlusion map.
110 ///
111 /// AO maps darken areas that should receive less ambient light, such as crevices
112 /// and contact points. The red channel is sampled and multiplied into the
113 /// indirect (ambient/IBL) term only — it never attenuates direct lighting.
114 pub occlusion_map: Option<AssetUUID>,
115
116 /// The emissive color of the material.
117 ///
118 /// Allows the material to emit light. This color is added to the final shaded result
119 /// and is not affected by lighting. Useful for self-illuminating objects like screens,
120 /// neon signs, or magical effects.
121 pub emissive: LinearRgba,
122
123 /// Optional texture for emissive color.
124 ///
125 /// If present, this texture's RGB values are multiplied with `emissive`.
126 pub emissive_texture: Option<AssetUUID>,
127
128 /// The alpha blending mode for this material.
129 ///
130 /// Determines how transparency is handled. See [`AlphaMode`] for details.
131 pub alpha_mode: AlphaMode,
132
133 /// The alpha cutoff threshold when using `AlphaMode::Mask`.
134 ///
135 /// Fragments with alpha values below this threshold are discarded.
136 /// Typically set to 0.5. Only used when `alpha_mode` is `AlphaMode::Mask`.
137 pub alpha_cutoff: f32,
138
139 /// Whether the material should be rendered double-sided.
140 ///
141 /// If `false`, back-facing triangles are culled for better performance.
142 /// If `true`, both sides of the geometry are rendered.
143 pub double_sided: bool,
144}
145
146impl Default for StandardMaterial {
147 fn default() -> Self {
148 Self {
149 base_color: LinearRgba::new(0.8, 0.8, 0.8, 1.0), // Light gray
150 base_color_texture: None,
151 metallic: 0.0, // Non-metallic by default
152 roughness: 0.5, // Medium roughness
153 metallic_roughness_texture: None,
154 normal_map: None,
155 occlusion_map: None,
156 emissive: LinearRgba::new(0.0, 0.0, 0.0, 1.0), // No emission
157 emissive_texture: None,
158 alpha_mode: AlphaMode::Opaque,
159 alpha_cutoff: 0.5,
160 double_sided: false,
161 }
162 }
163}
164
165impl Asset for StandardMaterial {}
166impl Material for StandardMaterial {
167 fn base_color(&self) -> crate::math::LinearRgba {
168 self.base_color
169 }
170
171 fn emissive_color(&self) -> crate::math::LinearRgba {
172 self.emissive
173 }
174
175 fn metallic(&self) -> f32 {
176 self.metallic
177 }
178
179 fn roughness(&self) -> f32 {
180 self.roughness
181 }
182
183 fn base_color_texture(&self) -> Option<AssetUUID> {
184 self.base_color_texture
185 }
186
187 fn metallic_roughness_texture(&self) -> Option<AssetUUID> {
188 self.metallic_roughness_texture
189 }
190
191 fn normal_map(&self) -> Option<AssetUUID> {
192 self.normal_map
193 }
194
195 fn emissive_texture(&self) -> Option<AssetUUID> {
196 self.emissive_texture
197 }
198
199 fn occlusion_map(&self) -> Option<AssetUUID> {
200 self.occlusion_map
201 }
202
203 fn alpha_mode(&self) -> AlphaMode {
204 self.alpha_mode
205 }
206
207 fn double_sided(&self) -> bool {
208 self.double_sided
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn test_standard_material_default() {
218 let material = StandardMaterial::default();
219
220 assert_eq!(material.base_color, LinearRgba::new(0.8, 0.8, 0.8, 1.0));
221 assert_eq!(material.metallic, 0.0);
222 assert_eq!(material.roughness, 0.5);
223 assert_eq!(material.emissive, LinearRgba::new(0.0, 0.0, 0.0, 1.0));
224 assert_eq!(material.alpha_mode, AlphaMode::Opaque);
225 assert_eq!(material.alpha_cutoff, 0.5);
226 assert!(!material.double_sided);
227 assert!(material.base_color_texture.is_none());
228 assert!(material.metallic_roughness_texture.is_none());
229 assert!(material.normal_map.is_none());
230 assert!(material.occlusion_map.is_none());
231 assert!(material.emissive_texture.is_none());
232 }
233
234 #[test]
235 fn test_standard_material_custom_creation() {
236 let material = StandardMaterial {
237 base_color: LinearRgba::new(1.0, 0.0, 0.0, 1.0),
238 metallic: 1.0,
239 roughness: 0.2,
240 ..Default::default()
241 };
242
243 assert_eq!(material.base_color, LinearRgba::new(1.0, 0.0, 0.0, 1.0));
244 assert_eq!(material.metallic, 1.0);
245 assert_eq!(material.roughness, 0.2);
246 }
247
248 #[test]
249 fn test_standard_material_metallic_range() {
250 // Test common metallic values
251 let dielectric = StandardMaterial {
252 metallic: 0.0,
253 ..Default::default()
254 };
255 assert_eq!(dielectric.metallic, 0.0);
256
257 let metal = StandardMaterial {
258 metallic: 1.0,
259 ..Default::default()
260 };
261 assert_eq!(metal.metallic, 1.0);
262 }
263
264 #[test]
265 fn test_standard_material_roughness_range() {
266 // Test roughness extremes
267 let smooth = StandardMaterial {
268 roughness: 0.0,
269 ..Default::default()
270 };
271 assert_eq!(smooth.roughness, 0.0);
272
273 let rough = StandardMaterial {
274 roughness: 1.0,
275 ..Default::default()
276 };
277 assert_eq!(rough.roughness, 1.0);
278 }
279
280 #[test]
281 fn test_standard_material_alpha_modes() {
282 let opaque = StandardMaterial {
283 alpha_mode: AlphaMode::Opaque,
284 ..Default::default()
285 };
286 assert_eq!(opaque.alpha_mode, AlphaMode::Opaque);
287
288 let masked = StandardMaterial {
289 alpha_mode: AlphaMode::Mask(0.5),
290 alpha_cutoff: 0.5,
291 ..Default::default()
292 };
293 assert_eq!(masked.alpha_mode, AlphaMode::Mask(0.5));
294 assert_eq!(masked.alpha_cutoff, 0.5);
295
296 let blend = StandardMaterial {
297 alpha_mode: AlphaMode::Blend,
298 ..Default::default()
299 };
300 assert_eq!(blend.alpha_mode, AlphaMode::Blend);
301 }
302
303 #[test]
304 fn test_standard_material_double_sided() {
305 let single_sided = StandardMaterial {
306 double_sided: false,
307 ..Default::default()
308 };
309 assert!(!single_sided.double_sided);
310
311 let double_sided = StandardMaterial {
312 double_sided: true,
313 ..Default::default()
314 };
315 assert!(double_sided.double_sided);
316 }
317
318 #[test]
319 fn test_standard_material_clone() {
320 let original = StandardMaterial {
321 base_color: LinearRgba::new(0.5, 0.5, 0.5, 1.0),
322 metallic: 0.8,
323 roughness: 0.3,
324 ..Default::default()
325 };
326
327 let cloned = original.clone();
328 assert_eq!(cloned.base_color, original.base_color);
329 assert_eq!(cloned.metallic, original.metallic);
330 assert_eq!(cloned.roughness, original.roughness);
331 }
332}