tessera_ui_basic_components/pipelines/text/
pipeline.rs

1//! Text Rendering Pipeline for UI Components
2//!
3//! This module implements the GPU pipeline and related utilities for efficient text rendering in Tessera UI components.
4//! It leverages the Glyphon engine for font management, shaping, and rasterization, providing high-quality and performant text output.
5//! Typical use cases include rendering static labels, paragraphs, and editable text fields within the UI.
6//!
7//! The pipeline is designed to be reusable and efficient, sharing a static font system across the application to minimize resource usage.
8//! It exposes APIs for preparing, measuring, and rendering text, supporting advanced features such as font fallback, shaping, and multi-line layout.
9//!
10//! This module is intended for integration into custom UI components and rendering flows that require flexible and robust text display.
11
12use std::{num::NonZero, sync::OnceLock};
13
14use glyphon::fontdb;
15use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
16use tessera_ui::{
17    Color, PxPosition,
18    renderer::drawer::pipeline::{DrawContext, DrawablePipeline},
19    wgpu,
20};
21
22use super::command::{TextCommand, TextConstraint};
23
24/// It costs a lot to create a glyphon font system, so we use a static one
25/// to share it every where and avoid creating it multiple times.
26static FONT_SYSTEM: OnceLock<RwLock<glyphon::FontSystem>> = OnceLock::new();
27
28/// Create TextData is a heavy operation, so we provide a lru cache to store recently used TextData.
29static TEXT_DATA_CACHE: OnceLock<RwLock<lru::LruCache<LruKey, TextData>>> = OnceLock::new();
30
31#[derive(PartialEq)]
32struct LruKey {
33    text: String,
34    color: Color,
35    size: f32,
36    line_height: f32,
37    constraint: TextConstraint,
38}
39
40impl Eq for LruKey {}
41
42impl std::hash::Hash for LruKey {
43    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
44        self.text.hash(state);
45        self.color.r.to_bits().hash(state);
46        self.color.g.to_bits().hash(state);
47        self.color.b.to_bits().hash(state);
48        self.color.a.to_bits().hash(state);
49        self.size.to_bits().hash(state);
50        self.line_height.to_bits().hash(state);
51        self.constraint.hash(state);
52    }
53}
54
55fn write_lru_cache() -> RwLockWriteGuard<'static, lru::LruCache<LruKey, TextData>> {
56    TEXT_DATA_CACHE
57        .get_or_init(|| {
58            RwLock::new(lru::LruCache::new(
59                NonZero::new(100).expect("text cache size must be non-zero"),
60            ))
61        })
62        .write()
63}
64
65#[cfg(target_os = "android")]
66fn init_font_system() -> RwLock<glyphon::FontSystem> {
67    let mut font_system = glyphon::FontSystem::new();
68
69    font_system.db_mut().load_fonts_dir("/system/fonts");
70    font_system.db_mut().set_sans_serif_family("Roboto");
71    font_system.db_mut().set_serif_family("Noto Serif");
72    font_system.db_mut().set_monospace_family("Droid Sans Mono");
73    font_system.db_mut().set_cursive_family("Dancing Script");
74    font_system.db_mut().set_fantasy_family("Dancing Script");
75
76    RwLock::new(font_system)
77}
78
79#[cfg(not(target_os = "android"))]
80fn init_font_system() -> RwLock<glyphon::FontSystem> {
81    RwLock::new(glyphon::FontSystem::new())
82}
83
84/// It costs a lot to create a glyphon font system, so we use a static one
85/// to share it every where and avoid creating it multiple times.
86/// This function returns a read lock of the font system.
87pub fn read_font_system() -> RwLockReadGuard<'static, glyphon::FontSystem> {
88    FONT_SYSTEM.get_or_init(init_font_system).read()
89}
90
91/// It costs a lot to create a glyphon font system, so we use a static one
92/// to share it every where and avoid creating it multiple times.
93/// This function returns a write lock of the font system.
94pub fn write_font_system() -> RwLockWriteGuard<'static, glyphon::FontSystem> {
95    FONT_SYSTEM.get_or_init(init_font_system).write()
96}
97
98/// A text renderer
99/// Pipeline for rendering text using the Glyphon engine.
100///
101/// This struct manages font atlas, cache, viewport, and swash cache for efficient text rendering.
102pub struct GlyphonTextRender {
103    /// Glyphon font atlas, a heavy-weight, shared resource.
104    atlas: glyphon::TextAtlas,
105    /// Glyphon cache, a heavy-weight, shared resource.
106    #[allow(unused)]
107    cache: glyphon::Cache,
108    /// Glyphon viewport, holds screen-size related buffers.
109    viewport: glyphon::Viewport,
110    /// Glyphon swash cache, a CPU-side cache for glyph rasterization.
111    swash_cache: glyphon::SwashCache,
112    /// Multisample state for anti-aliasing.
113    msaa: wgpu::MultisampleState,
114    /// Glyphon text renderer, responsible for rendering text.
115    renderer: glyphon::TextRenderer,
116}
117
118impl GlyphonTextRender {
119    /// Creates a new text renderer pipeline.
120    ///
121    /// # Parameters
122    ///
123    /// - `gpu`: The wgpu device.
124    /// - `queue`: The wgpu queue.
125    /// - `config`: Surface configuration.
126    /// - `sample_count`: Multisample count for anti-aliasing.
127    pub fn new(
128        gpu: &wgpu::Device,
129        queue: &wgpu::Queue,
130        config: &wgpu::SurfaceConfiguration,
131        sample_count: u32,
132    ) -> Self {
133        let cache = glyphon::Cache::new(gpu);
134        let mut atlas = glyphon::TextAtlas::new(gpu, queue, &cache, config.format);
135        let viewport = glyphon::Viewport::new(gpu, &cache);
136        let swash_cache = glyphon::SwashCache::new();
137        let msaa = wgpu::MultisampleState {
138            count: sample_count,
139            mask: !0,
140            alpha_to_coverage_enabled: false,
141        };
142        let renderer = glyphon::TextRenderer::new(&mut atlas, gpu, msaa, None);
143
144        Self {
145            atlas,
146            cache,
147            viewport,
148            swash_cache,
149            msaa,
150            renderer,
151        }
152    }
153}
154
155impl DrawablePipeline<TextCommand> for GlyphonTextRender {
156    fn draw(&mut self, context: &mut DrawContext<TextCommand>) {
157        if context.commands.is_empty() {
158            return;
159        }
160
161        self.viewport.update(
162            context.queue,
163            glyphon::Resolution {
164                width: context.config.width,
165                height: context.config.height,
166            },
167        );
168
169        let text_areas = context
170            .commands
171            .iter()
172            .map(|(command, _size, start_pos)| command.data.text_area(*start_pos));
173
174        self.renderer
175            .prepare(
176                context.device,
177                context.queue,
178                &mut write_font_system(),
179                &mut self.atlas,
180                &self.viewport,
181                text_areas,
182                &mut self.swash_cache,
183            )
184            .expect("glyphon prepare failed");
185
186        self.renderer
187            .render(&self.atlas, &self.viewport, context.render_pass)
188            .expect("glyphon render failed");
189
190        // Re-create the renderer to release borrow on atlas
191        let new_renderer =
192            glyphon::TextRenderer::new(&mut self.atlas, context.device, self.msaa, None);
193        let _ = std::mem::replace(&mut self.renderer, new_renderer);
194    }
195}
196
197/// Text data for rendering, including buffer and size.
198#[derive(Debug, Clone, PartialEq)]
199pub struct TextData {
200    /// glyphon text buffer
201    text_buffer: glyphon::Buffer,
202    /// text area size
203    pub size: [u32; 2],
204}
205
206impl TextData {
207    /// Prepares text data for rendering.
208    ///
209    /// # Parameters
210    /// - `text`: The text string.
211    /// - `color`: The text color.
212    /// - `size`: Font size.
213    /// - `line_height`: Line height.
214    /// - `constraint`: Text constraint for layout.
215    pub fn new(
216        text: String,
217        color: Color,
218        size: f32,
219        line_height: f32,
220        constraint: TextConstraint,
221    ) -> Self {
222        // Check cache first
223        let key = LruKey {
224            text: text.clone(),
225            color,
226            size,
227            line_height,
228            constraint: constraint.clone(),
229        };
230        if let Some(cache) = write_lru_cache().get(&key) {
231            return cache.clone();
232        }
233
234        // Create text buffer
235        let mut text_buffer = glyphon::Buffer::new(
236            &mut write_font_system(),
237            glyphon::Metrics::new(size, line_height),
238        );
239        let color = glyphon::Color::rgba(
240            (color.r * 255.0) as u8,
241            (color.g * 255.0) as u8,
242            (color.b * 255.0) as u8,
243            (color.a * 255.0) as u8,
244        );
245        text_buffer.set_wrap(&mut write_font_system(), glyphon::Wrap::Glyph);
246        text_buffer.set_size(
247            &mut write_font_system(),
248            constraint.max_width,
249            constraint.max_height,
250        );
251        text_buffer.set_text(
252            &mut write_font_system(),
253            &text,
254            &glyphon::Attrs::new()
255                .family(fontdb::Family::SansSerif)
256                .color(color),
257            glyphon::Shaping::Advanced,
258            None,
259        );
260        text_buffer.shape_until_scroll(&mut write_font_system(), false);
261        // Calculate text bounds
262        // Get the layout runs
263        let mut run_width: f32 = 0.0;
264        // Calculate total height including descender for the last line
265        let metrics = text_buffer.metrics();
266        let num_lines = text_buffer.layout_runs().count() as f32;
267        let descent_amount = (metrics.line_height - metrics.font_size).max(0.0);
268        let total_height = num_lines * metrics.line_height + descent_amount;
269        for run in text_buffer.layout_runs() {
270            // Take the max. width of all lines.
271            run_width = run_width.max(run.line_w);
272        }
273        // build text data
274        let result = Self {
275            text_buffer,
276            size: [run_width as u32, total_height.ceil() as u32],
277        };
278        // Insert into cache
279        write_lru_cache().put(key, result.clone());
280        // Return result
281        result
282    }
283
284    /// Builds [`TextData`] directly from a pre-shaped glyphon buffer.
285    pub fn from_buffer(text_buffer: glyphon::Buffer) -> Self {
286        // Calculate total height including descender for the last line
287        let metrics = text_buffer.metrics();
288        let num_lines = text_buffer.layout_runs().count() as f32;
289        let descent_amount = (metrics.line_height - metrics.font_size).max(0.0);
290        let total_height = num_lines * metrics.line_height + descent_amount;
291        // Calculate text bounds
292        let mut run_width: f32 = 0.0;
293        for run in text_buffer.layout_runs() {
294            // Take the max. width of all lines.
295            run_width = run_width.max(run.line_w);
296        }
297        // build text data
298        Self {
299            text_buffer,
300            size: [run_width as u32, total_height.ceil() as u32],
301        }
302    }
303
304    /// Get the glyphon text area from the text data
305    fn text_area(&'_ self, start_pos: PxPosition) -> glyphon::TextArea<'_> {
306        let bounds = glyphon::TextBounds {
307            left: start_pos.x.raw(),
308            top: start_pos.y.raw(),
309            right: start_pos.x.raw() + self.size[0] as i32,
310            bottom: start_pos.y.raw() + self.size[1] as i32,
311        };
312        glyphon::TextArea {
313            buffer: &self.text_buffer,
314            left: start_pos.x.to_f32(),
315            top: start_pos.y.to_f32(),
316            scale: 1.0,
317            bounds,
318            default_color: glyphon::Color::rgb(0, 0, 0), // Black by default
319            custom_glyphs: &[],
320        }
321    }
322}