Skip to main content

teksilo_scene/
cache.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Item-coordinate paint caching.
5//!
6//! When a [`SceneItem`](crate::SceneItem) returns
7//! [`CacheMode::ItemCoordinate`] from `SceneItem::cache_mode`,
8//! the [`SceneView`](crate::SceneView) caches the item's paint
9//! output as a [`RenderFrame`] in **local item coordinates**. On
10//! subsequent paint passes the cached frame is replayed via
11//! `Canvas::draw_render_frame` instead of re-running
12//! `item.paint`. Cache validity is keyed by
13//! [`Scene::item_change_signal`](crate::Scene::item_change_signal):
14//! a `LocalBoundsChanged` event for an id evicts that id's entry.
15//!
16//! Items whose visual depends on signal state outside of their
17//! `local_bounds` (e.g. `TextItem` with `with_signal_text`) should
18//! NOT use `ItemCoordinate` — the cache won't see signal-driven
19//! repaint dirties. The default for every `SceneItem` is
20//! [`CacheMode::None`].
21
22use std::collections::HashMap;
23
24use teksilo_canvas::RenderFrame;
25
26use crate::item::ItemId;
27
28/// Per-item paint caching strategy.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum CacheMode {
31    /// Re-run `item.paint` every frame. Default for every item.
32    #[default]
33    None,
34    /// Cache the paint output as a [`RenderFrame`] keyed by the
35    /// item's `local_bounds`. Cheap when the item's geometry is
36    /// stable and its content doesn't depend on external signal
37    /// state. The cache is dropped on `LocalBoundsChanged` for the
38    /// id.
39    ItemCoordinate,
40}
41
42/// SceneView's per-item paint cache. Owned by the SceneView, shared
43/// via `Rc<RefCell<>>` so the paint walk and the item-change
44/// observer can both touch it.
45#[derive(Debug, Default)]
46pub struct ItemCoordinateCache {
47    /// `id` → cached RenderFrame in **local item coordinates**, plus
48    /// the text raster scale the frame was recorded at. Looked up by
49    /// the paint walk; dropped on geometry change. The scale rides
50    /// along because glyph quads in the frame sample bitmaps of that
51    /// density: when the item's effective raster scale moves (the
52    /// view's zoom crossed a raster bucket, or the item's own
53    /// transform scale changed), [`get`](Self::get) misses and the
54    /// item re-records against fresh bitmaps. The arena-level
55    /// `paint_raster_scale` stamp can't reach frames cached here, so
56    /// the scale must be part of this cache's own validity.
57    entries: HashMap<ItemId, (RenderFrame, f32)>,
58    /// [`TextBackend::glyph_epoch`](teksilo_canvas::TextBackend::glyph_epoch)
59    /// as of the last paint pass that consulted this cache. Cached
60    /// frames bake glyph atlas UVs; when the backend evicts or resets
61    /// glyphs it bumps the epoch, and every entry here must be dropped
62    /// before being replayed — the baked UVs may now point at pixels
63    /// owned by unrelated glyphs. This cache lives outside the widget
64    /// arena, so the framework-level `invalidate_all_paints` recovery
65    /// cannot reach it; the epoch gate in `paint_band` is what keeps
66    /// it honest.
67    glyph_epoch: u64,
68}
69
70impl ItemCoordinateCache {
71    /// An empty cache.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Whether `id`'s entry is still valid. Test-only diagnostic;
77    /// apps observe cache effectiveness via the parent
78    /// [`SceneView::item_cache_len`](crate::SceneView::item_cache_len)
79    /// or by counting paint calls in their own SceneItem impl.
80    #[cfg(test)]
81    pub(crate) fn contains(&self, id: ItemId) -> bool {
82        self.entries.contains_key(&id)
83    }
84
85    /// Borrow the cached frame for `id`, if any — provided it was
86    /// recorded at `raster_scale`. A scale mismatch reads as a miss:
87    /// the caller re-records and [`insert`](Self::insert) replaces the
88    /// stale entry.
89    pub fn get(&self, id: ItemId, raster_scale: f32) -> Option<&RenderFrame> {
90        self.entries
91            .get(&id)
92            .filter(|(_, baked)| *baked == raster_scale)
93            .map(|(frame, _)| frame)
94    }
95
96    /// Insert (or replace) a cached frame for `id`, recorded at
97    /// `raster_scale`.
98    pub fn insert(&mut self, id: ItemId, frame: RenderFrame, raster_scale: f32) {
99        self.entries.insert(id, (frame, raster_scale));
100    }
101
102    /// Evict `id`'s entry. Called on `ItemChange::LocalBoundsChanged`
103    /// or any other invalidation.
104    pub fn evict(&mut self, id: ItemId) {
105        self.entries.remove(&id);
106    }
107
108    /// Drop every entry. Called when the glyph epoch moves (see
109    /// [`sync_glyph_epoch`](Self::sync_glyph_epoch)).
110    pub fn clear(&mut self) {
111        self.entries.clear();
112    }
113
114    /// Compare the text backend's current glyph epoch against the one
115    /// recorded on the last paint pass; on a change, drop every cached
116    /// frame (their baked atlas UVs may reference recycled slots) and
117    /// record the new epoch. Returns `true` when the cache was cleared.
118    pub fn sync_glyph_epoch(&mut self, current_epoch: u64) -> bool {
119        if self.glyph_epoch == current_epoch {
120            return false;
121        }
122        self.glyph_epoch = current_epoch;
123        let had_entries = !self.entries.is_empty();
124        self.clear();
125        had_entries
126    }
127
128    /// Number of cached entries (diagnostics / tests).
129    pub fn len(&self) -> usize {
130        self.entries.len()
131    }
132
133    /// Whether the cache is empty. Test-only diagnostic.
134    #[cfg(test)]
135    pub(crate) fn is_empty(&self) -> bool {
136        self.entries.is_empty()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn fresh_id() -> ItemId {
145        crate::item::ItemId::next()
146    }
147
148    #[test]
149    fn cache_default_mode_is_none() {
150        assert_eq!(CacheMode::default(), CacheMode::None);
151    }
152
153    #[test]
154    fn cache_round_trip() {
155        let mut c = ItemCoordinateCache::new();
156        let id = fresh_id();
157        assert!(!c.contains(id));
158        c.insert(id, RenderFrame::default(), 1.0);
159        assert!(c.contains(id));
160        assert_eq!(c.len(), 1);
161        c.evict(id);
162        assert!(!c.contains(id));
163        assert!(c.is_empty());
164    }
165
166    #[test]
167    fn sync_glyph_epoch_clears_on_change_only() {
168        let mut c = ItemCoordinateCache::new();
169        let id = fresh_id();
170        c.insert(id, RenderFrame::default(), 1.0);
171
172        // Same epoch as the initial one (0): entries survive.
173        assert!(!c.sync_glyph_epoch(0));
174        assert!(c.contains(id));
175
176        // Epoch moved (glyph eviction / scale reset): everything drops —
177        // the cached frames' baked atlas UVs may reference recycled slots.
178        assert!(c.sync_glyph_epoch(1));
179        assert!(!c.contains(id));
180        assert!(c.is_empty());
181
182        // Same epoch again: a refilled cache survives.
183        c.insert(id, RenderFrame::default(), 1.0);
184        assert!(!c.sync_glyph_epoch(1));
185        assert!(c.contains(id));
186    }
187
188    #[test]
189    fn get_misses_on_raster_scale_mismatch() {
190        let mut c = ItemCoordinateCache::new();
191        let id = fresh_id();
192        c.insert(id, RenderFrame::default(), 1.0);
193
194        // Hit at the recorded scale.
195        assert!(c.get(id, 1.0).is_some());
196        // The zoom crossed a raster bucket: the entry's glyph quads
197        // sample bitmaps of the old density — read as a miss so the
198        // item re-records.
199        assert!(c.get(id, 1.953_125).is_none());
200        // Replacing re-records at the new scale.
201        c.insert(id, RenderFrame::default(), 1.953_125);
202        assert!(c.get(id, 1.953_125).is_some());
203        assert!(c.get(id, 1.0).is_none());
204        assert_eq!(c.len(), 1);
205    }
206}