Skip to main content

wisp/text/
texture.rs

1//! Text render-to-texture path (M-TEXT.5 / AUT-79).
2//!
3//! Pairs [`FlexibleTextEngine`] (layout) and [`FlexibleTextRenderer`]
4//! (rasterization) with a FIFO-bounded cache keyed on
5//! `(content, style, wrap_width, dimensions)`. Static text reuses the
6//! cached texture; any change to the inputs invalidates the entry.
7//!
8//! The texture is a [`RenderTexture`], so downstream callers can feed
9//! it into the same render path as masks (M-DYN.1) and dynamic
10//! textures — filter / mask / blend / export composition all become
11//! sprite-pipeline routing decisions (the M-TEXT.6 work).
12//!
13//! Modeled on the `mask_cache` pattern in `crate::render`: same FIFO
14//! eviction at `MAX_ENTRIES`, same `Arc`-shared texture ownership,
15//! same `(hits, misses)` instrumentation hook.
16
17use std::cell::RefCell;
18use std::collections::{HashMap, VecDeque};
19use std::path::Path;
20use std::sync::Arc;
21
22use crate::application::Application;
23use crate::text::flexible::FlexibleTextEngine;
24use crate::text::flexible_renderer::FlexibleTextRenderer;
25use crate::text::{WispFontStyle, WispText, WispTextAlign, WispTextLayout};
26use crate::texture::render_texture::RenderTexture;
27use glam::Vec2;
28
29/// Maximum number of cached text textures before FIFO eviction.
30///
31/// 64 × 512 × 256 × 4 bytes ≈ 32 MB upper bound — small even on
32/// integrated GPUs and well within the recorder's working set.
33pub const MAX_ENTRIES: usize = 64;
34
35/// Cache key for a rendered text texture.
36///
37/// Hashes the content + style + wrap width + output dimensions; `f32`
38/// fields go through `to_bits()` so equality is exact-bit (avoids the
39/// NaN-aware quirks of `std::cmp::Eq` on floats). Same canonical NaN
40/// bits hash identically — fine because callers re-pass the same
41/// style value across frames.
42#[derive(Hash, Eq, PartialEq, Clone, Debug)]
43pub struct TextTextureKey {
44    content: String,
45    family: Option<String>,
46    size_bits: u32,
47    color_bits: [u32; 4],
48    line_height_bits: u32,
49    letter_spacing_bits: u32,
50    weight: u16,
51    italic: bool,
52    align: u8,
53    wrap_width_bits: Option<u32>,
54    width_px: u32,
55    height_px: u32,
56}
57
58impl TextTextureKey {
59    /// Build a key from a [`WispText`] and the requested texture
60    /// dimensions.
61    #[must_use]
62    pub fn new(text: &WispText, width_px: u32, height_px: u32) -> Self {
63        let s = text.style;
64        Self {
65            content: text.content.clone(),
66            family: text.font_family.clone(),
67            size_bits: s.size_ndc.to_bits(),
68            color_bits: [
69                s.color.r.to_bits(),
70                s.color.g.to_bits(),
71                s.color.b.to_bits(),
72                s.color.a.to_bits(),
73            ],
74            line_height_bits: s.line_height.to_bits(),
75            letter_spacing_bits: s.letter_spacing_ndc.to_bits(),
76            weight: s.weight.value(),
77            italic: matches!(s.style, WispFontStyle::Italic),
78            align: match s.align {
79                WispTextAlign::Left => 0,
80                WispTextAlign::Center => 1,
81                WispTextAlign::Right => 2,
82            },
83            wrap_width_bits: text.max_width_ndc.map(f32::to_bits),
84            width_px,
85            height_px,
86        }
87    }
88}
89
90/// FIFO-bounded cache of rendered text textures.
91pub struct TextTextureCache {
92    map: HashMap<TextTextureKey, Arc<RenderTexture>>,
93    order: VecDeque<TextTextureKey>,
94    hits: u64,
95    misses: u64,
96}
97
98impl std::fmt::Debug for TextTextureCache {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("TextTextureCache")
101            .field("entries", &self.map.len())
102            .field("hits", &self.hits)
103            .field("misses", &self.misses)
104            .finish_non_exhaustive()
105    }
106}
107
108impl Default for TextTextureCache {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl TextTextureCache {
115    /// Build an empty cache.
116    #[must_use]
117    pub fn new() -> Self {
118        Self {
119            map: HashMap::new(),
120            order: VecDeque::new(),
121            hits: 0,
122            misses: 0,
123        }
124    }
125
126    /// Get the cached texture for `key`, generating it via `generate`
127    /// (which is only called on a miss).
128    pub fn get_or_insert<F>(&mut self, key: TextTextureKey, generate: F) -> Arc<RenderTexture>
129    where
130        F: FnOnce() -> RenderTexture,
131    {
132        if let Some(existing) = self.map.get(&key) {
133            self.hits += 1;
134            return Arc::clone(existing);
135        }
136        self.misses += 1;
137
138        let rt = Arc::new(generate());
139        if self.map.len() >= MAX_ENTRIES
140            && let Some(oldest) = self.order.pop_front()
141        {
142            self.map.remove(&oldest);
143        }
144        self.map.insert(key.clone(), Arc::clone(&rt));
145        self.order.push_back(key);
146        rt
147    }
148
149    /// `(hits, misses)` since construction.
150    #[must_use]
151    pub fn stats(&self) -> (u64, u64) {
152        (self.hits, self.misses)
153    }
154
155    /// Number of currently-resident entries.
156    #[must_use]
157    pub fn len(&self) -> usize {
158        self.map.len()
159    }
160
161    /// True when no entries are resident.
162    #[must_use]
163    pub fn is_empty(&self) -> bool {
164        self.map.is_empty()
165    }
166
167    /// Drop all cached entries.
168    pub fn clear(&mut self) {
169        self.map.clear();
170        self.order.clear();
171        // Stats are sticky on purpose — they reflect lifetime activity.
172    }
173}
174
175/// High-level pipeline: text → `RenderTexture`, with caching.
176///
177/// Owns its own [`FlexibleTextEngine`] + [`FlexibleTextRenderer`] +
178/// [`TextTextureCache`]. The engine + renderer share an
179/// `Arc<Mutex<FontSystem>>` (M-TEXT.3 contract).
180///
181/// The pipeline is **opt-in** — callers construct it explicitly. It is
182/// not held by [`crate::render::Renderer`], so apps that never render
183/// flexible text don't pay glyphon + cache costs.
184pub struct TextTexturePipeline {
185    engine: FlexibleTextEngine,
186    renderer: RefCell<FlexibleTextRenderer>,
187    cache: RefCell<TextTextureCache>,
188    format: wgpu::TextureFormat,
189}
190
191impl std::fmt::Debug for TextTexturePipeline {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("TextTexturePipeline")
194            .field("format", &self.format)
195            .field("cache", &self.cache.borrow())
196            .finish_non_exhaustive()
197    }
198}
199
200impl TextTexturePipeline {
201    /// Build a pipeline with a system-fonts [`FlexibleTextEngine`].
202    #[must_use]
203    pub fn new(app: &Application, format: wgpu::TextureFormat) -> Self {
204        let engine = FlexibleTextEngine::new();
205        let renderer = FlexibleTextRenderer::new(app, format, engine.font_system_handle());
206        Self {
207            engine,
208            renderer: RefCell::new(renderer),
209            cache: RefCell::new(TextTextureCache::new()),
210            format,
211        }
212    }
213
214    /// Build a pipeline with an engine seeded from explicit font
215    /// files. See [`FlexibleTextEngine::from_font_paths`].
216    ///
217    /// # Errors
218    ///
219    /// Returns `io::Error` if any font path can't be opened or parsed.
220    pub fn from_font_paths<P: AsRef<Path>>(
221        app: &Application,
222        format: wgpu::TextureFormat,
223        paths: impl IntoIterator<Item = P>,
224    ) -> std::io::Result<Self> {
225        let engine = FlexibleTextEngine::from_font_paths(paths)?;
226        let renderer = FlexibleTextRenderer::new(app, format, engine.font_system_handle());
227        Ok(Self {
228            engine,
229            renderer: RefCell::new(renderer),
230            cache: RefCell::new(TextTextureCache::new()),
231            format,
232        })
233    }
234
235    /// Build a pipeline with an engine seeded from raw font bytes.
236    /// See [`FlexibleTextEngine::from_font_bytes`] — designed for
237    /// crates that ship their fonts via `include_bytes!` and want the
238    /// same font set in native + wasm builds.
239    #[must_use]
240    pub fn from_font_bytes(
241        app: &Application,
242        format: wgpu::TextureFormat,
243        bytes: impl IntoIterator<Item = Vec<u8>>,
244    ) -> Self {
245        let engine = FlexibleTextEngine::from_font_bytes(bytes);
246        let renderer = FlexibleTextRenderer::new(app, format, engine.font_system_handle());
247        Self {
248            engine,
249            renderer: RefCell::new(renderer),
250            cache: RefCell::new(TextTextureCache::new()),
251            format,
252        }
253    }
254
255    /// Color format the pipeline writes into (matches what was passed
256    /// to [`Self::new`] / [`Self::from_font_paths`]).
257    #[must_use]
258    pub fn format(&self) -> wgpu::TextureFormat {
259        self.format
260    }
261
262    /// `(hits, misses)` since construction. Useful in tests that
263    /// verify the cache short-circuits on repeated lookups.
264    #[must_use]
265    pub fn stats(&self) -> (u64, u64) {
266        self.cache.borrow().stats()
267    }
268
269    /// Number of currently-resident cache entries.
270    #[must_use]
271    pub fn cache_len(&self) -> usize {
272        self.cache.borrow().len()
273    }
274
275    /// Drop every cached entry.
276    pub fn clear_cache(&self) {
277        self.cache.borrow_mut().clear();
278    }
279
280    /// Borrow the engine for direct layout queries (metrics, etc.).
281    #[must_use]
282    pub fn engine(&self) -> &FlexibleTextEngine {
283        &self.engine
284    }
285
286    /// Render `text` into a `width_px × height_px` `RenderTexture`,
287    /// or return the cached version if `text` + dimensions match an
288    /// earlier call.
289    ///
290    /// The text is positioned with its top-left at the texture's top-left
291    /// (NDC `(-1, +1)`) and rendered with [`crate::color::Color`]
292    /// `WHITE` as the default per-glyph color (the actual glyph color
293    /// comes from [`crate::text::WispTextStyle::color`]).
294    pub fn render(
295        &self,
296        app: &Application,
297        text: &WispText,
298        width_px: u32,
299        height_px: u32,
300    ) -> Arc<RenderTexture> {
301        let key = TextTextureKey::new(text, width_px, height_px);
302        self.cache.borrow_mut().get_or_insert(key, || {
303            let rt = RenderTexture::with_format(app, width_px, height_px, self.format);
304            let layout = self.engine.layout_concrete(text);
305            // Skip the GPU pass when the layout is empty — glyphon
306            // accepts an empty TextArea but spending the encoder /
307            // submit cost is wasteful for blank text.
308            if layout.metrics().line_count == 0 {
309                return rt;
310            }
311            let mut r = self.renderer.borrow_mut();
312            r.set_resolution(width_px, height_px);
313            r.draw(
314                rt.view(),
315                &[(&layout, Vec2::new(-1.0, 1.0), text.style.color)],
316                /* clear = */ true,
317            );
318            rt
319        })
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::application::{AppConfig, Application};
327    use crate::color::Color;
328
329    fn boot() -> Application {
330        pollster::block_on(Application::new(AppConfig::default())).expect("init")
331    }
332
333    fn pipeline(app: &Application) -> TextTexturePipeline {
334        TextTexturePipeline::new(app, wgpu::TextureFormat::Rgba8Unorm)
335    }
336
337    #[test]
338    fn first_render_records_a_miss_and_a_cache_entry() {
339        let app = boot();
340        let p = pipeline(&app);
341        let _ = p.render(&app, &WispText::new("hello"), 64, 32);
342        assert_eq!(p.stats(), (0, 1));
343        assert_eq!(p.cache_len(), 1);
344    }
345
346    #[test]
347    fn second_render_with_same_inputs_is_a_cache_hit() {
348        let app = boot();
349        let p = pipeline(&app);
350        let a = p.render(&app, &WispText::new("hello"), 64, 32);
351        let b = p.render(&app, &WispText::new("hello"), 64, 32);
352        assert_eq!(p.stats(), (1, 1));
353        // Same Arc → same underlying GPU resource.
354        assert!(Arc::ptr_eq(&a, &b));
355    }
356
357    #[test]
358    fn changing_content_invalidates_cache() {
359        let app = boot();
360        let p = pipeline(&app);
361        let a = p.render(&app, &WispText::new("hello"), 64, 32);
362        let b = p.render(&app, &WispText::new("world"), 64, 32);
363        assert_eq!(p.stats(), (0, 2));
364        assert!(!Arc::ptr_eq(&a, &b));
365        assert_eq!(p.cache_len(), 2);
366    }
367
368    #[test]
369    fn changing_style_invalidates_cache() {
370        let app = boot();
371        let p = pipeline(&app);
372        let plain = WispText::new("hello");
373        let bold = WispText::new("hello")
374            .with_style(plain.style.with_weight(crate::text::WispFontWeight::Bold));
375        let a = p.render(&app, &plain, 64, 32);
376        let b = p.render(&app, &bold, 64, 32);
377        assert_eq!(p.stats(), (0, 2));
378        assert!(!Arc::ptr_eq(&a, &b));
379    }
380
381    #[test]
382    fn changing_color_invalidates_cache() {
383        let app = boot();
384        let p = pipeline(&app);
385        let red = WispText::new("hello").with_style(
386            crate::text::WispTextStyle::default().with_color(Color::rgba(1.0, 0.0, 0.0, 1.0)),
387        );
388        let green = WispText::new("hello").with_style(
389            crate::text::WispTextStyle::default().with_color(Color::rgba(0.0, 1.0, 0.0, 1.0)),
390        );
391        let a = p.render(&app, &red, 64, 32);
392        let b = p.render(&app, &green, 64, 32);
393        assert!(!Arc::ptr_eq(&a, &b));
394    }
395
396    #[test]
397    fn changing_wrap_width_invalidates_cache() {
398        let app = boot();
399        let p = pipeline(&app);
400        let unwrapped = WispText::new("a long line of text");
401        let wrapped = WispText::new("a long line of text").with_wrap(0.5);
402        let a = p.render(&app, &unwrapped, 64, 32);
403        let b = p.render(&app, &wrapped, 64, 32);
404        assert!(!Arc::ptr_eq(&a, &b));
405    }
406
407    #[test]
408    fn changing_dimensions_invalidates_cache() {
409        let app = boot();
410        let p = pipeline(&app);
411        let a = p.render(&app, &WispText::new("hello"), 64, 32);
412        let b = p.render(&app, &WispText::new("hello"), 128, 32);
413        assert!(!Arc::ptr_eq(&a, &b));
414        assert_eq!(p.cache_len(), 2);
415    }
416
417    #[test]
418    fn changing_font_family_invalidates_cache() {
419        let app = boot();
420        let p = pipeline(&app);
421        let a = p.render(&app, &WispText::new("hello"), 64, 32);
422        let b = p.render(
423            &app,
424            &WispText::new("hello").with_font_family("Inter"),
425            64,
426            32,
427        );
428        assert!(!Arc::ptr_eq(&a, &b));
429    }
430
431    #[test]
432    fn cache_evicts_at_capacity() {
433        let app = boot();
434        let p = pipeline(&app);
435        // Fill the cache + 1 to force eviction.
436        for i in 0..=MAX_ENTRIES {
437            // Distinct content per iteration → distinct keys.
438            let _ = p.render(&app, &WispText::new(format!("entry-{i}")), 32, 32);
439        }
440        // After (MAX_ENTRIES + 1) misses with FIFO eviction, len caps at MAX_ENTRIES.
441        assert_eq!(p.cache_len(), MAX_ENTRIES);
442        let (hits, misses) = p.stats();
443        assert_eq!(hits, 0);
444        let expected_misses = u64::try_from(MAX_ENTRIES + 1).expect("fits");
445        assert_eq!(misses, expected_misses);
446    }
447
448    #[test]
449    fn clear_cache_drops_entries_and_refills_on_next_render() {
450        let app = boot();
451        let p = pipeline(&app);
452        let _ = p.render(&app, &WispText::new("hello"), 64, 32);
453        assert_eq!(p.cache_len(), 1);
454        p.clear_cache();
455        assert_eq!(p.cache_len(), 0);
456        let _ = p.render(&app, &WispText::new("hello"), 64, 32);
457        // After clear, the previously-warm entry is a miss again.
458        assert_eq!(p.stats(), (0, 2));
459    }
460
461    #[test]
462    fn rendered_texture_has_non_zero_glyph_pixels() {
463        let app = boot();
464        let p = pipeline(&app);
465        let rt = p.render(&app, &WispText::new("hi"), 128, 64);
466        let bytes = rt.read_pixels(&app);
467        let non_zero = bytes.chunks_exact(4).filter(|p| p[3] > 0).count();
468        assert!(
469            non_zero > 0,
470            "expected the text pipeline to paint glyph pixels"
471        );
472    }
473}