Skip to main content

astrelis_text/
cache.rs

1//! Text shaping cache for performance optimization.
2//!
3//! This module implements version-based text caching.
4//! It caches shaped text results to avoid expensive reshaping operations every frame.
5
6use astrelis_core::alloc::HashMap;
7use std::sync::Arc;
8
9/// Key for caching shaped text results.
10///
11/// Uses version numbers and bucketed dimensions to create stable cache keys
12/// while allowing reasonable reuse across similar layouts.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct ShapeKey {
15    /// Font ID from the font system
16    pub font_id: u32,
17    /// Font size in pixels (rounded to nearest integer)
18    pub font_size_px: u16,
19    /// Text content version (from TextValue)
20    pub text_hash: u32,
21    /// Wrap width bucketed to 4px increments (0 = no wrap)
22    pub wrap_width_bucket: u16,
23}
24
25impl ShapeKey {
26    /// Create a new shape key with width bucketing.
27    pub fn new(font_id: u32, font_size: f32, text_content: &str, wrap_width: Option<f32>) -> Self {
28        Self {
29            font_id,
30            font_size_px: font_size.round() as u16,
31            text_hash: fxhash::hash32(text_content),
32            wrap_width_bucket: wrap_width.map(Self::bucket_width).unwrap_or(0),
33        }
34    }
35
36    /// Bucket width to 4px increments to increase cache hit rate.
37    ///
38    /// This allows text shaped at width 402px to be reused at 404px,
39    /// trading minimal visual accuracy for better cache performance.
40    fn bucket_width(width: f32) -> u16 {
41        (width / 4.0).round() as u16
42    }
43
44    /// Get the actual bucketed width value.
45    pub fn bucketed_width(&self) -> Option<f32> {
46        if self.wrap_width_bucket == 0 {
47            None
48        } else {
49            Some(self.wrap_width_bucket as f32 * 4.0)
50        }
51    }
52}
53
54/// Cached shaped text data.
55///
56/// Stores the expensive results of text shaping so they can be reused
57/// across multiple frames without reshaping.
58#[derive(Debug, Clone)]
59pub struct ShapedTextData {
60    /// Text content that was shaped
61    pub content: String,
62    /// Measured bounds (width, height)
63    pub bounds: (f32, f32),
64    /// The shaped buffer from the font renderer
65    /// Note: In a real implementation, this would contain the actual shaped runs,
66    /// glyph positions, etc. For now we store measurement data.
67    pub shaped_at_version: u32,
68    /// Render count for this cached entry
69    pub render_count: u64,
70}
71
72impl ShapedTextData {
73    pub fn new(content: String, bounds: (f32, f32), version: u32) -> Self {
74        Self {
75            content,
76            bounds,
77            shaped_at_version: version,
78            render_count: 0,
79        }
80    }
81}
82
83/// Cache for shaped text results.
84///
85/// Uses version-based keys to invalidate cached data when text content,
86/// font properties, or layout constraints change.
87pub struct TextShapeCache {
88    cache: HashMap<ShapeKey, Arc<ShapedTextData>>,
89    /// Statistics for monitoring cache performance
90    pub hits: u64,
91    pub misses: u64,
92}
93
94impl TextShapeCache {
95    /// Create a new empty text shape cache.
96    pub fn new() -> Self {
97        Self {
98            cache: HashMap::with_capacity(256),
99            hits: 0,
100            misses: 0,
101        }
102    }
103
104    /// Get cached shaped text or compute it.
105    ///
106    /// Returns an Arc to the shaped data, allowing cheap cloning and sharing.
107    /// The cache is invalidated automatically when the key changes (version bump).
108    pub fn get_or_shape<F>(&mut self, key: ShapeKey, shape_fn: F) -> Arc<ShapedTextData>
109    where
110        F: FnOnce() -> ShapedTextData,
111    {
112        if let Some(cached) = self.cache.get_mut(&key) {
113            self.hits += 1;
114            // Increment render count to track cache effectiveness
115            if let Some(data) = Arc::get_mut(cached) {
116                data.render_count += 1;
117            }
118            return cached.clone();
119        }
120
121        self.misses += 1;
122        let shaped = Arc::new(shape_fn());
123        self.cache.insert(key, shaped.clone());
124        shaped
125    }
126
127    /// Get cached data without computing if missing.
128    pub fn get(&mut self, key: &ShapeKey) -> Option<Arc<ShapedTextData>> {
129        let result = self.cache.get_mut(key).map(|cached| {
130            if let Some(data) = Arc::get_mut(cached) {
131                data.render_count += 1;
132            }
133            cached.clone()
134        });
135        if result.is_some() {
136            self.hits += 1;
137        } else {
138            self.misses += 1;
139        }
140        result
141    }
142
143    /// Insert shaped data into the cache.
144    pub fn insert(&mut self, key: ShapeKey, data: ShapedTextData) -> Arc<ShapedTextData> {
145        let arc_data = Arc::new(data);
146        self.cache.insert(key, arc_data.clone());
147        arc_data
148    }
149
150    /// Clear the cache (useful when fonts are reloaded).
151    pub fn clear(&mut self) {
152        self.cache.clear();
153        self.hits = 0;
154        self.misses = 0;
155    }
156
157    /// Remove entries older than a certain version (garbage collection).
158    pub fn prune_old_versions(&mut self, min_version: u32) {
159        self.cache
160            .retain(|_key, data| data.shaped_at_version >= min_version);
161    }
162
163    /// Get cache statistics.
164    pub fn hit_rate(&self) -> f32 {
165        let total = self.hits + self.misses;
166        if total == 0 {
167            0.0
168        } else {
169            self.hits as f32 / total as f32
170        }
171    }
172
173    /// Get the number of cached entries.
174    pub fn len(&self) -> usize {
175        self.cache.len()
176    }
177
178    /// Check if the cache is empty.
179    pub fn is_empty(&self) -> bool {
180        self.cache.is_empty()
181    }
182
183    /// Get cache statistics as a formatted string.
184    pub fn stats_string(&self) -> String {
185        let total_renders: u64 = self.cache.values().map(|arc| arc.render_count).sum();
186        format!(
187            "TextCache: {} entries, {:.1}% hit rate ({} hits, {} misses), {} total renders",
188            self.len(),
189            self.hit_rate() * 100.0,
190            self.hits,
191            self.misses,
192            total_renders
193        )
194    }
195
196    /// Get average renders per cached entry (effectiveness metric).
197    pub fn avg_renders_per_entry(&self) -> f32 {
198        if self.cache.is_empty() {
199            return 0.0;
200        }
201        let total_renders: u64 = self.cache.values().map(|arc| arc.render_count).sum();
202        total_renders as f32 / self.cache.len() as f32
203    }
204}
205
206impl Default for TextShapeCache {
207    fn default() -> Self {
208        Self::new()
209    }
210}