//! Text rendering cache that pre-renders text to textures for smooth scaling. //! //! Text is rendered at multiple resolutions (mip levels) so zooming in can use //! high-resolution glyphs while zooming out benefits from GPU mipmapping for //! smooth minification. use ab_glyph::{Font as _, FontRef, PxScale, ScaleFont as _}; use egui::{Color32, ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode}; use log::trace; use std::collections::HashMap; /// Available render scales (mip levels) for pre-rendering text. const RENDER_SCALES: [f32; 6] = [1.0, 4.0, 8.0, 16.0, 32.0, 64.0]; /// Texture filtering mode for text rendering. /// - `LINEAR`: Smooth scaling, slight blur when scaled (good for most cases) /// - `NEAREST`: Sharp/pixelated, no interpolation (good for pixel art style) const TEXTURE_OPTIONS: TextureOptions = TextureOptions { magnification: TextureFilter::Linear, minification: TextureFilter::Linear, wrap_mode: TextureWrapMode::ClampToEdge, mipmap_mode: Some(TextureFilter::Linear), }; /// Maximum texture dimension to prevent memory issues. const MAX_TEXTURE_DIM: u32 = 8192; /// Maximum pixel count (16M pixels = 64MB for RGBA). const MAX_PIXEL_COUNT: usize = 16 * 1024 * 1024; /// A cached rendered text texture. pub struct CachedText { /// The texture handle for the rendered text. pub texture: TextureHandle, /// Width of the texture in pixels (at render scale). pub width: u32, /// Height of the texture in pixels (at render scale). pub height: u32, /// Render scale that was used to generate the texture. pub render_scale: f32, } /// Cache for rendered text textures. pub struct TextCache { /// The font used for rendering. font: FontRef<'static>, /// Cached text textures, keyed by (content, `size_key`, render scale). cache: HashMap<(String, u32, u32), CachedText>, } impl TextCache { /// Create a new text cache with the embedded Noto Sans font. /// /// Text is rendered in white - apply color as a tint when drawing. pub fn new() -> Self { log::debug!("Initializing text cache..."); let font_data: &'static [u8] = include_bytes!("../assets/NotoSans-Regular.ttf"); let font = FontRef::try_from_slice(font_data).expect("embedded font should be valid"); log::info!( "Text cache initialized (font: {} bytes, render scales: {:?}, mipmaps: {:?})", font_data.len(), RENDER_SCALES, TEXTURE_OPTIONS.mipmap_mode ); Self { font, cache: HashMap::new(), } } /// Returns the number of cached text textures. pub fn cache_size(&self) -> usize { self.cache.len() } /// Returns approximate GPU memory used by cached text textures (bytes). pub fn cache_memory_bytes(&self) -> usize { self.cache .values() .map(|cached| cached.width as usize * cached.height as usize * 4) .sum() } /// Get or create a cached texture for the given text. /// /// The texture is rendered at the smallest available render scale that can /// cover the requested zoom. Higher zoom levels therefore pick a higher /// pre-render scale while lower zooms reuse the mip levels generated by the /// GPU. pub fn get_or_create( &mut self, ctx: &egui::Context, text: &str, nominal_font_size: f32, zoom: f32, ) -> &CachedText { // Round font size to reduce cache entries (0.5px granularity) let size_key = (nominal_font_size * 2.0).round() as u32; let render_scale = Self::pick_render_scale(nominal_font_size, zoom, ctx.pixels_per_point()); let render_scale_key = Self::render_scale_key(render_scale); let key = (text.to_owned(), size_key, render_scale_key); if !self.cache.contains_key(&key) { let cached = self.render_text(ctx, text, nominal_font_size, size_key, render_scale); self.cache.insert(key.clone(), cached); } self.cache.get(&key).expect("just inserted") } fn pick_render_scale(nominal_font_size: f32, zoom: f32, pixels_per_point: f32) -> f32 { if nominal_font_size <= 0.0 { return RENDER_SCALES[0]; } let effective_zoom = (zoom * pixels_per_point).max(1.0); let max_scale_allowed = (MAX_TEXTURE_DIM as f32 / nominal_font_size).max(1.0); let max_level = RENDER_SCALES .iter() .copied() .filter(|&scale| scale <= max_scale_allowed) .last() .unwrap_or(RENDER_SCALES[0]); RENDER_SCALES .iter() .copied() .find(|&scale| scale >= effective_zoom && scale <= max_level) .unwrap_or(max_level) } fn render_scale_key(scale: f32) -> u32 { scale.to_bits() } /// Render text to a texture at high resolution. fn render_text( &self, ctx: &egui::Context, text: &str, nominal_font_size: f32, size_key: u32, render_scale: f32, ) -> CachedText { let start_time = std::time::Instant::now(); trace!( "Rendering text '{}' at size {}px (scale {})...", &text[..text.len().min(20)], nominal_font_size, render_scale ); let mut warned_fallback = false; let mut first_oversize_dims: Option<(u32, u32)> = None; let mut selected = None; for candidate_scale in std::iter::once(render_scale).chain( RENDER_SCALES .iter() .rev() .copied() .filter(|&s| s < render_scale), ) { let render_size = nominal_font_size * candidate_scale; let scale = PxScale::from(render_size); let scaled_font = self.font.as_scaled(scale); // Calculate text dimensions let height = scaled_font.height(); let ascent = scaled_font.ascent(); let width = Self::measure_text_width(text, &scaled_font); // Early return for empty/invalid text if width <= 0.0 || height <= 0.0 || text.trim().is_empty() { return Self::create_empty_texture(ctx, size_key, candidate_scale); } let padding = 2.0; let img_width = (width + padding * 2.0).ceil() as u32; let img_height = (height + padding * 2.0).ceil() as u32; if img_width == 0 || img_height == 0 { return Self::create_empty_texture(ctx, size_key, candidate_scale); } if img_width > MAX_TEXTURE_DIM || img_height > MAX_TEXTURE_DIM { if first_oversize_dims.is_none() { first_oversize_dims = Some((img_width, img_height)); } warned_fallback = warned_fallback || candidate_scale == render_scale; continue; } let pixel_count = img_width as usize * img_height as usize; if pixel_count > MAX_PIXEL_COUNT { log::warn!( "Text texture too large: {img_width}x{img_height} for '{}' at scale {} (pixel limit)", &text[..text.len().min(20)], candidate_scale ); continue; } selected = Some(( candidate_scale, scaled_font, scale, ascent, padding, img_width, img_height, )); break; } let Some((chosen_scale, scaled_font, scale, ascent, padding, img_width, img_height)) = selected else { if let Some((w, h)) = first_oversize_dims { log::error!( "Text texture exceeds MAX_TEXTURE_DIM ({MAX_TEXTURE_DIM}) at all scales; requested '{}' => {w}x{h}", &text[..text.len().min(20)] ); } else { log::error!( "Unable to render text '{}' within texture limits; no suitable scale found", &text[..text.len().min(20)] ); } return Self::create_empty_texture(ctx, size_key, render_scale); }; if warned_fallback { if let Some((w, h)) = first_oversize_dims { log::warn!( "Requested text scale {} for '{}' would create texture {}x{} (>{MAX_TEXTURE_DIM}); using scale {} instead", render_scale, &text[..text.len().min(20)], w, h, chosen_scale ); } } // Render glyphs to pixel buffer let pixels = Self::render_glyphs( text, &scaled_font, scale, ascent, padding, img_width, img_height, ); // Create egui texture let image = ColorImage { size: [img_width as usize, img_height as usize], pixels, source_size: egui::Vec2::new(img_width as f32, img_height as f32), }; let texture = ctx.load_texture( format!( "text_{size_key}_{}_{}", text.len(), Self::render_scale_key(render_scale) ), image, TEXTURE_OPTIONS, ); let duration = start_time.elapsed(); trace!( "Rendered text '{}' ({}x{} @{}) in {:.2?}", &text[..text.len().min(20)], img_width, img_height, chosen_scale, duration ); CachedText { texture, width: img_width, height: img_height, render_scale: chosen_scale, } } /// Measure the width of text in pixels. fn measure_text_width( text: &str, scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>, ) -> f32 { let mut width = 0.0f32; let mut last_glyph_id = None; for c in text.chars() { let glyph_id = scaled_font.glyph_id(c); if let Some(last_id) = last_glyph_id { width += scaled_font.kern(last_id, glyph_id); } width += scaled_font.h_advance(glyph_id); last_glyph_id = Some(glyph_id); } width } /// Render glyphs to a pixel buffer. fn render_glyphs( text: &str, scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>, scale: PxScale, ascent: f32, padding: f32, img_width: u32, img_height: u32, ) -> Vec { let mut pixels = vec![Color32::TRANSPARENT; (img_width * img_height) as usize]; let mut cursor_x = padding; let mut last_glyph_id = None; for c in text.chars() { let glyph_id = scaled_font.glyph_id(c); if let Some(last_id) = last_glyph_id { cursor_x += scaled_font.kern(last_id, glyph_id); } if let Some(outlined) = scaled_font.outline_glyph(ab_glyph::Glyph { id: glyph_id, scale, position: ab_glyph::point(cursor_x, ascent + padding), }) { let bounds = outlined.px_bounds(); outlined.draw(|x, y, coverage| { let px = bounds.min.x as i32 + x as i32; let py = bounds.min.y as i32 + y as i32; if px >= 0 && py >= 0 { let px = px as u32; let py = py as u32; if px < img_width && py < img_height { let idx = (py * img_width + px) as usize; let alpha = (coverage * 255.0) as u8; let existing = pixels[idx]; let new_alpha = alpha.saturating_add(existing.a()); // Render in white - color is applied as tint when drawing pixels[idx] = Color32::from_rgba_unmultiplied(255, 255, 255, new_alpha); } } }); } cursor_x += scaled_font.h_advance(glyph_id); last_glyph_id = Some(glyph_id); } pixels } /// Create a minimal 1x1 transparent texture for empty/invalid text. fn create_empty_texture(ctx: &egui::Context, size_key: u32, render_scale: f32) -> CachedText { let image = ColorImage { size: [1, 1], pixels: vec![Color32::TRANSPARENT], source_size: egui::Vec2::new(1.0, 1.0), }; let texture = ctx.load_texture( format!( "text_empty_{size_key}_{}", Self::render_scale_key(render_scale) ), image, TEXTURE_OPTIONS, ); CachedText { texture, width: 1, height: 1, render_scale, } } }