line-and-surface/src/text_cache.rs

395 lines
13 KiB
Rust

//! 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<Color32> {
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,
}
}
}