Skip to main content

concinnity_core/render/decal/
set.rs

1//! The decal slot table the backends share. Holds one slot per decal id with a
2//! tombstone free-list, caches the world AABB the frustum cull tests and the
3//! uniform block the pass uploads, and tracks which frame-in-flight copy of
4//! that block is still stale.
5
6use alloc::vec::Vec;
7use core::cell::Cell;
8use core::fmt;
9
10use crate::gfx::frustum::Frustum;
11use crate::render::decal::DecalRecord;
12use crate::render::frame_dirty::FrameDirty;
13use crate::render::uniforms::DecalParams;
14
15/// Exponent of the decal's edge-fade curve, applied by the fragment shader to
16/// the distance from the projection box's faces.
17const EDGE_FADE_POW: f32 = 2.0;
18
19/// Returned by [`DecalSet::insert`] when every slot up to the set's capacity is
20/// live. The capacity is the backend's own per-decal descriptor reservation, so
21/// the backend words the error.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct AtCapacity;
24
25/// Why [`DecalSet::remove`] rejected an id.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RemoveError {
28    /// No slot has ever been handed out under this id.
29    OutOfRange,
30    /// The id names a slot an earlier remove already tombstoned.
31    AlreadyRemoved,
32}
33
34impl fmt::Display for RemoveError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::OutOfRange => f.write_str("out of range"),
38            Self::AlreadyRemoved => f.write_str("already removed"),
39        }
40    }
41}
42
43// One live decal. `aabb_*` and `params` are derived from `record` at insert and
44// never recomputed: the pass reads them every frame, and a decal's transform is
45// fixed for the life of its slot.
46struct Slot {
47    record: DecalRecord,
48    aabb_min: [f32; 3],
49    aabb_max: [f32; 3],
50    params: DecalParams,
51    dirty: Cell<FrameDirty>,
52}
53
54impl Slot {
55    fn new(record: DecalRecord, frames: usize) -> Self {
56        let (aabb_min, aabb_max) = record.aabb();
57        Self {
58            aabb_min,
59            aabb_max,
60            params: DecalParams {
61                model: record.model,
62                inv_model: record.inv_model,
63                tint: record.tint,
64                fade_pow: EDGE_FADE_POW,
65                _pad0: 0.0,
66                _pad1: 0.0,
67                _pad2: 0.0,
68            },
69            record,
70            dirty: Cell::new(FrameDirty::new(frames)),
71        }
72    }
73}
74
75/// The decals a backend's projected-decal pass draws, addressed by the slot id
76/// [`DecalSet::insert`] returns. That id is stable until [`DecalSet::remove`]
77/// tombstones it, and indexes the backend's parallel per-decal resources (an
78/// albedo descriptor, a uniform ring slot).
79///
80/// `capacity` caps the slot table at the backend's reserved descriptor count;
81/// `frames` is its frames in flight, which sizes the per-slot upload tracking.
82///
83/// ```rust
84/// # use concinnity_core::gfx::transform::IDENTITY;
85/// # use concinnity_core::render::decal::{DecalRecord, DecalSet};
86/// # let record = DecalRecord {
87/// #     model: IDENTITY,
88/// #     inv_model: IDENTITY,
89/// #     texture_slot: 0,
90/// #     tint: [1.0; 4],
91/// # };
92/// let mut decals = DecalSet::new(2, 2);
93/// let id = decals.insert(record).expect("room for two");
94/// decals.remove(id).expect("live slot");
95/// assert!(decals.is_empty());
96/// ```
97pub struct DecalSet {
98    slots: Vec<Option<Slot>>,
99    free_slots: Vec<usize>,
100    live: usize,
101    capacity: usize,
102    frames: usize,
103}
104
105impl DecalSet {
106    /// An empty set holding at most `capacity` slots, tracking uploads for
107    /// `frames` frames in flight.
108    pub fn new(capacity: usize, frames: usize) -> Self {
109        Self {
110            slots: Vec::new(),
111            free_slots: Vec::new(),
112            live: 0,
113            capacity,
114            frames,
115        }
116    }
117
118    /// Place `record` in a slot and return its id, reusing a tombstoned slot
119    /// before growing the table so a spawn / despawn cycle stays bounded. The
120    /// new slot is marked stale for every frame in flight.
121    pub fn insert(&mut self, record: DecalRecord) -> Result<usize, AtCapacity> {
122        let slot = Slot::new(record, self.frames);
123        let id = match self.free_slots.pop() {
124            Some(id) => {
125                self.slots[id] = Some(slot);
126                id
127            }
128            None => {
129                if self.slots.len() >= self.capacity {
130                    return Err(AtCapacity);
131                }
132                self.slots.push(Some(slot));
133                self.slots.len() - 1
134            }
135        };
136        self.live += 1;
137        Ok(id)
138    }
139
140    /// Tombstone the slot at `id`, freeing it for the next [`Self::insert`].
141    pub fn remove(&mut self, id: usize) -> Result<(), RemoveError> {
142        let slot = self.slots.get_mut(id).ok_or(RemoveError::OutOfRange)?;
143        if slot.take().is_none() {
144            return Err(RemoveError::AlreadyRemoved);
145        }
146        self.free_slots.push(id);
147        self.live -= 1;
148        Ok(())
149    }
150
151    /// Whether no slot is live, so the pass can be skipped outright.
152    pub fn is_empty(&self) -> bool {
153        self.live == 0
154    }
155
156    /// The live decals whose cached world AABB meets `frustum`, in slot order.
157    /// Each decal is tested once: a caller that needs to know whether the pass
158    /// draws anything peeks this iterator rather than counting it first.
159    pub fn visible<'a>(
160        &'a self,
161        frustum: &'a Frustum,
162    ) -> impl Iterator<Item = VisibleDecal<'a>> + 'a {
163        self.slots.iter().enumerate().filter_map(move |(id, slot)| {
164            let slot = slot.as_ref()?;
165            if !frustum.intersects_aabb(slot.aabb_min, slot.aabb_max) {
166                return None;
167            }
168            Some(VisibleDecal {
169                id,
170                record: &slot.record,
171                params: &slot.params,
172                dirty: &slot.dirty,
173            })
174        })
175    }
176}
177
178/// A decal that survived the frustum cull, with the per-decal inputs its draw
179/// needs.
180pub struct VisibleDecal<'a> {
181    /// The decal's slot id, indexing the backend's parallel per-decal
182    /// resources.
183    pub id: usize,
184    /// The decal's record.
185    pub record: &'a DecalRecord,
186    /// The uniform block the pass binds for this decal.
187    pub params: &'a DecalParams,
188    dirty: &'a Cell<FrameDirty>,
189}
190
191impl VisibleDecal<'_> {
192    /// Whether frame `frame`'s copy of [`Self::params`] is stale, clearing the
193    /// flag if so. A backend whose ring slot survives the frame writes only
194    /// when this reports true; one that re-supplies the block inline with every
195    /// draw ignores it.
196    pub fn take_upload(&self, frame: usize) -> bool {
197        let mut dirty = self.dirty.get();
198        let pending = dirty.take(frame);
199        self.dirty.set(dirty);
200        pending
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::render::decal::decal_model_matrix;
208    use crate::render::decal::invert_decal_model;
209
210    fn record_at(position: [f32; 3]) -> DecalRecord {
211        let model = decal_model_matrix(position, [0.0; 3], [1.0; 3]);
212        DecalRecord {
213            model,
214            inv_model: invert_decal_model(model).expect("unit decal inverts"),
215            texture_slot: 0,
216            tint: [1.0; 4],
217        }
218    }
219
220    // A frustum that admits everything: six planes whose normals all point
221    // inward from far outside the sampled range.
222    fn frustum_containing_everything() -> Frustum {
223        use crate::gfx::frustum::Plane;
224        let plane = |normal: [f32; 3]| Plane { normal, d: 1.0e6 };
225        Frustum {
226            planes: [
227                plane([1.0, 0.0, 0.0]),
228                plane([-1.0, 0.0, 0.0]),
229                plane([0.0, 1.0, 0.0]),
230                plane([0.0, -1.0, 0.0]),
231                plane([0.0, 0.0, 1.0]),
232                plane([0.0, 0.0, -1.0]),
233            ],
234        }
235    }
236
237    // A frustum admitting only points with x <= 10: the +X plane's half-space.
238    fn frustum_left_of_ten() -> Frustum {
239        use crate::gfx::frustum::Plane;
240        let wide = |normal: [f32; 3]| Plane { normal, d: 1.0e6 };
241        let mut planes = frustum_containing_everything().planes;
242        planes[0] = Plane {
243            normal: [-1.0, 0.0, 0.0],
244            d: 10.0,
245        };
246        planes[1] = wide([1.0, 0.0, 0.0]);
247        Frustum { planes }
248    }
249
250    #[test]
251    fn an_empty_set_is_empty_and_draws_nothing() {
252        let decals = DecalSet::new(4, 2);
253        assert!(decals.is_empty());
254        assert_eq!(decals.visible(&frustum_containing_everything()).count(), 0);
255    }
256
257    #[test]
258    fn insert_hands_out_ascending_ids_up_to_capacity() {
259        let mut decals = DecalSet::new(2, 2);
260        assert_eq!(decals.insert(record_at([0.0; 3])), Ok(0));
261        assert_eq!(decals.insert(record_at([0.0; 3])), Ok(1));
262        assert_eq!(decals.insert(record_at([0.0; 3])), Err(AtCapacity));
263        assert!(!decals.is_empty());
264    }
265
266    #[test]
267    fn a_removed_slot_is_reused_before_the_table_grows() {
268        let mut decals = DecalSet::new(2, 2);
269        let first = decals.insert(record_at([0.0; 3])).expect("first slot");
270        decals.insert(record_at([0.0; 3])).expect("second slot");
271        decals.remove(first).expect("live slot");
272        assert_eq!(decals.insert(record_at([0.0; 3])), Ok(first));
273        // The table never grew past its capacity, so a third add still fails.
274        assert_eq!(decals.insert(record_at([0.0; 3])), Err(AtCapacity));
275    }
276
277    #[test]
278    fn remove_rejects_unknown_and_repeated_ids() {
279        let mut decals = DecalSet::new(2, 2);
280        assert_eq!(decals.remove(0), Err(RemoveError::OutOfRange));
281        let id = decals.insert(record_at([0.0; 3])).expect("free slot");
282        assert_eq!(decals.remove(id), Ok(()));
283        assert_eq!(decals.remove(id), Err(RemoveError::AlreadyRemoved));
284        assert!(decals.is_empty());
285    }
286
287    #[test]
288    fn remove_errors_read_as_the_backend_messages() {
289        use alloc::format;
290        assert_eq!(format!("{}", RemoveError::OutOfRange), "out of range");
291        assert_eq!(
292            format!("{}", RemoveError::AlreadyRemoved),
293            "already removed"
294        );
295    }
296
297    #[test]
298    fn only_slots_meeting_the_frustum_are_visible() {
299        let mut decals = DecalSet::new(4, 2);
300        let near = decals.insert(record_at([0.0; 3])).expect("free slot");
301        let far = decals
302            .insert(record_at([100.0, 0.0, 0.0]))
303            .expect("free slot");
304        let frustum = frustum_left_of_ten();
305        let ids: Vec<usize> = decals.visible(&frustum).map(|d| d.id).collect();
306        assert_eq!(ids, alloc::vec![near]);
307        // The culled slot is still live: a wider frustum draws both.
308        let all: Vec<usize> = decals
309            .visible(&frustum_containing_everything())
310            .map(|d| d.id)
311            .collect();
312        assert_eq!(all, alloc::vec![near, far]);
313    }
314
315    #[test]
316    fn a_tombstoned_slot_never_becomes_visible() {
317        let mut decals = DecalSet::new(4, 2);
318        let id = decals.insert(record_at([0.0; 3])).expect("free slot");
319        decals.remove(id).expect("live slot");
320        assert_eq!(decals.visible(&frustum_containing_everything()).count(), 0);
321    }
322
323    #[test]
324    fn visible_carries_the_cached_params_of_its_record() {
325        let mut decals = DecalSet::new(4, 2);
326        let record = record_at([1.0, 2.0, 3.0]);
327        decals.insert(record).expect("free slot");
328        let frustum = frustum_containing_everything();
329        let decal = decals.visible(&frustum).next().expect("one visible decal");
330        assert_eq!(decal.record.model, record.model);
331        assert_eq!(decal.params.model, record.model);
332        assert_eq!(decal.params.inv_model, record.inv_model);
333        assert_eq!(decal.params.tint, record.tint);
334        assert_eq!(decal.params.fade_pow, EDGE_FADE_POW);
335    }
336
337    #[test]
338    fn a_new_slot_uploads_once_per_frame_in_flight() {
339        let mut decals = DecalSet::new(4, 3);
340        decals.insert(record_at([0.0; 3])).expect("free slot");
341        let frustum = frustum_containing_everything();
342        for frame in 0..3 {
343            let decal = decals.visible(&frustum).next().expect("visible");
344            assert!(decal.take_upload(frame), "frame {frame} seeds stale");
345        }
346        // Steady state: the ring has caught up and every frame writes nothing.
347        for frame in (0..3).cycle().take(9) {
348            let decal = decals.visible(&frustum).next().expect("visible");
349            assert!(!decal.take_upload(frame), "frame {frame} stays clean");
350        }
351    }
352
353    #[test]
354    fn a_reused_slot_is_stale_again_for_every_frame() {
355        let mut decals = DecalSet::new(4, 2);
356        let id = decals.insert(record_at([0.0; 3])).expect("free slot");
357        let frustum = frustum_containing_everything();
358        for frame in 0..2 {
359            assert!(
360                decals
361                    .visible(&frustum)
362                    .next()
363                    .expect("visible")
364                    .take_upload(frame)
365            );
366        }
367        decals.remove(id).expect("live slot");
368        assert_eq!(decals.insert(record_at([5.0, 0.0, 0.0])), Ok(id));
369        for frame in 0..2 {
370            let decal = decals.visible(&frustum).next().expect("visible");
371            assert!(
372                decal.take_upload(frame),
373                "frame {frame} re-armed by the add"
374            );
375        }
376    }
377
378    #[test]
379    fn a_decal_culled_this_frame_keeps_its_pending_upload() {
380        let mut decals = DecalSet::new(4, 2);
381        decals
382            .insert(record_at([100.0, 0.0, 0.0]))
383            .expect("free slot");
384        // Culled: the pass never reaches it, so nothing is taken.
385        assert_eq!(decals.visible(&frustum_left_of_ten()).count(), 0);
386        let frustum = frustum_containing_everything();
387        for frame in 0..2 {
388            let decal = decals.visible(&frustum).next().expect("visible");
389            assert!(decal.take_upload(frame), "frame {frame} still owes a write");
390        }
391    }
392}