Skip to main content

primase_core/
reflection_probe.rs

1//! Spatial reflection probes and explicitly selected pairs of environment states.
2use crate::{
3    ColorSpace, Component, Entity, Geometry, IrradianceVolume, JsonError, Linear, TextureLayers,
4    TextureShape, Transform, World,
5};
6use serde::Serialize;
7use serde_json::Value;
8
9/// How surface roughness selects the mip of a stored reflection cube. LODs refer to the
10/// cube's stored mip chain; synthesizing a different chain is a separate render-graph operation.
11#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ProbeMipPolicy {
14    /// Sample this LOD independently of roughness.
15    Fixed(f32),
16    /// Map perceptual roughness in \[0,1\] linearly onto this inclusive LOD interval.
17    LinearRoughness { minimum: f32, maximum: f32 },
18}
19
20/// One authored environment state. Its diffuse field is the existing irradiance-volume
21/// entity, including that entity's transform and coefficient sampling domain.
22#[derive(Clone, Debug, PartialEq, Serialize)]
23pub struct ProbeState {
24    pub cube: Entity,
25    pub diffuse_volume: Option<Entity>,
26    /// Multiplier on the cube's linear radiance, finite and nonnegative.
27    pub intensity: f32,
28    pub mip_policy: ProbeMipPolicy,
29}
30
31/// The caller or source has selected both states and the interpolation factor explicitly.
32/// State identifiers and clock/quality selection remain source concerns. A consumer blends
33/// sampled linear radiance and decoded irradiance as A + (B - A) * factor. It must preserve
34/// both original states when a source program performs this blend itself.
35#[derive(Clone, Debug, PartialEq, Serialize)]
36pub struct EnvironmentProbeBlend {
37    pub state_a: ProbeState,
38    pub state_b: ProbeState,
39    /// Finite, inclusive \[0,1\]. The endpoints still retain both states and their provenance.
40    pub factor: f32,
41}
42
43/// An environment probe's spatial influence. The entity's transform maps the unit box
44/// [-1,1] to world metres. Regions blend in increasing priority; equal-priority order
45/// belongs to the source graph. The fade shell is measured in authored box metres.
46///
47/// The linked diffuse volumes are states of this region, rather than additional independent
48/// contributions. A consumer drawing these regions must exclude their linked volumes from
49/// any independent irradiance-volume accumulation.
50#[derive(Clone, Debug, PartialEq, Serialize)]
51pub struct ReflectionProbeRegion {
52    pub blend: EnvironmentProbeBlend,
53    pub half_extents: [f32; 3],
54    pub fade_distance: f32,
55    pub priority: u8,
56    /// Reflection capture origin in world metres.
57    pub reflection_origin: [f32; 3],
58    /// Optional parallax-correction box relative to the influence region. Absence means
59    /// the sampled reflection direction is used directly.
60    pub parallax_from_region: Option<Transform>,
61}
62
63impl Component for ReflectionProbeRegion {
64    const NAME: &'static str = "core.reflection_probe_region";
65    const GEOMETRY: Geometry = Geometry::None;
66
67    fn references(&self, out: &mut Vec<Entity>) {
68        for state in [&self.blend.state_a, &self.blend.state_b] {
69            out.push(state.cube);
70            out.extend(state.diffuse_volume);
71        }
72    }
73
74    fn to_json(&self) -> Result<Option<Value>, JsonError> {
75        crate::json::to_value(self).map(Some)
76    }
77
78    fn verify(&self, entity: Entity, world: &World) -> Result<(), String> {
79        let transform = world
80            .get::<Transform>(entity)
81            .ok_or("reflection probe region has no transform")?;
82        for transform in std::iter::once(transform).chain(self.parallax_from_region.as_ref()) {
83            if !transform.cols.iter().flatten().all(|v| v.is_finite()) || Linear::of(transform).singular() {
84                return Err("reflection probe region has a nonfinite or singular transform".into());
85            }
86        }
87        if !self.half_extents.iter().all(|v| v.is_finite() && *v > 0.)
88            || !self.fade_distance.is_finite()
89            || self.fade_distance < 0.
90            || !self.reflection_origin.iter().all(|v| v.is_finite())
91        {
92            return Err("reflection probe region has invalid bounds, fade or origin".into());
93        }
94        if !self.blend.factor.is_finite() || !(0. ..=1.).contains(&self.blend.factor) {
95            return Err("environment probe blend factor must be finite and in [0,1]".into());
96        }
97        for state in [&self.blend.state_a, &self.blend.state_b] {
98            if !state.intensity.is_finite() || state.intensity < 0. {
99                return Err("reflection probe intensity must be finite and nonnegative".into());
100            }
101            let cube = world
102                .get::<TextureLayers>(state.cube)
103                .ok_or("reflection probe cube is not a layered texture")?;
104            if cube.shape != (TextureShape::Cube { cubes: 1 })
105                || cube.width != cube.height
106                || cube.color_space != ColorSpace::Linear
107                || cube.levels.is_empty()
108            {
109                return Err("reflection probe requires one square linear cube with stored mips".into());
110            }
111            let (minimum, maximum) = match state.mip_policy {
112                ProbeMipPolicy::Fixed(lod) => (lod, lod),
113                ProbeMipPolicy::LinearRoughness { minimum, maximum } => (minimum, maximum),
114            };
115            if !minimum.is_finite()
116                || !maximum.is_finite()
117                || minimum < 0.
118                || maximum < minimum
119                || maximum > (cube.levels.len() - 1) as f32
120            {
121                return Err("reflection probe LOD policy exceeds its stored mip chain".into());
122            }
123            if let Some(diffuse) = state.diffuse_volume
124                && world.get::<IrradianceVolume>(diffuse).is_none()
125            {
126                return Err("reflection probe diffuse state is not an irradiance volume".into());
127            }
128        }
129        Ok(())
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn region(world: &mut World) -> (Entity, ReflectionProbeRegion) {
138        let cube = world.spawn();
139        world.insert(
140            cube,
141            TextureLayers {
142                shape: TextureShape::Cube { cubes: 1 },
143                width: 1,
144                height: 1,
145                format: crate::TexFormat::Rgba16Float,
146                levels: vec![vec![vec![0; 8]; 6]],
147                color_space: ColorSpace::Linear,
148                role: crate::TexRole::Other,
149                normal: None,
150            },
151        );
152        let state = ProbeState {
153            cube,
154            diffuse_volume: None,
155            intensity: 1.,
156            mip_policy: ProbeMipPolicy::LinearRoughness {
157                minimum: 0.,
158                maximum: 0.,
159            },
160        };
161        let entity = world.spawn();
162        world.insert(entity, Transform::IDENTITY);
163        (
164            entity,
165            ReflectionProbeRegion {
166                blend: EnvironmentProbeBlend {
167                    state_a: state.clone(),
168                    state_b: state,
169                    factor: 0.5,
170                },
171                half_extents: [1.; 3],
172                fade_distance: 0.,
173                priority: 0,
174                reflection_origin: [0.; 3],
175                parallax_from_region: None,
176            },
177        )
178    }
179
180    #[test]
181    fn probe_states_retain_both_cube_and_diffuse_dependencies() {
182        let mut world = World::new();
183        let (entity, mut probe) = region(&mut world);
184        let diffuse = world.spawn();
185        // The link is typed: a name-only entity cannot serve as an irradiance state.
186        probe.blend.state_b.diffuse_volume = Some(diffuse);
187        assert!(probe.verify(entity, &world).is_err());
188        let mut references = Vec::new();
189        probe.references(&mut references);
190        assert_eq!(
191            references,
192            vec![probe.blend.state_a.cube, probe.blend.state_b.cube, diffuse]
193        );
194        probe.blend.state_b.diffuse_volume = None;
195        world.insert(entity, probe);
196        assert!(world.verify_components().is_empty());
197        assert!(world.closure([entity]).contains(&references[0]));
198    }
199
200    #[test]
201    fn probes_refuse_missing_mips_invalid_blends_and_array_stand_ins() {
202        let mut world = World::new();
203        let (entity, mut probe) = region(&mut world);
204        probe.blend.state_a.mip_policy = ProbeMipPolicy::Fixed(1.);
205        assert!(probe.verify(entity, &world).is_err());
206        probe.blend.state_a.mip_policy = ProbeMipPolicy::Fixed(0.);
207        probe.blend.factor = f32::NAN;
208        assert!(probe.verify(entity, &world).is_err());
209        probe.blend.factor = 1.;
210        assert!(probe.verify(entity, &world).is_ok());
211        world
212            .get_mut::<TextureLayers>(probe.blend.state_b.cube)
213            .expect("cube")
214            .shape = TextureShape::Array { layers: 6 };
215        assert!(probe.verify(entity, &world).is_err());
216    }
217}