Skip to main content

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}