Skip to main content

primase_core/
pbr.rs

1//! The `pbr.*` dialect: a baked metal-rough material, glTF 2.0's material model. The common lowering target for the
2//! glTF and USD writers and a destination's: a pass lowers a source's material (`layered_material`, ...) to a
3//! [`PbrMaterial`] on the same material entity, which keeps its dialect components.
4//!
5//! Meanings follow glTF 2.0 ยง3.9 exactly, so the glTF writer is a direct mapping: factors are linear, colour textures
6//! sRGB, packed maps linear, and [`Default`] is glTF's default material.
7
8use crate::json::JsonError;
9use crate::world::{Component, Entity};
10use iridia_interface::Filters;
11use serde::Serialize;
12use serde_json::Value;
13/// How UVs outside 0-1 sample a texture along one axis.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default, Serialize)]
15pub enum Wrap {
16    #[default]
17    Repeat,
18    /// Clamp to the edge texel.
19    Clamp,
20    Mirror,
21}
22/// A transform of the texture coordinates a slot samples with, exactly glTF's `KHR_texture_transform` (in the world's
23/// texture convention: origin top-left, v down): `uv' = offset + R(rotation) * (scale * uv)`, `R(a)` taking (u, v) to
24/// (u cos a - v sin a, u sin a + v cos a). No reader sets a rotation yet.
25#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
26pub struct UvTransform {
27    pub offset: [f32; 2],
28    pub scale: [f32; 2],
29    /// Radians.
30    pub rotation: f32,
31}
32impl UvTransform {
33    /// No change: offset 0, scale 1, rotation 0.
34    pub const IDENTITY: UvTransform = UvTransform {
35        offset: [0.0; 2],
36        scale: [1.0; 2],
37        rotation: 0.0,
38    };
39
40    /// The transform applied to one coordinate pair, in f32.
41    pub fn apply(&self, [u, v]: [f32; 2]) -> [f32; 2] {
42        let (u, v) = (u * self.scale[0], v * self.scale[1]);
43        let (u, v) = if self.rotation == 0.0 {
44            (u, v)
45        } else {
46            let (s, c) = self.rotation.sin_cos();
47            (u * c - v * s, u * s + v * c)
48        };
49        [u + self.offset[0], v + self.offset[1]]
50    }
51
52    /// A tiling and offset given with the texture origin bottom-left (`uv * scale + offset`, v up: one source's
53    /// `_MainTex_ST`-style uniform), for a reader that stores v as 1 - v and flips the texture's rows.
54    ///
55    /// The source samples its texture at v_s = v * scale_v + offset_v. In the world both the coordinate and the image
56    /// are flipped, so a world coordinate w = 1 - v must sample at 1 - v_s = 1 - (1 - w) * scale_v - offset_v =
57    /// w * scale_v + (1 - scale_v - offset_v): the scale is the same and the offset along v is 1 - scale_v - offset_v.
58    /// u is unchanged.
59    pub fn from_bottom_left(scale: [f32; 2], offset: [f32; 2]) -> UvTransform {
60        UvTransform {
61            offset: [offset[0], 1.0 - scale[1] - offset[1]],
62            scale,
63            rotation: 0.0,
64        }
65    }
66}
67/// A texture used by a material slot.
68#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
69pub struct TextureRef {
70    /// The texture entity (`core.texture` or `core.texture_file`), or a `core.texture_absent`
71    /// ([`crate::TextureAbsent`]): a texture the source names for this slot that the read could not give. A consumer
72    /// treats such a slot as bound to no texture and counts it by the absence's reason; a viewer shows the absence.
73    pub texture: Entity,
74    /// Which `TexCoord(n)` stream it samples.
75    pub texcoord: u8,
76    /// Wrap along u, then v.
77    pub wrap: [Wrap; 2],
78    /// Applied to the coordinates before sampling; `None` is the identity.
79    pub transform: Option<UvTransform>,
80    /// The sampler's filters, as the source states them.
81    pub filters: Filters,
82}
83impl TextureRef {
84    /// Sampling `TexCoord(0)` with repeat wrapping, untransformed, no filter stated.
85    pub fn new(texture: Entity) -> Self {
86        TextureRef {
87            texture,
88            texcoord: 0,
89            wrap: [Wrap::Repeat; 2],
90            transform: None,
91            filters: Filters::default(),
92        }
93    }
94}
95/// Whether a material is lit.
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
97pub enum Shading {
98    /// Lit by the scene's lights (glTF's metal-rough model).
99    #[default]
100    Lit,
101    /// Not lit, exactly `KHR_materials_unlit`: the colour drawn is `base_color` x `base_color_texture` x the vertex
102    /// colour (`COLOR_0`), whatever lights the scene. Its other slots stay as the source stated them (a writer may
103    /// emit them as the fallback glTF prescribes); a renderer draws none of them.
104    Unlit,
105}
106/// How the base colour's alpha is used.
107#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize)]
108pub enum AlphaMode {
109    /// Alpha ignored.
110    #[default]
111    Opaque,
112    /// Alpha tested: drawn where alpha >= `cutoff` (finite and >= 0, as glTF's `alphaCutoff`).
113    Mask { cutoff: f32 },
114    /// Alpha blended over what is behind.
115    Blend,
116}
117/// Specular reflectance, exactly glTF's `KHR_materials_specular`: the dielectric part's F0 is
118/// `min(0.04 * color, 1) * factor` (glTF's default index of refraction, 1.5). Metals ignore it. What a game's own
119/// specular model means in these terms is its lowering pass's named approximation.
120#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
121pub struct Specular {
122    /// 0-1 (`specularFactor`), multiplied into `texture`'s A channel.
123    pub factor: f32,
124    /// Linear: A the specular strength (`specularTexture`).
125    pub texture: Option<TextureRef>,
126    /// Linear RGB, each >= 0 (`specularColorFactor`), multiplied into `color_texture`.
127    pub color: [f32; 3],
128    /// sRGB colour (`specularColorTexture`).
129    pub color_texture: Option<TextureRef>,
130}
131impl Default for Specular {
132    /// The extension's defaults: factor 1, colour white, no textures.
133    fn default() -> Self {
134        Specular {
135            factor: 1.0,
136            texture: None,
137            color: [1.0; 3],
138            color_texture: None,
139        }
140    }
141}
142/// A baked metal-rough material (component `pbr.material`).
143#[derive(Clone, Debug, PartialEq, Serialize)]
144pub struct PbrMaterial {
145    /// Linear RGBA multiplied into `base_color_texture`: RGB finite and >= 0, past 1 where the source's colour is
146    /// (an HDR colour a shader multiplies as it is, as Unity's `_BaseColor` may be); alpha within 0-1 (verified). A
147    /// consumer that cannot hold a channel past 1 refuses it or says so by name, never clamping it silently.
148    pub base_color: [f32; 4],
149    /// sRGB colour, alpha in A.
150    pub base_color_texture: Option<TextureRef>,
151    /// 0-1, multiplied into the texture's B channel.
152    pub metallic: f32,
153    /// 0-1, multiplied into the texture's G channel.
154    pub roughness: f32,
155    /// Linear: G roughness, B metallic (R may hold occlusion: point `occlusion_texture` at the same entity).
156    pub metallic_roughness_texture: Option<TextureRef>,
157    /// Linear: R ambient occlusion.
158    pub occlusion_texture: Option<TextureRef>,
159    /// 0-1: how much of the occlusion applies.
160    pub occlusion_strength: f32,
161    /// Tangent-space normal map in glTF's convention (OpenGL green up, Z stored); a pass converts others first.
162    pub normal_texture: Option<TextureRef>,
163    /// Scales the normal map's X and Y; finite.
164    pub normal_scale: f32,
165    /// Linear RGB, each channel within 0-1 (verified), multiplied into `emissive_texture`.
166    pub emissive: [f32; 3],
167    /// sRGB colour.
168    pub emissive_texture: Option<TextureRef>,
169    /// Multiplies the emission (`KHR_materials_emissive_strength`): finite, >= 0, 1 by default. Emission brighter
170    /// than 1 keeps `emissive` within 0-1 (normalised by its largest channel) and carries the rest here, so a source's
171    /// radiance is kept rather than clamped.
172    pub emissive_strength: f32,
173    pub alpha_mode: AlphaMode,
174    /// Back faces drawn too (and lit with the flipped normal).
175    pub double_sided: bool,
176    /// The source's specular reflectance; `None`: it says nothing (glTF's default, the extension not written).
177    pub specular: Option<Specular>,
178    /// Lit or not. Whether what it draws casts shadows is the drawing entity's ([`crate::Shadows`]), not the
179    /// material's.
180    pub shading: Shading,
181}
182impl Component for PbrMaterial {
183    const NAME: &'static str = "pbr.material";
184    const GEOMETRY: crate::Geometry = crate::Geometry::None;
185    /// 2: `emissive_strength`; 3: `cast_shadows`; 4: `specular`; 5: `shading` and each texture's `filters`,
186    /// `cast_shadows` gone (shadow casting is [`crate::Shadows`]', per drawn entity).
187    const VERSION: u32 = 5;
188    fn to_json(&self) -> Result<Option<Value>, JsonError> {
189        crate::json::to_value(self).map(Some)
190    }
191    /// Every range its fields document: the colours, factors and strengths, the mask cutoff and each texture's
192    /// coordinate transform.
193    fn verify(&self, _e: Entity, _w: &crate::World) -> Result<(), String> {
194        let unit = |x: f32| (0.0..=1.0).contains(&x);
195        let [r, g, b, a] = self.base_color;
196        if ![r, g, b].iter().all(|&c| c.is_finite() && c >= 0.0) || !unit(a) {
197            return Err(format!(
198                "base colour {:?} is not finite and >= 0 with alpha within 0-1",
199                self.base_color
200            ));
201        }
202        for (name, x) in [
203            ("metallic", self.metallic),
204            ("roughness", self.roughness),
205            ("occlusion strength", self.occlusion_strength),
206        ] {
207            if !unit(x) {
208                return Err(format!("{name} {x} is not within 0-1"));
209            }
210        }
211        if !self.normal_scale.is_finite() {
212            return Err(format!("normal scale {} is not finite", self.normal_scale));
213        }
214        if let AlphaMode::Mask { cutoff } = self.alpha_mode
215            && !(cutoff.is_finite() && cutoff >= 0.0)
216        {
217            return Err(format!("mask cutoff {cutoff} is not finite and >= 0"));
218        }
219        for (slot, r) in self.slots() {
220            if let Some(t) = r.transform
221                && !t
222                    .offset
223                    .iter()
224                    .chain(&t.scale)
225                    .chain([&t.rotation])
226                    .all(|x| x.is_finite())
227            {
228                return Err(format!("{slot:?} texture transform {t:?} is not finite"));
229            }
230        }
231        if !self.emissive.iter().all(|c| (0.0..=1.0).contains(c)) {
232            return Err(format!(
233                "emissive {:?} is not within 0-1 (brighter emission is carried by emissive_strength)",
234                self.emissive
235            ));
236        }
237        if !self.emissive_strength.is_finite() || self.emissive_strength < 0.0 {
238            return Err(format!(
239                "emissive strength {} is not finite and >= 0",
240                self.emissive_strength
241            ));
242        }
243        if let Some(s) = &self.specular {
244            if !(s.factor.is_finite() && (0.0..=1.0).contains(&s.factor)) {
245                return Err(format!("specular factor {} is not in 0-1", s.factor));
246            }
247            if s.color.iter().any(|c| !c.is_finite() || *c < 0.0) {
248                return Err(format!("specular colour {:?} is not finite and >= 0", s.color));
249            }
250        }
251        Ok(())
252    }
253    fn references(&self, out: &mut Vec<Entity>) {
254        out.extend(self.textures().map(|r| r.texture));
255    }
256}
257impl Default for PbrMaterial {
258    /// glTF's default material: white, fully metallic and rough, opaque, single-sided, lit.
259    fn default() -> Self {
260        PbrMaterial {
261            base_color: [1.0; 4],
262            base_color_texture: None,
263            metallic: 1.0,
264            roughness: 1.0,
265            metallic_roughness_texture: None,
266            occlusion_texture: None,
267            occlusion_strength: 1.0,
268            normal_texture: None,
269            normal_scale: 1.0,
270            emissive: [0.0; 3],
271            emissive_texture: None,
272            emissive_strength: 1.0,
273            alpha_mode: AlphaMode::Opaque,
274            double_sided: false,
275            specular: None,
276            shading: Shading::Lit,
277        }
278    }
279}
280/// A texture slot of a [`PbrMaterial`], in glTF's order.
281#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
282pub enum TextureSlot {
283    BaseColor,
284    MetallicRoughness,
285    Normal,
286    Occlusion,
287    Emissive,
288    /// The specular extension's strength (`specularTexture`).
289    Specular,
290    /// The specular extension's colour (`specularColorTexture`).
291    SpecularColor,
292}
293impl TextureSlot {
294    /// Its name in a report.
295    pub fn name(self) -> &'static str {
296        match self {
297            TextureSlot::BaseColor => "base colour",
298            TextureSlot::MetallicRoughness => "metallic-roughness",
299            TextureSlot::Normal => "normal",
300            TextureSlot::Occlusion => "occlusion",
301            TextureSlot::Emissive => "emissive",
302            TextureSlot::Specular => "specular",
303            TextureSlot::SpecularColor => "specular colour",
304        }
305    }
306}
307/// Every texture slot of material `$m` with each slot's field borrowed as `$b` (nothing: by value; `&mut`: mutably),
308/// in glTF's order: the one list [`PbrMaterial::slots`] and [`PbrMaterial::slots_mut`] both read.
309macro_rules! texture_slots {
310    ($m:expr, $($b:tt)*) => {{
311        let m = $m;
312        let (specular, color) = match $($b)* m.specular {
313            Some(s) => (Some($($b)* s.texture), Some($($b)* s.color_texture)),
314            None => (None, None),
315        };
316        [
317            (TextureSlot::BaseColor, Some($($b)* m.base_color_texture)),
318            (TextureSlot::MetallicRoughness, Some($($b)* m.metallic_roughness_texture)),
319            (TextureSlot::Normal, Some($($b)* m.normal_texture)),
320            (TextureSlot::Occlusion, Some($($b)* m.occlusion_texture)),
321            (TextureSlot::Emissive, Some($($b)* m.emissive_texture)),
322            (TextureSlot::Specular, specular),
323            (TextureSlot::SpecularColor, color),
324        ]
325    }};
326}
327impl PbrMaterial {
328    /// Every texture slot that is set, with its slot, in glTF's order: base colour, metallic-roughness, normal,
329    /// occlusion, emissive, then the specular extension's strength and colour.
330    pub fn slots(&self) -> impl Iterator<Item = (TextureSlot, TextureRef)> {
331        texture_slots!(self,)
332            .into_iter()
333            .filter_map(|(slot, r)| r.flatten().map(|r| (slot, r)))
334    }
335    /// [`PbrMaterial::slots`], each mutably.
336    pub fn slots_mut(&mut self) -> impl Iterator<Item = (TextureSlot, &mut TextureRef)> {
337        texture_slots!(self, &mut)
338            .into_iter()
339            .filter_map(|(slot, r)| r.and_then(Option::as_mut).map(|r| (slot, r)))
340    }
341    /// Every texture slot that is set ([`PbrMaterial::slots`] without the slots).
342    pub fn textures(&self) -> impl Iterator<Item = TextureRef> {
343        self.slots().map(|(_, r)| r)
344    }
345    /// Every texture slot that is set, mutably ([`PbrMaterial::slots_mut`] without the slots).
346    pub fn textures_mut(&mut self) -> impl Iterator<Item = &mut TextureRef> {
347        self.slots_mut().map(|(_, r)| r)
348    }
349}
350#[cfg(test)]
351mod tests {
352    use super::{AlphaMode, PbrMaterial, TextureRef, UvTransform};
353    use crate::{Component, World};
354
355    /// Each documented range is verified: one field out of it at a time is refused, naming it.
356    #[test]
357    fn verify_covers_every_documented_range() {
358        let mut w = World::new();
359        let e = w.spawn();
360        let texture = w.spawn();
361        let ok = PbrMaterial::default();
362        assert_eq!(ok.verify(e, &w), Ok(()));
363        let refused = |m: PbrMaterial, what: &str| {
364            let err = m.verify(e, &w).unwrap_err();
365            assert!(err.contains(what), "{what}: {err}");
366        };
367        // a colour past 1 is the source's (an HDR colour), held as it is
368        let bright = PbrMaterial {
369            base_color: [26.5, 0.5, 0.0, 1.0],
370            ..ok.clone()
371        };
372        assert_eq!(bright.verify(e, &w), Ok(()));
373        for base_color in [
374            [-0.1, 0.0, 0.0, 1.0],
375            [f32::INFINITY, 0.0, 0.0, 1.0],
376            [0.0, f32::NAN, 0.0, 1.0],
377            [1.0, 1.0, 1.0, 1.5],
378        ] {
379            refused(
380                PbrMaterial {
381                    base_color,
382                    ..ok.clone()
383                },
384                "base colour",
385            );
386        }
387        refused(
388            PbrMaterial {
389                metallic: -0.1,
390                ..ok.clone()
391            },
392            "metallic",
393        );
394        refused(
395            PbrMaterial {
396                roughness: f32::NAN,
397                ..ok.clone()
398            },
399            "roughness",
400        );
401        refused(
402            PbrMaterial {
403                occlusion_strength: 2.0,
404                ..ok.clone()
405            },
406            "occlusion strength",
407        );
408        refused(
409            PbrMaterial {
410                normal_scale: f32::INFINITY,
411                ..ok.clone()
412            },
413            "normal scale",
414        );
415        refused(
416            PbrMaterial {
417                alpha_mode: AlphaMode::Mask { cutoff: -0.5 },
418                ..ok.clone()
419            },
420            "mask cutoff",
421        );
422        let mut transformed = TextureRef::new(texture);
423        transformed.transform = Some(UvTransform {
424            rotation: f32::NAN,
425            ..UvTransform::IDENTITY
426        });
427        refused(
428            PbrMaterial {
429                normal_texture: Some(transformed),
430                ..ok.clone()
431            },
432            "texture transform",
433        );
434        // a negative normal scale (a flipped map) and a cutoff above 1 are allowed
435        assert_eq!(
436            PbrMaterial {
437                normal_scale: -1.0,
438                ..ok.clone()
439            }
440            .verify(e, &w),
441            Ok(())
442        );
443        assert_eq!(
444            PbrMaterial {
445                alpha_mode: AlphaMode::Mask { cutoff: 1.5 },
446                ..ok
447            }
448            .verify(e, &w),
449            Ok(())
450        );
451    }
452
453    /// Every slot, the specular extension's two included, by value and mutably, in one order.
454    #[test]
455    fn every_slot_is_listed_by_value_and_mutably_in_gltf_order() {
456        use super::{Entity, Specular, TextureRef, TextureSlot};
457        let r = |i: u32| TextureRef::new(Entity::from_index(i));
458        let mut m = PbrMaterial {
459            base_color_texture: Some(r(1)),
460            metallic_roughness_texture: Some(r(2)),
461            normal_texture: Some(r(3)),
462            occlusion_texture: Some(r(4)),
463            emissive_texture: Some(r(5)),
464            specular: Some(Specular {
465                factor: 1.0,
466                texture: Some(r(6)),
467                color: [1.0; 3],
468                color_texture: Some(r(7)),
469            }),
470            ..PbrMaterial::default()
471        };
472        let slots: Vec<TextureSlot> = m.slots().map(|(s, _)| s).collect();
473        assert_eq!(
474            slots,
475            [
476                TextureSlot::BaseColor,
477                TextureSlot::MetallicRoughness,
478                TextureSlot::Normal,
479                TextureSlot::Occlusion,
480                TextureSlot::Emissive,
481                TextureSlot::Specular,
482                TextureSlot::SpecularColor
483            ]
484        );
485        assert_eq!(
486            m.textures().map(|t| t.texture.index()).collect::<Vec<_>>(),
487            [1, 2, 3, 4, 5, 6, 7]
488        );
489        for t in m.textures_mut() {
490            t.texcoord = 1;
491        }
492        assert!(m.textures().all(|t| t.texcoord == 1));
493        // an unset slot is not listed
494        m.specular = None;
495        m.normal_texture = None;
496        assert_eq!(m.textures_mut().count(), 4);
497    }
498
499    #[test]
500    fn bottom_left_tiling_samples_the_same_texels() {
501        // a source coordinate and its tiling, all dyadic so every step is exact
502        let (scale, offset) = ([2.0f32, 0.5], [0.25f32, 0.125]);
503        let t = UvTransform::from_bottom_left(scale, offset);
504        assert_eq!(t.offset, [0.25, 0.375]);
505        for (u, v) in [(0.0f32, 0.0f32), (0.5, 0.25), (0.75, 1.0)] {
506            // the source samples its image at (u, v) * scale + offset; the world's image is flipped, so that texel is
507            // at 1 - v_s in the world
508            let source = [u * scale[0] + offset[0], v * scale[1] + offset[1]];
509            let world = t.apply([u, 1.0 - v]);
510            assert_eq!(world, [source[0], 1.0 - source[1]], "({u}, {v})");
511        }
512    }
513
514    #[test]
515    fn rotation_is_counter_clockwise_after_the_scale() {
516        let t = UvTransform {
517            offset: [0.0, 1.0],
518            scale: [0.5, 0.5],
519            rotation: std::f32::consts::FRAC_PI_2,
520        };
521        let [u, v] = t.apply([1.0, 0.0]);
522        assert!(u.abs() < 1e-7 && (v - 1.5).abs() < 1e-7, "{u} {v}");
523        assert_eq!(UvTransform::IDENTITY.apply([0.3, 0.7]), [0.3, 0.7]);
524    }
525
526    /// The rule the glTF writer relies on its callers to enforce: a negative, infinite or NaN strength fails
527    /// verification, and the zero and default strengths pass.
528    #[test]
529    fn emissive_strength_is_finite_and_non_negative() {
530        use crate::World;
531
532        let failures = |strength: f32| {
533            let mut w = World::new();
534            let e = w.spawn();
535            w.insert(
536                e,
537                PbrMaterial {
538                    emissive_strength: strength,
539                    ..PbrMaterial::default()
540                },
541            );
542            w.verify_components()
543                .into_iter()
544                .map(|(name, _, detail)| (name.to_owned(), detail))
545                .collect::<Vec<_>>()
546        };
547        for strength in [-1.0, -0.001, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
548            let failures = failures(strength);
549            assert_eq!(failures.len(), 1, "{strength}");
550            assert_eq!(failures[0].0, "pbr.material", "{strength}");
551            assert!(failures[0].1.contains("emissive strength"), "{:?}", failures[0]);
552        }
553        assert!(failures(0.0).is_empty());
554        assert!(failures(1.0).is_empty());
555    }
556
557    /// The emissive colour is within 0-1 (glTF's `emissiveFactor`): a channel above 1, below 0 or not finite fails
558    /// verification, brighter emission being the strength's; the bounds themselves pass.
559    #[test]
560    fn emissive_is_within_0_to_1() {
561        use crate::World;
562
563        let failures = |emissive: [f32; 3]| {
564            let mut w = World::new();
565            let e = w.spawn();
566            w.insert(
567                e,
568                PbrMaterial {
569                    emissive,
570                    emissive_strength: 4.0,
571                    ..PbrMaterial::default()
572                },
573            );
574            w.verify_components()
575                .into_iter()
576                .map(|(_, _, detail)| detail)
577                .collect::<Vec<_>>()
578        };
579        for emissive in [
580            [1.5, 0.0, 0.0],
581            [0.0, -0.25, 0.0],
582            [0.0, 0.0, f32::NAN],
583            [f32::INFINITY, 1.0, 1.0],
584        ] {
585            let failures = failures(emissive);
586            assert_eq!(failures.len(), 1, "{emissive:?}");
587            assert!(failures[0].contains("emissive"), "{failures:?}");
588        }
589        assert!(failures([0.0; 3]).is_empty());
590        assert!(failures([1.0, 0.5, 0.0]).is_empty());
591    }
592}