Skip to main content

primase_core/
detail.rs

1//! A material's surface as its base and tiled detail textures combine (component `pbr.detail`), for a target that
2//! composites detail at draw time instead of taking it baked.
3//!
4//! It sits beside the material's `pbr.material`, which stays the complete bake (the detail baked in at a capped size):
5//! the host and the fallback for every target that does not draw this. The textures are the game's own, each sampled
6//! with its own UV set, wrap, transform (its tiling) and filters ([`TextureRef`]), their lanes as the game's program
7//! reads them (a base normal's alpha may be a mask). Nothing here is baked.
8//!
9//! The combination is a closed set of equations ([`DetailMix`]), each the one a family of programs computes, added only
10//! where every program said to compute it is shown to on the CPU executor (the equation against the program's decoded
11//! outputs, over random texture samples). Its outputs are the surface the equation yields ([`DetailSurface`]), the mix
12//! happening before any lighting model; a closed set of lowerings ([`SurfaceLowering`]) says how a G-buffer holds that
13//! surface ([`SurfaceLowering::written`]: the roles a program writes, what the proof compares) and makes it the PBR
14//! model's ([`SurfaceLowering::evaluate`]), the same lowering the source's bake applies, so every consumer converts
15//! through core.
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::layered::{Source, check_source, mask_value};
20use crate::pbr::TextureRef;
21use crate::shader::ScalarEncoding;
22use crate::{Component, Entity, JsonError, World};
23
24/// A material's detail form (component `pbr.detail`).
25#[derive(Clone, Debug, PartialEq, Serialize)]
26pub struct DetailedMaterial {
27    pub mix: DetailMix,
28    /// How the mix's outputs become the PBR model.
29    pub lowering: SurfaceLowering,
30    /// What the equation holds constant that the source varies per draw, each at the value the source's bake freezes
31    /// it to (the surface front-facing, decals and wear off): what a consumer shows beside it, and what holds for the
32    /// equation to be the program's.
33    pub frozen: Vec<Frozen>,
34}
35
36/// One thing a [`DetailedMaterial`]'s equation holds constant ([`DetailedMaterial::frozen`]): a closed set, each with
37/// its value.
38#[derive(Clone, Debug, PartialEq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Frozen {
41    /// The surface drawn front-facing: the program's facing input true.
42    FrontFacing,
43    /// A vertex input the program reads, held at `value` (lanes past the input's own 0), by its semantic name and
44    /// index.
45    VertexInput {
46        semantic: String,
47        index: u32,
48        value: [f32; 4],
49    },
50    /// A constant the source's engine sets per draw, held at `value` (its words as floats), by its name in the
51    /// program.
52    EngineConstant { name: String, value: Vec<f32> },
53    /// A texture the source's engine binds per draw, read as the constant `value`, by its name in the program.
54    EngineTexture { name: String, value: [f32; 4] },
55}
56
57impl Frozen {
58    /// Its values all finite.
59    fn finite(&self) -> bool {
60        match self {
61            Frozen::FrontFacing => true,
62            Frozen::VertexInput { value, .. } | Frozen::EngineTexture { value, .. } => {
63                value.iter().all(|v| v.is_finite())
64            }
65            Frozen::EngineConstant { value, .. } => value.iter().all(|v| v.is_finite()),
66        }
67    }
68}
69
70/// The equations a detail material is drawn by.
71#[derive(Clone, Debug, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum DetailMix {
74    DetailLerp(DetailLerp),
75}
76
77/// The model's revision: the version of the component. 4 adds the base colour's desaturation, the `max`, `affine` and `saturate` weight
78/// steps and the `metallic_gloss` lowering, and moves the dielectric reflectance into the `gloss_reflectance` lowering;
79/// 3, the earlier shape, is read as a subset.
80pub const REVISION: u32 = 4;
81
82/// The most details a [`DetailLerp`] chains: this model's own bound, the largest chain an evaluator is built for (the
83/// texture bindings of a detail material grow with it: three for the base and two for each detail). The programs it
84/// was derived from read one to three.
85pub const DETAIL_LIMIT: usize = 3;
86
87/// The weights of the luma a program desaturates the base colour toward ([`DetailLerp::base_saturation`]): the values the
88/// programs write, which sum to 1.
89pub const LUMA: [f32; 3] = [0.29891, 0.58661, 0.11448];
90
91/// Tinted details lerped in turn over a base, as the detail-blend programs this model was derived from compute them. With
92/// `A`, `N`, `M` the base colour, normal and mask samples, `D_i`, `K_i` the detail colour and normal samples,
93/// `lerp(a, b, t) = a + (b - a) * t` (lane by lane) and `chain(x)` the details' values `x_0, x_1, ...` lerped in turn by
94/// the weights (`x_0`, then `lerp(.., x_1, w_1)`, then `lerp(.., x_2, w_2)`):
95///
96/// - `A' = lerp(A.rgb, luma(A.rgb), base_saturation)` with `luma(x) = dot(x, `[`LUMA`]`)`; `A.rgb` where it is 0
97/// - `C = saturate(gain * A'.rgb * chain(tint_i * D_i.rgb))`
98/// - `f = saturate(chain(r_i))`, each detail's reflectance term `r_i` as it states ([`ReflectanceTerm`])
99/// - the surface is `C`, `colour` and `f`; the lowering says what a G-buffer holds of it: albedo `(1 - f) * C * colour`
100///   and reflectance (F0) `lerp(dielectric_f0, C, f)` ([`SurfaceLowering::GlossReflectance`]), or albedo `C * colour` and
101///   metallic `f` ([`SurfaceLowering::MetallicGloss`])
102/// - normal: `xy = 2 * N.xy - 1`, `z = sqrt(1 - min(dot(xy, xy), 1))` of the base's `xy`, then `xy += normal_strength *
103///   (2 * chain(K_i.xy) - 1)`: tangent space, unnormalised
104/// - gloss: `saturate(2 * chain(s_i) * chain(K_i.z + gloss_bias_i))` as the program stores it, each detail's scale `s_i`
105///   as it states ([`GlossScale`]), decoded by `gloss_encoding` (the smoothness the lighting reads)
106/// - alpha `saturate(D_alpha.a * A.a)` of the detail `alpha` states, where the program's alpha has a role; 1 where it
107///   has none
108///
109/// One detail is no lerp at all. The operations are [`DetailLerp::evaluate`]'s, in its order.
110#[derive(Clone, Debug, PartialEq, Serialize)]
111pub struct DetailLerp {
112    pub base_colour: TextureRef,
113    pub base_normal: TextureRef,
114    pub base_mask: TextureRef,
115    /// How far the base colour is desaturated toward its luma before the details are multiplied over it (0: not at all,
116    /// 1: grey).
117    pub base_saturation: f32,
118    /// One to [`DETAIL_LIMIT`], in the chain's order.
119    pub details: Vec<Detail>,
120    /// The weight each detail after the first is lerped in by, per pixel ([`Weight`]): one fewer than the details.
121    pub weights: Vec<Weight>,
122    pub gain: f32,
123    pub normal_strength: f32,
124    /// Linear RGB the albedo is multiplied by.
125    pub colour: [f32; 3],
126    /// How the program stores the gloss in its G-buffer (the binding's `Gloss` encoding).
127    pub gloss_encoding: ScalarEncoding,
128    /// The detail whose colour's alpha, times the base colour's, is the surface's alpha: where the program writes its
129    /// alpha to a lane with a role (the binding's `Coverage` or `Opacity`). `None` where that lane has no role (no blend
130    /// weighs by it, no discard tests it): the surface is opaque.
131    pub alpha: Option<usize>,
132}
133
134/// The largest exponent of a [`WeightStep::Power`].
135pub const WEIGHT_POWER_LIMIT: u16 = 512;
136
137/// A detail's weight per pixel: its source's value, then each step in turn.
138#[derive(Clone, Debug, PartialEq, Serialize)]
139pub struct Weight {
140    pub source: Source,
141    pub steps: Vec<WeightStep>,
142}
143
144/// One step of a [`Weight`]: a closed set, each a form a program is shown to compute.
145#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
146#[serde(rename_all = "snake_case")]
147pub enum WeightStep {
148    /// `x^exponent`, 1 to [`WEIGHT_POWER_LIMIT`], as [`weight_power`] takes it.
149    Power(u16),
150    /// `min(x, bound)`, the bound where `x` is NaN (D3D's `min`).
151    Min(f32),
152    /// `max(x, bound)`, the bound where `x` is NaN (D3D's `max`): `max(x, 0)` before a power keeps a program's `pow` of a
153    /// negative value (NaN, which its `saturate` makes 0) the same 0 here.
154    Max(f32),
155    /// `x * scale + bias`, the product rounded before the sum.
156    Affine { scale: f32, bias: f32 },
157    /// `clamp(x, 0, 1)`, 0 where `x` is NaN (D3D's `saturate`).
158    Saturate,
159}
160
161/// `x^exponent` by squaring, as a program writes a power: `x`, `x^2`, `x^4`, ... each the square of the one before, and
162/// the powers of the exponent's set bits multiplied in from the lowest (`x^260 = x^4 * x^256`; a power of two is its
163/// squarings alone).
164pub fn weight_power(x: f32, exponent: u16) -> f32 {
165    let mut square = x;
166    let mut out: Option<f32> = None;
167    for bit in 0..16 {
168        if exponent >> bit == 0 {
169            break;
170        }
171        if exponent & (1 << bit) != 0 {
172            out = Some(out.map_or(square, |acc| acc * square));
173        }
174        square *= square;
175    }
176    out.unwrap_or(1.0)
177}
178
179/// `steps` of `v`, in turn.
180pub fn weight_steps(v: f32, steps: &[WeightStep]) -> f32 {
181    steps.iter().fold(v, |x, step| match *step {
182        WeightStep::Power(exponent) => weight_power(x, exponent),
183        WeightStep::Min(bound) => x.min(bound),
184        WeightStep::Max(bound) => x.max(bound),
185        WeightStep::Affine { scale, bias } => x * scale + bias,
186        // as a GPU's saturate: NaN is 0
187        WeightStep::Saturate => {
188            if x.is_nan() {
189                0.0
190            } else {
191                x.clamp(0.0, 1.0)
192            }
193        }
194    })
195}
196
197impl Weight {
198    /// Its value for its source's value `v`.
199    pub fn evaluate(&self, v: f32) -> f32 {
200        weight_steps(v, &self.steps)
201    }
202
203    /// Its value for the samples of the base normal, where its source is a mask of it.
204    pub fn of_sample(&self, sample: [f32; 4]) -> Option<f32> {
205        match self.source {
206            Source::Mask { channels, bias, .. } => Some(self.evaluate(mask_value(channels, bias, sample))),
207            Source::Vertex { .. } => None,
208        }
209    }
210
211    /// Its own checks (its source's apart): each power's exponent 1 to [`WEIGHT_POWER_LIMIT`], each bound finite.
212    pub fn check(&self) -> Result<(), String> {
213        for step in &self.steps {
214            match *step {
215                WeightStep::Power(e) if !(1..=WEIGHT_POWER_LIMIT).contains(&e) => {
216                    return Err(format!("power exponent {e} is not 1 to {WEIGHT_POWER_LIMIT}"));
217                }
218                WeightStep::Min(bound) if !bound.is_finite() => {
219                    return Err(format!("min bound {bound} is not finite"));
220                }
221                WeightStep::Max(bound) if !bound.is_finite() => {
222                    return Err(format!("max bound {bound} is not finite"));
223                }
224                WeightStep::Affine { scale, bias } if !(scale.is_finite() && bias.is_finite()) => {
225                    return Err(format!("affine step {scale} * x + {bias} is not finite"));
226                }
227                _ => {}
228            }
229        }
230        Ok(())
231    }
232}
233
234/// What a detail's gloss term is scaled by before the doubling: a closed set, each a form a program is shown to compute.
235#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
236#[serde(rename_all = "snake_case")]
237pub enum GlossScale {
238    /// The base normal's z sample.
239    BaseNormalZ,
240    /// A constant.
241    Constant(f32),
242}
243
244impl GlossScale {
245    /// Its value for the base normal sample `normal`.
246    pub fn value(self, normal: [f32; 4]) -> f32 {
247        match self {
248            GlossScale::BaseNormalZ => normal[2],
249            GlossScale::Constant(c) => c,
250        }
251    }
252}
253
254/// One detail of a [`DetailLerp`]: its textures and constants.
255#[derive(Clone, Debug, PartialEq, Serialize)]
256pub struct Detail {
257    pub colour: TextureRef,
258    pub normal: TextureRef,
259    /// Linear RGB its colour is multiplied by.
260    pub tint: [f32; 3],
261    /// Added to its normal's z in the gloss.
262    pub gloss_bias: f32,
263    /// What its gloss term is scaled by.
264    pub gloss_scale: GlossScale,
265    /// Its term of the reflectance mix `f`.
266    pub reflectance: ReflectanceTerm,
267}
268
269/// A detail's term of the reflectance mix, as the program computes it: a closed set, each a form a program is shown to
270/// compute.
271#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
272#[serde(rename_all = "snake_case")]
273pub enum ReflectanceTerm {
274    /// The base mask's x plus the bias.
275    MaskPlusBias(f32),
276    /// The bias alone.
277    Bias(f32),
278}
279
280impl ReflectanceTerm {
281    /// Its value for the base mask sample `mask`.
282    pub fn value(self, mask: [f32; 4]) -> f32 {
283        match self {
284            ReflectanceTerm::MaskPlusBias(bias) => mask[0] + bias,
285            ReflectanceTerm::Bias(bias) => bias,
286        }
287    }
288
289    /// Its bias.
290    pub fn bias(self) -> f32 {
291        match self {
292            ReflectanceTerm::MaskPlusBias(bias) | ReflectanceTerm::Bias(bias) => bias,
293        }
294    }
295}
296
297/// The texture samples of one pixel of a [`DetailLerp`], each a texture's four lanes as sampled.
298#[derive(Clone, Debug, PartialEq)]
299pub struct DetailSamples {
300    pub base_colour: [f32; 4],
301    pub base_normal: [f32; 4],
302    pub base_mask: [f32; 4],
303    /// Each detail's colour and normal, in the chain's order.
304    pub details: Vec<DetailSample>,
305}
306
307/// One detail's samples.
308#[derive(Clone, Copy, Debug, PartialEq)]
309pub struct DetailSample {
310    pub colour: [f32; 4],
311    pub normal: [f32; 4],
312}
313
314/// One pixel's surface, before any G-buffer or PBR lowering: what the equation yields. A lowering says what a G-buffer
315/// holds of it ([`SurfaceLowering::written`]) and what the PBR model is ([`SurfaceLowering::evaluate`]).
316#[derive(Clone, Copy, Debug, PartialEq)]
317pub struct DetailSurface {
318    /// Linear RGB: the details' colour `C`, before the material's colour.
319    pub c: [f32; 3],
320    /// Linear RGB the material multiplies the albedo by.
321    pub colour: [f32; 3],
322    /// The mix `f` between the dielectric and the colour, 0 to 1 (the metal).
323    pub mix: f32,
324    pub alpha: f32,
325    /// Tangent space, unnormalised (the program normalises after its tangent frame).
326    pub normal: [f32; 3],
327    /// Smoothness, 1 - perceptual roughness.
328    pub gloss: f32,
329}
330
331/// The roles a program's G-buffer holds of a [`DetailSurface`] under a [`SurfaceLowering`].
332#[derive(Clone, Copy, Debug, PartialEq)]
333pub struct Written {
334    /// Linear RGB: the diffuse colour.
335    pub albedo: [f32; 3],
336    /// Linear RGB reflectance at normal incidence (F0), where the G-buffer stores one.
337    pub reflectance: Option<[f32; 3]>,
338    /// The metal factor, where the G-buffer stores one.
339    pub metallic: Option<f32>,
340}
341
342/// How a [`DetailSurface`] is held by a G-buffer and becomes the PBR model: a closed set, each the lowering a source's
343/// bake applies to the same surface. Both are the same statement (a mix between a dielectric and the colour); they
344/// differ in what the source's G-buffer stores of it.
345#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
346#[serde(rename_all = "snake_case")]
347pub enum SurfaceLowering {
348    /// A G-buffer that stores an F0 reflectance: albedo `(1 - f) * C * colour` and F0 `lerp(dielectric_f0, C, f)`. The PBR
349    /// model: the base colour the albedo, roughness `1 - gloss`, metallic 0 (the reflectance carries the metal), and the
350    /// F0 as the specular colour glTF's `KHR_materials_specular` states with a colour factor of 25 (its F0 is
351    /// `0.04 * factor * colour`).
352    GlossReflectance {
353        /// The reflectance (F0) of the dielectric part, where `f` is 0.
354        dielectric_f0: f32,
355    },
356    /// A G-buffer that stores a metallic factor: albedo `C * colour` and metallic `f`. The PBR model: the base colour the
357    /// albedo, metallic `f`, roughness `1 - gloss`, and no specular colour (the dielectric reflectance is the consumer's:
358    /// glTF's 0.04, which is a choice the source's lighting may not make, so a writer reports it).
359    MetallicGloss,
360}
361
362/// One pixel of the PBR model ([`SurfaceLowering::evaluate`]).
363#[derive(Clone, Copy, Debug, PartialEq)]
364pub struct PbrPixel {
365    /// Linear RGB and alpha.
366    pub base_color: [f32; 4],
367    /// Tangent space, unit length (NaN where the surface's normal is zero, as a program's normalise gives).
368    pub normal: [f32; 3],
369    pub roughness: f32,
370    pub metallic: f32,
371    /// The specular colour, `KHR_materials_specular`'s `specularColorTexture` value, with its colour factor
372    /// ([`SPECULAR_COLOR_FACTOR`]); `None` where the lowering states none.
373    pub specular_color: Option<[f32; 3]>,
374}
375
376/// The `specularColorFactor` the F0 of [`SurfaceLowering::GlossReflectance`] is written with: glTF's F0 is
377/// `0.04 * factor * colour`, so 25 makes the colour the F0.
378pub const SPECULAR_COLOR_FACTOR: f32 = 25.0;
379
380fn lerp(a: f32, b: f32, t: f32) -> f32 {
381    a + (b - a) * t
382}
383
384/// `x` lerped in turn by `weights`: the first value, then each next one lerped over the result by its weight.
385pub fn chain(x: impl IntoIterator<Item = f32>, weights: &[f32]) -> f32 {
386    let mut x = x.into_iter();
387    let first = x.next().unwrap_or(0.0);
388    x.zip(weights).fold(first, |acc, (next, &w)| lerp(acc, next, w))
389}
390
391impl DetailLerp {
392    /// The textures it samples: the base's, each detail's colour and normal, and those of its weights after them.
393    pub fn textures(&self) -> Vec<&TextureRef> {
394        let mut out = vec![&self.base_colour, &self.base_normal, &self.base_mask];
395        for d in &self.details {
396            out.extend([&d.colour, &d.normal]);
397        }
398        out.extend(self.weights.iter().filter_map(|w| match &w.source {
399            Source::Mask { texture, .. } => Some(texture),
400            Source::Vertex { .. } => None,
401        }));
402        out
403    }
404
405    /// The weights' values for the values of their sources.
406    pub fn weight_values(&self, sources: &[f32]) -> Vec<f32> {
407        self.weights
408            .iter()
409            .zip(sources)
410            .map(|(w, &v)| w.evaluate(v))
411            .collect()
412    }
413
414    /// One pixel's surface from its samples and the values of its weights ([`DetailLerp::weight_values`]).
415    pub fn evaluate(&self, s: &DetailSamples, weights: &[f32]) -> DetailSurface {
416        let (a, n, m) = (s.base_colour, s.base_normal, s.base_mask);
417        let pairs = || self.details.iter().zip(&s.details);
418        let base: [f32; 3] = if self.base_saturation == 0.0 {
419            [a[0], a[1], a[2]]
420        } else {
421            let luma = a[0] * LUMA[0] + a[1] * LUMA[1] + a[2] * LUMA[2];
422            std::array::from_fn(|i| lerp(a[i], luma, self.base_saturation))
423        };
424        let c: [f32; 3] = std::array::from_fn(|i| {
425            let detail = chain(pairs().map(|(d, x)| d.tint[i] * x.colour[i]), weights);
426            (self.gain * base[i] * detail).clamp(0.0, 1.0)
427        });
428        let f = chain(self.details.iter().map(|d| d.reflectance.value(m)), weights).clamp(0.0, 1.0);
429        let base = [2.0 * n[0] - 1.0, 2.0 * n[1] - 1.0];
430        let z = (1.0 - (base[0] * base[0] + base[1] * base[1]).min(1.0)).sqrt();
431        let detail = |i: usize| 2.0 * chain(s.details.iter().map(|x| x.normal[i]), weights) - 1.0;
432        let normal = [
433            base[0] + self.normal_strength * detail(0),
434            base[1] + self.normal_strength * detail(1),
435            z,
436        ];
437        let scale = chain(self.details.iter().map(|d| d.gloss_scale.value(n)), weights);
438        let stored =
439            (2.0 * scale * chain(pairs().map(|(d, x)| x.normal[2] + d.gloss_bias), weights)).clamp(0.0, 1.0);
440        let gloss = self.gloss_encoding.decode(stored);
441        let alpha = self
442            .alpha
443            .and_then(|j| s.details.get(j))
444            .map_or(1.0, |x| (x.colour[3] * a[3]).clamp(0.0, 1.0));
445        DetailSurface {
446            c,
447            colour: self.colour,
448            mix: f,
449            alpha,
450            normal,
451            gloss,
452        }
453    }
454
455    /// Its constants all finite.
456    fn constants_finite(&self) -> bool {
457        let scale = |d: &Detail| match d.gloss_scale {
458            GlossScale::Constant(c) => c,
459            GlossScale::BaseNormalZ => 0.0,
460        };
461        self.details
462            .iter()
463            .flat_map(|d| {
464                d.tint
465                    .into_iter()
466                    .chain([d.gloss_bias, d.reflectance.bias(), scale(d)])
467            })
468            .chain(self.colour)
469            .chain([self.gain, self.normal_strength, self.base_saturation])
470            .all(f32::is_finite)
471    }
472
473    /// One to [`DETAIL_LIMIT`] details, one weight fewer, each weight bounded, the constants finite, the alpha's detail
474    /// one of them and the gloss encoding sound.
475    fn check(&self) -> Result<(), String> {
476        if !(1..=DETAIL_LIMIT).contains(&self.details.len()) {
477            return Err(format!(
478                "{} details: a detail lerp chains 1 to {DETAIL_LIMIT}",
479                self.details.len()
480            ));
481        }
482        if self.weights.len() + 1 != self.details.len() {
483            return Err(format!(
484                "{} weights for {} details: one fewer than the details",
485                self.weights.len(),
486                self.details.len()
487            ));
488        }
489        if !self.constants_finite() {
490            return Err("a detail mix constant is not finite".into());
491        }
492        for w in &self.weights {
493            w.check().map_err(|e| format!("weight: {e}"))?;
494        }
495        if let Some(j) = self.alpha
496            && j >= self.details.len()
497        {
498            return Err(format!(
499                "the alpha is detail {j}'s, of {} details",
500                self.details.len()
501            ));
502        }
503        self.gloss_encoding
504            .check()
505            .map_err(|e| format!("gloss encoding: {e}"))
506    }
507}
508
509impl DetailMix {
510    /// The textures it samples, its weights' included.
511    pub fn textures(&self) -> Vec<&TextureRef> {
512        match self {
513            DetailMix::DetailLerp(mix) => mix.textures(),
514        }
515    }
516}
517
518impl SurfaceLowering {
519    /// The roles a G-buffer under this lowering holds of `surface`: what a program that writes them writes, and what the
520    /// proof compares with the program's decoded outputs.
521    pub fn written(self, surface: &DetailSurface) -> Written {
522        let (c, f) = (surface.c, surface.mix);
523        match self {
524            SurfaceLowering::GlossReflectance { dielectric_f0 } => Written {
525                albedo: std::array::from_fn(|i| (1.0 - f) * c[i] * surface.colour[i]),
526                reflectance: Some(c.map(|c| lerp(dielectric_f0, c, f))),
527                metallic: None,
528            },
529            SurfaceLowering::MetallicGloss => Written {
530                albedo: std::array::from_fn(|i| c[i] * surface.colour[i]),
531                reflectance: None,
532                metallic: Some(f),
533            },
534        }
535    }
536
537    /// The PBR model's pixel for `surface`.
538    pub fn evaluate(self, surface: &DetailSurface) -> PbrPixel {
539        let written = self.written(surface);
540        let [r, g, b] = written.albedo;
541        let n = surface.normal;
542        let length = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
543        PbrPixel {
544            base_color: [r, g, b, surface.alpha],
545            normal: n.map(|x| x / length),
546            roughness: 1.0 - surface.gloss,
547            metallic: written.metallic.unwrap_or(0.0),
548            specular_color: written.reflectance,
549        }
550    }
551
552    /// Its constants finite.
553    fn check(self) -> Result<(), String> {
554        match self {
555            SurfaceLowering::GlossReflectance { dielectric_f0 } if !dielectric_f0.is_finite() => {
556                Err(format!("dielectric reflectance {dielectric_f0} is not finite"))
557            }
558            _ => Ok(()),
559        }
560    }
561}
562
563impl Component for DetailedMaterial {
564    const NAME: &'static str = "pbr.detail";
565    const GEOMETRY: crate::Geometry = crate::Geometry::None;
566    const VERSION: u32 = REVISION;
567    fn to_json(&self) -> Result<Option<Value>, JsonError> {
568        crate::json::to_value(self).map(Some)
569    }
570    /// Its constants and texture transforms finite, its textures alive, its details 1 to [`DETAIL_LIMIT`] and its weights
571    /// bounded channels of sources that are, one fewer.
572    fn verify(&self, _e: Entity, world: &World) -> Result<(), String> {
573        if let Some(f) = self.frozen.iter().find(|f| !f.finite()) {
574            return Err(format!("frozen {f:?} is not finite"));
575        }
576        self.lowering.check()?;
577        match &self.mix {
578            DetailMix::DetailLerp(mix) => {
579                mix.check()?;
580                for w in &mix.weights {
581                    check_source(world, "weight", &w.source)?;
582                }
583            }
584        }
585        for t in self.mix.textures() {
586            if !world.is_alive(t.texture) {
587                return Err(format!("texture {:?} is not alive", t.texture));
588            }
589            if let Some(x) = t.transform
590                && !x
591                    .offset
592                    .iter()
593                    .chain(&x.scale)
594                    .chain([&x.rotation])
595                    .all(|v| v.is_finite())
596            {
597                return Err(format!("texture transform {x:?} is not finite"));
598            }
599        }
600        Ok(())
601    }
602    fn references(&self, out: &mut Vec<Entity>) {
603        out.extend(self.mix.textures().into_iter().map(|t| t.texture));
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::layered::{Channel, power};
611
612    /// `(1 - N.a)^8` (`flip`: `N.a^8`) of the base normal `texture`.
613    fn weight(texture: TextureRef, flip: bool) -> Weight {
614        Weight {
615            source: Source::Mask {
616                texture,
617                channels: [0., 0., 0., if flip { 1. } else { -1. }],
618                bias: if flip { 0. } else { 1. },
619            },
620            steps: vec![WeightStep::Power(8)],
621        }
622    }
623
624    /// A detail lerp of `n` details with distinct constants; the second detail's weight `(1 - N.a)^8`, the third's
625    /// `N.a^8`, as a source program that squares three times.
626    fn mix(world: &mut World, n: usize) -> DetailLerp {
627        let mut t = || TextureRef::new(world.spawn());
628        let (base_colour, base_normal, base_mask) = (t(), t(), t());
629        let tints = [[1., 0.5, 0.25], [0.5, 1., 1.], [0.25, 0.75, 2.]];
630        let reflectance = [
631            ReflectanceTerm::MaskPlusBias(0.125),
632            ReflectanceTerm::Bias(0.75),
633            ReflectanceTerm::Bias(0.375),
634        ];
635        let details = (0..n)
636            .map(|i| Detail {
637                colour: t(),
638                normal: t(),
639                tint: tints[i],
640                gloss_bias: [0.25, -0.25, 0.5][i],
641                gloss_scale: GlossScale::BaseNormalZ,
642                reflectance: reflectance[i],
643            })
644            .collect();
645        DetailLerp {
646            weights: [weight(base_normal, false), weight(base_normal, true)][..n - 1].to_vec(),
647            base_colour,
648            base_normal,
649            base_mask,
650            base_saturation: 0.,
651            details,
652            gain: 4.55,
653            normal_strength: 0.5,
654            colour: [1., 1., 0.5],
655            gloss_encoding: ScalarEncoding::LINEAR,
656            alpha: Some(0),
657        }
658    }
659
660    fn samples(n: usize) -> DetailSamples {
661        let details = [
662            ([0.5, 0.5, 0.5, 1.], [0.5, 0.5, 0.75, 0.]),
663            ([1., 0.25, 0.5, 0.], [1., 0., 0.25, 0.]),
664            ([0.25, 0.75, 0.125, 0.5], [0.25, 0.75, 0.5, 0.]),
665        ];
666        DetailSamples {
667            base_colour: [0.1, 0.2, 0.05, 0.5],
668            base_normal: [0.75, 0.25, 0.5, 0.75],
669            base_mask: [0.5, 0., 0., 0.],
670            details: details[..n]
671                .iter()
672                .map(|&(colour, normal)| DetailSample { colour, normal })
673                .collect(),
674        }
675    }
676
677    /// Each output of the equation as its doc states it, for one pixel's samples of one, two and three details: one
678    /// detail no lerp, two lerped by `(1 - N.a)^8` (by squaring), three lerped on by `N.a^8`.
679    #[test]
680    fn details_are_lerped_in_turn_over_the_base() {
681        let mut world = World::new();
682        for n in 1..=3 {
683            let mix = mix(&mut world, n);
684            let s = samples(n);
685            let a = s.base_normal[3];
686            let sources = [1. - a, a];
687            let weights = mix.weight_values(&sources[..n - 1]);
688            if n >= 2 {
689                assert_eq!(weights[0].to_bits(), power(1. - a, 8).to_bits());
690                assert_eq!(
691                    weights[0].to_bits(),
692                    (((0.25f32 * 0.25) * (0.25 * 0.25)) * ((0.25 * 0.25) * (0.25 * 0.25))).to_bits()
693                );
694            }
695            if n == 3 {
696                assert_eq!(weights[1].to_bits(), power(a, 8).to_bits());
697            }
698            // the chain written out: each next value lerped over the result so far
699            let by_hand = |x: &[f32]| -> f32 {
700                let mut acc = x[0];
701                for (next, w) in x[1..].iter().zip(&weights) {
702                    acc = acc + (next - acc) * w;
703                }
704                acc
705            };
706            let out = mix.evaluate(&s, &weights);
707            let written = SurfaceLowering::GlossReflectance { dielectric_f0: 0.03 }.written(&out);
708            let c: [f32; 3] = std::array::from_fn(|i| {
709                let x: Vec<f32> = (0..n)
710                    .map(|j| mix.details[j].tint[i] * s.details[j].colour[i])
711                    .collect();
712                (mix.gain * s.base_colour[i] * by_hand(&x)).clamp(0., 1.)
713            });
714            let terms: Vec<f32> = [0.5 + 0.125, 0.75, 0.375][..n].to_vec();
715            let f = by_hand(&terms).clamp(0., 1.);
716            assert_eq!((out.c, out.mix, out.colour), (c, f, mix.colour), "{n} details");
717            for (i, c) in c.iter().enumerate() {
718                assert_eq!(written.albedo[i], (1. - f) * c * mix.colour[i], "{n} details");
719                assert_eq!(
720                    written.reflectance.map(|r| r[i]),
721                    Some(0.03 + (c - 0.03) * f),
722                    "{n} details"
723                );
724                assert_eq!(written.metallic, None);
725            }
726            let k = |i: usize| {
727                let x: Vec<f32> = s.details.iter().map(|d| d.normal[i]).collect();
728                2. * by_hand(&x) - 1.
729            };
730            assert_eq!(
731                out.normal,
732                [0.5 + 0.5 * k(0), -0.5 + 0.5 * k(1), (1f32 - 0.5).sqrt()],
733                "{n} details"
734            );
735            let g: Vec<f32> = (0..n)
736                .map(|j| s.details[j].normal[2] + mix.details[j].gloss_bias)
737                .collect();
738            assert_eq!(out.gloss, (2. * 0.5 * by_hand(&g)).clamp(0., 1.), "{n} details");
739            assert_eq!(out.alpha, 0.5);
740        }
741        // one detail is no lerp: its own values exactly
742        let one = mix(&mut world, 1);
743        let s = samples(1);
744        let out = one.evaluate(&s, &[]);
745        let f = 0.5 + 0.125;
746        let written = SurfaceLowering::GlossReflectance { dielectric_f0: 0.03 }.written(&out);
747        assert_eq!(
748            written.reflectance.map(|r| r[0]),
749            Some(0.03 + ((4.55 * 0.1 * (1. * 0.5)) - 0.03) * f)
750        );
751    }
752
753    /// A base saturation of 0 leaves the base colour as it is (the same bits as before the term existed); otherwise the
754    /// base colour is lerped toward its luma before the details are multiplied over it.
755    #[test]
756    fn the_base_colour_is_desaturated_toward_its_luma() {
757        let mut world = World::new();
758        let mut one = mix(&mut world, 1);
759        let s = samples(1);
760        let plain = one.evaluate(&s, &[]);
761        let a = s.base_colour;
762        let (gain, tint) = (one.gain, one.details[0].tint);
763        let by_hand = |saturation: f32| -> [f32; 3] {
764            let luma = a[0] * LUMA[0] + a[1] * LUMA[1] + a[2] * LUMA[2];
765            std::array::from_fn(|i| {
766                let base = a[i] + (luma - a[i]) * saturation;
767                (gain * base * (tint[i] * s.details[0].colour[i])).clamp(0., 1.)
768            })
769        };
770        let none: [f32; 3] =
771            std::array::from_fn(|i| (gain * a[i] * (tint[i] * s.details[0].colour[i])).clamp(0., 1.));
772        assert_eq!(plain.c, none);
773        for saturation in [0.25, 1.] {
774            one.base_saturation = saturation;
775            assert_eq!(one.evaluate(&s, &[]).c, by_hand(saturation), "{saturation}");
776        }
777        assert!((LUMA.iter().sum::<f32>() - 1.).abs() < 1e-6);
778        // a grey base colour is its own luma
779        let mut grey = samples(1);
780        grey.base_colour = [0.25, 0.25, 0.25, 1.];
781        let desaturated = one.evaluate(&grey, &[]);
782        one.base_saturation = 0.;
783        let kept = one.evaluate(&grey, &[]);
784        for i in 0..3 {
785            assert!((kept.c[i] - desaturated.c[i]).abs() <= 1e-6, "lane {i}");
786        }
787    }
788
789    /// The `max`, `affine` and `saturate` steps are taken as they read, in turn, and a NaN is the bound (`max`) or 0
790    /// (`saturate`) as a D3D program's is.
791    #[test]
792    fn the_ramp_steps_are_taken_in_turn() {
793        let ramp = [
794            WeightStep::Affine {
795                scale: 2.,
796                bias: -0.5,
797            },
798            WeightStep::Saturate,
799        ];
800        assert_eq!(weight_steps(0.375, &ramp), 0.25);
801        assert_eq!(weight_steps(0.125, &ramp), 0.);
802        assert_eq!(weight_steps(1., &ramp), 1.);
803        assert_eq!(weight_steps(f32::NAN, &ramp), 0.);
804        assert_eq!(
805            weight_steps(-1., &[WeightStep::Max(0.), WeightStep::Power(8)]),
806            0.
807        );
808        assert_eq!(weight_steps(f32::NAN, &[WeightStep::Max(0.)]), 0.);
809        assert_eq!(weight_steps(0.5, &[WeightStep::Max(0.75)]), 0.75);
810        // a power after the ramp window: the order matters
811        assert_eq!(
812            weight_steps(
813                0.5,
814                &[
815                    WeightStep::Affine {
816                        scale: 1.,
817                        bias: 0.25
818                    },
819                    WeightStep::Power(2)
820                ]
821            ),
822            0.5625
823        );
824        let mut world = World::new();
825        let mut weight = |steps| Weight {
826            source: Source::Mask {
827                texture: TextureRef::new(world.spawn()),
828                channels: [0., 0., 0., 1.],
829                bias: 0.,
830            },
831            steps,
832        };
833        assert_eq!(weight(ramp.to_vec()).check(), Ok(()));
834        assert!(
835            weight(vec![WeightStep::Affine {
836                scale: f32::NAN,
837                bias: 0.
838            }])
839            .check()
840            .unwrap_err()
841            .contains("affine")
842        );
843        assert!(
844            weight(vec![WeightStep::Max(f32::INFINITY)])
845                .check()
846                .unwrap_err()
847                .contains("max bound")
848        );
849        assert_eq!(weight(vec![WeightStep::Saturate]).check(), Ok(()));
850    }
851
852    /// The gloss is computed as the program stores it and decoded by the stated encoding (for example the square root of
853    /// the stored value, times 0.95); an encoding of power 0 is refused.
854    #[test]
855    fn the_gloss_is_decoded_by_its_encoding() {
856        let mut world = World::new();
857        let linear = mix(&mut world, 2);
858        let encoding = ScalarEncoding {
859            power: 1,
860            root: 2,
861            scale_bits: 0.95f32.to_bits(),
862        };
863        let encoded = DetailLerp {
864            gloss_encoding: encoding,
865            ..linear.clone()
866        };
867        let mut s = samples(2);
868        s.base_normal = [0.5, 0.5, 0.25, 0.75];
869        s.details[1].normal = [0.5, 0.5, 0.5, 0.];
870        let weights = linear.weight_values(&[1. - s.base_normal[3]]);
871        let stored = linear.evaluate(&s, &weights).gloss;
872        assert!(stored > 0. && stored < 1.);
873        let gloss = encoded.evaluate(&s, &weights).gloss;
874        assert_eq!(gloss, encoding.decode(stored));
875        assert_eq!(gloss, stored.sqrt() * 0.95);
876        let e = world.spawn();
877        let bad = DetailedMaterial {
878            mix: DetailMix::DetailLerp(DetailLerp {
879                gloss_encoding: ScalarEncoding { power: 0, ..encoding },
880                ..linear
881            }),
882            lowering: SurfaceLowering::GlossReflectance { dielectric_f0: 0.03 },
883            frozen: Vec::new(),
884        };
885        assert!(bad.verify(e, &world).unwrap_err().contains("gloss encoding"));
886    }
887
888    /// A power of two is taken by squaring and any other exponent by multiplying in from the left; an exponent of 0 or
889    /// past the limit is refused.
890    #[test]
891    fn a_power_is_taken_in_its_stated_order_and_bounded() {
892        let x = 0.737_f32;
893        assert_eq!(power(x, 1), x);
894        assert_eq!(power(x, 8).to_bits(), {
895            let a = x * x;
896            let b = a * a;
897            (b * b).to_bits()
898        });
899        assert_eq!(power(x, 3).to_bits(), ((x * x) * x).to_bits());
900        let channel = |exponent| Channel::Power {
901            v: Source::Vertex {
902                semantic: crate::Semantic::Color(0),
903                channel: 0,
904            },
905            exponent,
906        };
907        assert!(channel(0).check().is_err());
908        assert!(channel(crate::layered::POWER_LIMIT + 1).check().is_err());
909        assert!(channel(16).check().is_ok());
910        assert_eq!(channel(2).evaluate(&[0.5]), 0.25);
911    }
912
913    /// A weight's power is taken by squaring, the powers of the exponent's set bits multiplied in from the lowest, as
914    /// one source program writes `(22 N.z)^260` (`x^4 * x^256`) and others `(1 - N.a)^8` (three squarings, the
915    /// layered [`power`]'s bits); a min takes its bound where the value is NaN.
916    #[test]
917    fn a_weights_steps_are_taken_in_the_programs_order() {
918        let x = 0.987_654_3_f32;
919        let squares: Vec<f32> = std::iter::successors(Some(x), |s| Some(s * s)).take(9).collect();
920        assert_eq!(
921            weight_power(x, 260).to_bits(),
922            (squares[2] * squares[8]).to_bits()
923        );
924        assert_eq!(weight_power(x, 8).to_bits(), power(x, 8).to_bits());
925        assert_eq!(weight_power(x, 1), x);
926        assert_eq!(
927            weight_power(x, 7).to_bits(),
928            ((squares[0] * squares[1]) * squares[2]).to_bits()
929        );
930        let mut world = World::new();
931        let switch_one = Weight {
932            source: Source::Mask {
933                texture: TextureRef::new(world.spawn()),
934                channels: [0., 0., 22., 0.],
935                bias: 0.,
936            },
937            steps: vec![WeightStep::Power(260), WeightStep::Min(1.)],
938        };
939        assert_eq!(switch_one.check(), Ok(()));
940        // a base normal z of 0 takes the first detail alone, one of 1/11 (22 z = 2, past 1 before the power) the second
941        assert_eq!(switch_one.of_sample([0.5, 0.5, 0., 0.5]), Some(0.));
942        assert_eq!(switch_one.of_sample([0.5, 0.5, 1. / 11., 0.5]), Some(1.));
943        let z = 0.045_f32;
944        let v = 22. * z;
945        let squares: Vec<f32> = std::iter::successors(Some(v), |s| Some(s * s)).take(9).collect();
946        assert_eq!(
947            switch_one.of_sample([0.5, 0.5, z, 0.5]).unwrap().to_bits(),
948            (squares[2] * squares[8]).min(1.).to_bits()
949        );
950        assert_eq!(switch_one.evaluate(f32::NAN), 1.);
951        // the old channel's eighth power and the weight's are the same bits
952        let old = Channel::Power {
953            v: Source::Vertex {
954                semantic: crate::Semantic::Color(0),
955                channel: 0,
956            },
957            exponent: 8,
958        };
959        let new = weight(TextureRef::new(world.spawn()), true);
960        for a in [0.1_f32, 0.37, 0.5, 0.93] {
961            assert_eq!(old.evaluate(&[a]).to_bits(), new.evaluate(a).to_bits());
962        }
963    }
964
965    /// A detail's gloss term is scaled as it states: switch 1's first detail by 0.5, its second by the base normal's z,
966    /// lerped by the weight like any other term; a scale every detail takes from the base normal's z is that z.
967    #[test]
968    fn each_detail_scales_its_gloss_as_it_states() {
969        let mut world = World::new();
970        let mut two = mix(&mut world, 2);
971        let s = samples(2);
972        let n = s.base_normal;
973        let w = 0.25_f32;
974        let base = two.evaluate(&s, &[w]).gloss;
975        let terms: Vec<f32> = (0..2)
976            .map(|j| s.details[j].normal[2] + two.details[j].gloss_bias)
977            .collect();
978        let g = terms[0] + (terms[1] - terms[0]) * w;
979        assert_eq!(base, (2. * n[2] * g).clamp(0., 1.));
980        two.details[0].gloss_scale = GlossScale::Constant(0.5);
981        let scale = 0.5 + (n[2] - 0.5) * w;
982        assert_eq!(two.evaluate(&s, &[w]).gloss, (2. * scale * g).clamp(0., 1.));
983    }
984
985    /// The alpha is the stated detail's colour alpha times the base colour's, and 1 where none is stated.
986    #[test]
987    fn the_alpha_is_the_stated_details() {
988        let mut world = World::new();
989        let mut two = mix(&mut world, 2);
990        let s = samples(2);
991        two.alpha = Some(1);
992        assert_eq!(two.evaluate(&s, &[0.5]).alpha, 0.);
993        two.alpha = Some(0);
994        assert_eq!(two.evaluate(&s, &[0.5]).alpha, 0.5);
995        two.alpha = None;
996        assert_eq!(two.evaluate(&s, &[0.5]).alpha, 1.);
997    }
998
999    /// Under each lowering the surface is held as a G-buffer holds it: an F0 reflectance (the albedo `(1 - f) C colour`),
1000    /// or a metallic factor (the albedo `C colour`); either way roughness `1 - gloss` and the normal made unit length.
1001    #[test]
1002    fn a_surface_lowers_to_the_pbr_model_as_its_g_buffer_holds_it() {
1003        let surface = DetailSurface {
1004            c: [0.25, 0.5, 0.75],
1005            colour: [1., 1., 0.5],
1006            mix: 0.5,
1007            alpha: 0.5,
1008            normal: [0., 3., 4.],
1009            gloss: 0.75,
1010        };
1011        let f0 = SurfaceLowering::GlossReflectance { dielectric_f0: 0. };
1012        let written = f0.written(&surface);
1013        assert_eq!(written.albedo, [0.125, 0.25, 0.1875]);
1014        assert_eq!(written.reflectance, Some([0.125, 0.25, 0.375]));
1015        assert_eq!(written.metallic, None);
1016        let pixel = f0.evaluate(&surface);
1017        assert_eq!(pixel.base_color, [0.125, 0.25, 0.1875, 0.5]);
1018        assert_eq!(pixel.normal, [0., 0.6, 0.8]);
1019        assert_eq!((pixel.roughness, pixel.metallic), (0.25, 0.));
1020        assert_eq!(pixel.specular_color, Some([0.125, 0.25, 0.375]));
1021        assert_eq!(0.04 * SPECULAR_COLOR_FACTOR, 1.);
1022        let metal = SurfaceLowering::MetallicGloss;
1023        let written = metal.written(&surface);
1024        assert_eq!(written.albedo, [0.25, 0.5, 0.375]);
1025        assert_eq!((written.reflectance, written.metallic), (None, Some(0.5)));
1026        let pixel = metal.evaluate(&surface);
1027        assert_eq!(pixel.base_color, [0.25, 0.5, 0.375, 0.5]);
1028        assert_eq!(pixel.normal, [0., 0.6, 0.8]);
1029        assert_eq!(
1030            (pixel.roughness, pixel.metallic, pixel.specular_color),
1031            (0.25, 0.5, None)
1032        );
1033        // a dielectric reflectance that is not finite is refused
1034        let mut world = World::new();
1035        let e = world.spawn();
1036        let bad = DetailedMaterial {
1037            mix: DetailMix::DetailLerp(mix(&mut world, 1)),
1038            lowering: SurfaceLowering::GlossReflectance {
1039                dielectric_f0: f32::NAN,
1040            },
1041            frozen: Vec::new(),
1042        };
1043        assert!(bad.verify(e, &world).unwrap_err().contains("dielectric"));
1044    }
1045
1046    /// A dead texture, a constant that is not finite, an unbounded weight, no or too many details and a weight count other
1047    /// than one fewer are refused; the textures are its references.
1048    #[test]
1049    fn a_detail_material_verifies_its_textures_constants_and_weights() {
1050        let mut world = World::new();
1051        let good = DetailedMaterial {
1052            mix: DetailMix::DetailLerp(mix(&mut world, 2)),
1053            lowering: SurfaceLowering::GlossReflectance { dielectric_f0: 0.03 },
1054            frozen: vec![
1055                Frozen::FrontFacing,
1056                Frozen::EngineConstant {
1057                    name: "SomeEngineConstant".into(),
1058                    value: vec![0.],
1059                },
1060            ],
1061        };
1062        let e = world.spawn();
1063        assert_eq!(good.verify(e, &world), Ok(()));
1064        let mut refs = Vec::new();
1065        good.references(&mut refs);
1066        assert_eq!(refs.len(), 8, "seven textures and the weight's");
1067        let edit = |f: &dyn Fn(&mut DetailLerp)| {
1068            let mut bad = good.clone();
1069            let DetailMix::DetailLerp(m) = &mut bad.mix;
1070            f(m);
1071            bad
1072        };
1073        let bad = edit(&|m| m.gain = f32::NAN);
1074        assert!(bad.verify(e, &world).unwrap_err().contains("not finite"));
1075        let bad = edit(&|m| m.base_saturation = f32::INFINITY);
1076        assert!(bad.verify(e, &world).unwrap_err().contains("not finite"));
1077        let mut bad = good.clone();
1078        bad.frozen.push(Frozen::EngineTexture {
1079            name: "g_DecalTexture".into(),
1080            value: [f32::INFINITY; 4],
1081        });
1082        assert!(bad.verify(e, &world).unwrap_err().contains("frozen"));
1083        let bad = edit(&|m| m.details[1].reflectance = ReflectanceTerm::Bias(f32::INFINITY));
1084        assert!(bad.verify(e, &world).unwrap_err().contains("not finite"));
1085        let bad = edit(&|m| m.weights[0].steps = vec![WeightStep::Power(0)]);
1086        assert!(bad.verify(e, &world).unwrap_err().contains("exponent"));
1087        let bad = edit(&|m| m.weights[0].steps = vec![WeightStep::Power(WEIGHT_POWER_LIMIT + 1)]);
1088        assert!(bad.verify(e, &world).unwrap_err().contains("exponent"));
1089        let bad = edit(&|m| m.weights[0].steps.push(WeightStep::Min(f32::NAN)));
1090        assert!(bad.verify(e, &world).unwrap_err().contains("min bound"));
1091        let bad = edit(&|m| m.alpha = Some(2));
1092        assert!(bad.verify(e, &world).unwrap_err().contains("detail 2's, of 2"));
1093        let bad = edit(&|m| m.details[0].gloss_scale = GlossScale::Constant(f32::INFINITY));
1094        assert!(bad.verify(e, &world).unwrap_err().contains("not finite"));
1095        let bad = edit(&|m| m.weights.clear());
1096        assert!(bad.verify(e, &world).unwrap_err().contains("one fewer"));
1097        let bad = edit(&|m| {
1098            m.details.clear();
1099            m.weights.clear();
1100        });
1101        assert!(bad.verify(e, &world).unwrap_err().contains("1 to 3"));
1102        let mut four = mix(&mut world, 3);
1103        four.details.push(four.details[0].clone());
1104        four.weights.push(four.weights[0].clone());
1105        let bad = DetailedMaterial {
1106            mix: DetailMix::DetailLerp(four),
1107            ..good.clone()
1108        };
1109        assert!(bad.verify(e, &world).unwrap_err().contains("1 to 3"));
1110        let dead = world.spawn();
1111        world.despawn(dead);
1112        let bad = edit(&|m| m.details[1].colour = TextureRef::new(dead));
1113        assert!(bad.verify(e, &world).unwrap_err().contains("not alive"));
1114    }
1115}