diff --git a/src/app.rs b/src/app.rs index da00223..ec168ef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,5 @@ use crate::svg::{Renderable as _, SvgContent}; -use crate::text_cache::{TextCache, RENDER_SCALE}; +use crate::text_cache::TextCache; /// We derive Deserialize/Serialize so we can persist app state on shutdown. #[derive(serde::Deserialize, serde::Serialize)] @@ -497,9 +497,13 @@ impl eframe::App for TemplateApp { continue; } - let cached = - text_cache.get_or_create(ctx, &line.content, text_elem.font_size); - let scale_factor = zoom / RENDER_SCALE; + let cached = text_cache.get_or_create( + ctx, + &line.content, + text_elem.font_size, + zoom, + ); + let scale_factor = zoom / cached.render_scale; let display_width = cached.width as f32 * scale_factor; let display_height = cached.height as f32 * scale_factor; diff --git a/src/svg.rs b/src/svg.rs index d31eb16..862671d 100644 --- a/src/svg.rs +++ b/src/svg.rs @@ -1,7 +1,7 @@ //! SVG parsing module for extracting special elements from SVG files. -use quick_xml::events::Event; use quick_xml::Reader; +use quick_xml::events::Event; use std::fs; /// Trait for elements that can be rendered with a bounding box. diff --git a/src/text_cache.rs b/src/text_cache.rs index b049070..9d1faff 100644 --- a/src/text_cache.rs +++ b/src/text_cache.rs @@ -1,19 +1,26 @@ //! Text rendering cache that pre-renders text to textures for smooth scaling. //! -//! Text is rendered at 4x the nominal font size to allow crisp display when -//! zooming in, while scaling down smoothly when zooming out. +//! 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, TextureHandle, TextureOptions}; +use egui::{Color32, ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode}; +use log::trace; use std::collections::HashMap; -/// Scale factor for pre-rendering text (4x nominal size for crisp scaling). -pub const RENDER_SCALE: f32 = 4.0; +/// 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_FILTER: TextureOptions = TextureOptions::LINEAR; +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 = 4096; @@ -29,14 +36,16 @@ pub struct CachedText { 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`). - cache: HashMap<(String, u32), CachedText>, + /// Cached text textures, keyed by (content, `size_key`, render scale). + cache: HashMap<(String, u32, u32), CachedText>, } impl TextCache { @@ -48,9 +57,10 @@ impl TextCache { 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 scale: {}x)", + "Text cache initialized (font: {} bytes, render scales: {:?}, mipmaps: {:?})", font_data.len(), - RENDER_SCALE + RENDER_SCALES, + TEXTURE_OPTIONS.mipmap_mode ); Self { @@ -66,25 +76,56 @@ impl TextCache { /// Get or create a cached texture for the given text. /// - /// The texture is rendered at `RENDER_SCALE` times the nominal font size. + /// 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 key = (text.to_owned(), size_key); + 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); + 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, @@ -92,37 +133,107 @@ impl TextCache { text: &str, nominal_font_size: f32, size_key: u32, + render_scale: f32, ) -> CachedText { - let render_size = nominal_font_size * RENDER_SCALE; - let scale = PxScale::from(render_size); - let scaled_font = self.font.as_scaled(scale); + let start_time = std::time::Instant::now(); + trace!( + "Rendering text '{}' at size {}px (scale {})...", + &text[..text.len().min(20)], + nominal_font_size, + render_scale + ); - // Calculate text dimensions - let height = scaled_font.height(); - let ascent = scaled_font.ascent(); - let width = Self::measure_text_width(text, &scaled_font); + let mut warned_fallback = false; + let mut first_oversize_dims: Option<(u32, u32)> = None; - // 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); + 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; } - // Calculate image dimensions with padding - let padding = 2.0; - let img_width = ((width + padding * 2.0).ceil() as u32).min(MAX_TEXTURE_DIM); - let img_height = ((height + padding * 2.0).ceil() as u32).min(MAX_TEXTURE_DIM); + 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 img_width == 0 || img_height == 0 { - return Self::create_empty_texture(ctx, size_key); - } - - 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 '{}', skipping", - &text[..text.len().min(20)] - ); - return Self::create_empty_texture(ctx, size_key); + 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 @@ -144,15 +255,30 @@ impl TextCache { }; let texture = ctx.load_texture( - format!("text_{size_key}_{}", text.len()), + format!( + "text_{size_key}_{}_{}", + text.len(), + Self::render_scale_key(render_scale) + ), image, - TEXTURE_FILTER, + 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, } } @@ -235,19 +361,27 @@ impl TextCache { } /// Create a minimal 1x1 transparent texture for empty/invalid text. - fn create_empty_texture(ctx: &egui::Context, size_key: u32) -> CachedText { + 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}"), image, TEXTURE_FILTER); + 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, } } }