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}