Skip to main content

primase_core/
texture_switch.rs

1//! Which form of its textures a placement draws, by distance ([`TextureSwitch`], `core.texture_switch`), and the view
2//! and state the distances are evaluated in ([`TextureSwitchView`], `core.texture_switch_view`).
3//!
4//! The textures' forms are [`crate::TextureForms`] (level 0 the texture's own, level 1 its coarser form). A source that
5//! switches between them per placement while it draws states the rule here, and a renderer applies it every frame:
6//! [`TextureSwitch::next`] from the placement's [`box_distance`] scaled by [`TextureSwitchView::factor`], in the
7//! view's [`TextureSwitchState`]. A writer, which cannot switch per frame, keeps one form with `select-texture-form`.
8
9use serde::Serialize;
10use serde_json::Value;
11
12use crate::{Component, Entity, Instance, JsonError, Resource, Transform, World};
13
14/// The level a placement's textures start at, before the first evaluation: the coarser form.
15pub const TEXTURE_SWITCH_START: u8 = 1;
16
17/// When a placement draws its textures' level-1 forms (`core.texture_switch`), on the placement (an entity with
18/// `core.instance`). Distances are in the world's units, measured from the camera to the placement's world box
19/// ([`box_distance`]) and scaled by [`TextureSwitchView::factor`]. The level starts at [`TEXTURE_SWITCH_START`] and
20/// moves with hysteresis: from 0 to 1 at a distance of `(border + play) * scale * load` or more, from 1 to 0 below
21/// `(border - play) * scale * load * ramp`, otherwise it stays (`load` and `ramp` the view's [`TextureSwitchState`]).
22///
23/// The level picks the form of every texture the placement's prototype binds that has `core.texture_forms`: level 0
24/// the texture's own, level 1 its form at detail 1. A binding the source draws coarse whatever the level binds that form
25/// itself (a texture with no forms), so it does not follow. A placement without the component draws level 0.
26#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
27pub struct TextureSwitch {
28    /// The distance the level changes at, positive.
29    pub border: f32,
30    /// The margin either side of `border` (hysteresis), not negative. It may reach `border`: a placement then never
31    /// returns to level 0 once it left it.
32    pub play: f32,
33    /// A factor on both thresholds, positive (a source's per-kind scale of its draw distances).
34    pub scale: f32,
35}
36
37impl TextureSwitch {
38    /// The level after one evaluation at scaled distance `distance` in `state`, from level `current` (0 or 1; any
39    /// other level is taken as 1).
40    pub fn next(&self, current: u8, distance: f32, state: TextureSwitchState) -> u8 {
41        let threshold = if current == 0 {
42            (self.border + self.play) * self.scale * state.load
43        } else {
44            (self.border - self.play) * self.scale * state.load * state.ramp
45        };
46        u8::from(distance >= threshold)
47    }
48}
49
50fn positive(value: f32, what: &str) -> Result<(), String> {
51    if value.is_finite() && value > 0.0 {
52        Ok(())
53    } else {
54        Err(format!("{what} {value} is not a finite positive number"))
55    }
56}
57
58impl Component for TextureSwitch {
59    const NAME: &'static str = "core.texture_switch";
60    const GEOMETRY: crate::Geometry = crate::Geometry::None;
61    /// World distances of the placement, not local-space geometry.
62    fn apply_linear(&self, _l: &crate::Linear) -> crate::world::Linearity<Self> {
63        crate::world::Linearity::Invariant
64    }
65    fn to_json(&self) -> Result<Option<Value>, JsonError> {
66        crate::json::to_value(self).map(Some)
67    }
68    /// On a placement; `border` and `scale` finite and positive, `play` finite and not negative.
69    fn verify(&self, e: Entity, w: &World) -> Result<(), String> {
70        if !w.has::<Instance>(e) {
71            return Err("a texture switch on an entity that is not a placement (no core.instance)".into());
72        }
73        positive(self.border, "border")?;
74        positive(self.scale, "scale")?;
75        if !(self.play.is_finite() && self.play >= 0.0) {
76            return Err(format!("play {} is not a finite number of 0 or more", self.play));
77        }
78        Ok(())
79    }
80}
81
82/// The view the distances of every [`TextureSwitch`] are stated for, and the state they are evaluated in
83/// (`core.texture_switch_view`, a world resource): a camera of vertical field of view `reference_fov_y` measures them
84/// as they are, a narrower one as nearer; with a `reference_height`, a view taller than it as nearer too. A world with
85/// texture switches has it.
86#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
87pub struct TextureSwitchView {
88    /// Radians, more than 0 and less than pi.
89    pub reference_fov_y: f32,
90    /// The view height in pixels the distances are stated for; 0 is none: the view's height takes no part.
91    pub reference_height: u32,
92    /// The factors on the thresholds the source states the switch in.
93    pub state: TextureSwitchState,
94}
95
96/// The source's factors on a [`TextureSwitch`]'s thresholds, which its own runtime may move: the state a renderer
97/// evaluates the switch in, stated by the source with what it stands for.
98#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
99pub struct TextureSwitchState {
100    /// On both thresholds, from 0 to 1.
101    pub load: f32,
102    /// On the return threshold (level 1 to 0) only, from 0 to 1.
103    pub ramp: f32,
104}
105
106impl TextureSwitchState {
107    /// Neither factor moves a threshold.
108    pub const SETTLED: TextureSwitchState = TextureSwitchState { load: 1.0, ramp: 1.0 };
109}
110
111impl TextureSwitchView {
112    /// What a camera of vertical field of view `fov_y` (radians) drawing a view `height` pixels tall multiplies its
113    /// distances by before comparing them to the thresholds: `tan(fov_y / 2) / tan(reference_fov_y / 2)`, divided by
114    /// `height / reference_height` where `reference_height` is not 0.
115    pub fn factor(&self, fov_y: f32, height: u32) -> f32 {
116        let fov = (fov_y / 2.0).tan() / (self.reference_fov_y / 2.0).tan();
117        if self.reference_height == 0 {
118            fov
119        } else {
120            fov / (height as f32 / self.reference_height as f32)
121        }
122    }
123}
124
125impl Resource for TextureSwitchView {
126    const NAME: &'static str = "core.texture_switch_view";
127    fn to_json(&self) -> Result<Option<Value>, JsonError> {
128        crate::json::to_value(self).map(Some)
129    }
130    /// A field of view strictly between 0 and pi; each factor of the state from 0 to 1.
131    fn verify(&self, _w: &World) -> Result<(), String> {
132        let f = self.reference_fov_y;
133        if !(f.is_finite() && f > 0.0 && f < std::f32::consts::PI) {
134            return Err(format!(
135                "reference vertical field of view {f} is not between 0 and pi radians"
136            ));
137        }
138        for (what, v) in [("load", self.state.load), ("ramp", self.state.ramp)] {
139            if !(0.0..=1.0).contains(&v) {
140                return Err(format!("state {what} {v} is not from 0 to 1"));
141            }
142        }
143        Ok(())
144    }
145}
146
147/// The world-space axis-aligned box of the local boxes `boxes` (min, max) placed by `t`: the union of the eight
148/// transformed corners of each. `None` when `boxes` is empty.
149pub fn world_box(
150    boxes: impl IntoIterator<Item = ([f32; 3], [f32; 3])>,
151    t: &Transform,
152) -> Option<([f32; 3], [f32; 3])> {
153    let mut out: Option<([f32; 3], [f32; 3])> = None;
154    for (lo, hi) in boxes {
155        for corner in 0..8 {
156            let local = [
157                if corner & 1 == 0 { lo[0] } else { hi[0] },
158                if corner & 2 == 0 { lo[1] } else { hi[1] },
159                if corner & 4 == 0 { lo[2] } else { hi[2] },
160            ];
161            let p: [f32; 3] = std::array::from_fn(|k| {
162                t.cols[0][k] * local[0] + t.cols[1][k] * local[1] + t.cols[2][k] * local[2] + t.cols[3][k]
163            });
164            let (min, max) = out.get_or_insert((p, p));
165            for k in 0..3 {
166                min[k] = min[k].min(p[k]);
167                max[k] = max[k].max(p[k]);
168            }
169        }
170    }
171    out
172}
173
174/// The distance from `point` to the axis-aligned box `min`..`max`: to its nearest point, 0 inside.
175pub fn box_distance(point: [f32; 3], min: [f32; 3], max: [f32; 3]) -> f32 {
176    (0..3)
177        .map(|k| {
178            let d = point[k] - point[k].clamp(min[k], max[k]);
179            d * d
180        })
181        .sum::<f32>()
182        .sqrt()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const SWITCH: TextureSwitch = TextureSwitch {
190        border: 30.0,
191        play: 5.0,
192        scale: 1.0,
193    };
194
195    const SETTLED: TextureSwitchState = TextureSwitchState::SETTLED;
196
197    /// Level 0 leaves at border + play, level 1 returns below border - play; between them each stays.
198    #[test]
199    fn the_level_moves_with_hysteresis() {
200        assert_eq!(SWITCH.next(0, 34.9, SETTLED), 0);
201        assert_eq!(SWITCH.next(0, 35.0, SETTLED), 1);
202        assert_eq!(SWITCH.next(1, 25.0, SETTLED), 1);
203        assert_eq!(SWITCH.next(1, 24.9, SETTLED), 0);
204        for d in [25.0, 30.0, 34.9] {
205            assert_eq!(SWITCH.next(0, d, SETTLED), 0, "{d}");
206            assert_eq!(SWITCH.next(1, d, SETTLED), 1, "{d}");
207        }
208        // a placement starts coarse and comes back only inside border - play
209        assert_eq!(SWITCH.next(TEXTURE_SWITCH_START, 30.0, SETTLED), 1);
210        // the scale moves both thresholds
211        let far = TextureSwitch { scale: 2.0, ..SWITCH };
212        assert_eq!((far.next(0, 69.9, SETTLED), far.next(0, 70.0, SETTLED)), (0, 1));
213        assert_eq!((far.next(1, 50.0, SETTLED), far.next(1, 49.9, SETTLED)), (1, 0));
214    }
215
216    /// The state's load moves both thresholds, its ramp the return one alone; a ramp of 0 never returns.
217    #[test]
218    fn the_state_moves_the_thresholds() {
219        let half = TextureSwitchState { load: 0.5, ramp: 1.0 };
220        assert_eq!((SWITCH.next(0, 17.4, half), SWITCH.next(0, 17.5, half)), (0, 1));
221        assert_eq!((SWITCH.next(1, 12.5, half), SWITCH.next(1, 12.4, half)), (1, 0));
222        let ramping = TextureSwitchState { load: 1.0, ramp: 0.5 };
223        assert_eq!(
224            (SWITCH.next(0, 34.9, ramping), SWITCH.next(0, 35.0, ramping)),
225            (0, 1)
226        );
227        assert_eq!(
228            (SWITCH.next(1, 12.5, ramping), SWITCH.next(1, 12.4, ramping)),
229            (1, 0)
230        );
231        let off = TextureSwitchState { load: 1.0, ramp: 0.0 };
232        assert_eq!(SWITCH.next(1, 0.0, off), 1);
233    }
234
235    /// The distance is to the nearest point of the box, 0 inside; the box is the union of every transformed corner.
236    #[test]
237    fn the_distance_is_to_the_world_box() {
238        let (lo, hi) = ([-1.0, 0.0, -1.0], [1.0, 2.0, 1.0]);
239        assert_eq!(box_distance([0.0, 1.0, 0.0], lo, hi), 0.0);
240        assert_eq!(box_distance([4.0, 1.0, 0.0], lo, hi), 3.0);
241        assert_eq!(box_distance([4.0, 6.0, 0.0], lo, hi), 5.0);
242        // a quarter turn about y, then moved 10 along x: the box keeps its size, rotated
243        let t = Transform {
244            cols: [
245                [0.0, 0.0, -1.0],
246                [0.0, 1.0, 0.0],
247                [1.0, 0.0, 0.0],
248                [10.0, 0.0, 0.0],
249            ],
250        };
251        let boxes = [
252            ([0.0, 0.0, 0.0], [2.0, 1.0, 1.0]),
253            ([0.0, 0.0, 0.0], [1.0, 3.0, 1.0]),
254        ];
255        assert_eq!(world_box(boxes, &t), Some(([10.0, 0.0, -2.0], [11.0, 3.0, 0.0])));
256        assert_eq!(world_box([], &t), None);
257    }
258
259    /// The view's factor is 1 at its own field of view and grows with a wider one; Elden Ring's reference (43 deg)
260    /// against a 48 deg camera is the 1.130 of its captured frames. Without a reference height the view's height takes
261    /// no part; with one, a view twice as tall halves the factor.
262    #[test]
263    fn a_wider_camera_measures_farther() {
264        let view = TextureSwitchView {
265            reference_fov_y: 43f32.to_radians(),
266            reference_height: 0,
267            state: SETTLED,
268        };
269        assert!((view.factor(43f32.to_radians(), 1440) - 1.0).abs() < 1e-6);
270        assert!((view.factor(48f32.to_radians(), 1440) - 1.130).abs() < 5e-4);
271        assert_eq!(
272            view.factor(48f32.to_radians(), 720),
273            view.factor(48f32.to_radians(), 1440)
274        );
275        let tall = TextureSwitchView {
276            reference_height: 720,
277            ..view
278        };
279        assert!((tall.factor(43f32.to_radians(), 1440) - 0.5).abs() < 1e-6);
280        assert!((tall.factor(43f32.to_radians(), 720) - 1.0).abs() < 1e-6);
281        let w = World::new();
282        assert_eq!(view.verify(&w), Ok(()));
283        for bad in [0.0, -1.0, std::f32::consts::PI, f32::NAN] {
284            let bad_view = TextureSwitchView {
285                reference_fov_y: bad,
286                ..view
287            };
288            assert!(bad_view.verify(&w).is_err(), "{bad}");
289        }
290        for state in [
291            TextureSwitchState { load: 1.5, ramp: 1.0 },
292            TextureSwitchState {
293                load: 1.0,
294                ramp: -0.1,
295            },
296            TextureSwitchState {
297                load: f32::NAN,
298                ramp: 1.0,
299            },
300        ] {
301            assert!(
302                TextureSwitchView { state, ..view }.verify(&w).is_err(),
303                "{state:?}"
304            );
305        }
306    }
307
308    /// The component sits on a placement and holds finite, positive thresholds.
309    #[test]
310    fn a_switch_is_on_a_placement_with_positive_thresholds() {
311        let mut w = World::new();
312        let prototype = w.spawn();
313        let placed = w.spawn();
314        w.insert(
315            placed,
316            Transform {
317                cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]],
318            },
319        );
320        w.insert(
321            placed,
322            Instance {
323                prototype,
324                variant: 0,
325            },
326        );
327        assert_eq!(SWITCH.verify(placed, &w), Ok(()));
328        assert!(SWITCH.verify(prototype, &w).is_err());
329        for bad in [
330            TextureSwitch {
331                border: 0.0,
332                ..SWITCH
333            },
334            TextureSwitch { scale: 0.0, ..SWITCH },
335            TextureSwitch { play: -1.0, ..SWITCH },
336            TextureSwitch {
337                border: f32::NAN,
338                ..SWITCH
339            },
340        ] {
341            assert!(bad.verify(placed, &w).is_err(), "{bad:?}");
342        }
343        // play at or beyond the border is allowed: such a placement never returns to its own textures
344        assert_eq!(TextureSwitch { play: 40.0, ..SWITCH }.verify(placed, &w), Ok(()));
345    }
346}