feat: multi-scale text rendering
This commit is contained in:
parent
9c993325f4
commit
3282bcc4ae
3 changed files with 185 additions and 47 deletions
12
src/app.rs
12
src/app.rs
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::svg::{Renderable as _, SvgContent};
|
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.
|
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
||||||
#[derive(serde::Deserialize, serde::Serialize)]
|
#[derive(serde::Deserialize, serde::Serialize)]
|
||||||
|
|
@ -497,9 +497,13 @@ impl eframe::App for TemplateApp {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached =
|
let cached = text_cache.get_or_create(
|
||||||
text_cache.get_or_create(ctx, &line.content, text_elem.font_size);
|
ctx,
|
||||||
let scale_factor = zoom / RENDER_SCALE;
|
&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_width = cached.width as f32 * scale_factor;
|
||||||
let display_height = cached.height as f32 * scale_factor;
|
let display_height = cached.height as f32 * scale_factor;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! SVG parsing module for extracting special elements from SVG files.
|
//! SVG parsing module for extracting special elements from SVG files.
|
||||||
|
|
||||||
use quick_xml::events::Event;
|
|
||||||
use quick_xml::Reader;
|
use quick_xml::Reader;
|
||||||
|
use quick_xml::events::Event;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
/// Trait for elements that can be rendered with a bounding box.
|
/// Trait for elements that can be rendered with a bounding box.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
//! Text rendering cache that pre-renders text to textures for smooth scaling.
|
//! 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
|
//! Text is rendered at multiple resolutions (mip levels) so zooming in can use
|
||||||
//! zooming in, while scaling down smoothly when zooming out.
|
//! high-resolution glyphs while zooming out benefits from GPU mipmapping for
|
||||||
|
//! smooth minification.
|
||||||
|
|
||||||
use ab_glyph::{Font as _, FontRef, PxScale, ScaleFont as _};
|
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;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Scale factor for pre-rendering text (4x nominal size for crisp scaling).
|
/// Available render scales (mip levels) for pre-rendering text.
|
||||||
pub const RENDER_SCALE: f32 = 4.0;
|
const RENDER_SCALES: [f32; 6] = [1.0, 4.0, 8.0, 16.0, 32.0, 64.0];
|
||||||
|
|
||||||
/// Texture filtering mode for text rendering.
|
/// Texture filtering mode for text rendering.
|
||||||
/// - `LINEAR`: Smooth scaling, slight blur when scaled (good for most cases)
|
/// - `LINEAR`: Smooth scaling, slight blur when scaled (good for most cases)
|
||||||
/// - `NEAREST`: Sharp/pixelated, no interpolation (good for pixel art style)
|
/// - `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.
|
/// Maximum texture dimension to prevent memory issues.
|
||||||
const MAX_TEXTURE_DIM: u32 = 4096;
|
const MAX_TEXTURE_DIM: u32 = 4096;
|
||||||
|
|
@ -29,14 +36,16 @@ pub struct CachedText {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
/// Height of the texture in pixels (at render scale).
|
/// Height of the texture in pixels (at render scale).
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
|
/// Render scale that was used to generate the texture.
|
||||||
|
pub render_scale: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cache for rendered text textures.
|
/// Cache for rendered text textures.
|
||||||
pub struct TextCache {
|
pub struct TextCache {
|
||||||
/// The font used for rendering.
|
/// The font used for rendering.
|
||||||
font: FontRef<'static>,
|
font: FontRef<'static>,
|
||||||
/// Cached text textures, keyed by (content, `size_key`).
|
/// Cached text textures, keyed by (content, `size_key`, render scale).
|
||||||
cache: HashMap<(String, u32), CachedText>,
|
cache: HashMap<(String, u32, u32), CachedText>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextCache {
|
impl TextCache {
|
||||||
|
|
@ -48,9 +57,10 @@ impl TextCache {
|
||||||
let font_data: &'static [u8] = include_bytes!("../assets/NotoSans-Regular.ttf");
|
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");
|
let font = FontRef::try_from_slice(font_data).expect("embedded font should be valid");
|
||||||
log::info!(
|
log::info!(
|
||||||
"Text cache initialized (font: {} bytes, render scale: {}x)",
|
"Text cache initialized (font: {} bytes, render scales: {:?}, mipmaps: {:?})",
|
||||||
font_data.len(),
|
font_data.len(),
|
||||||
RENDER_SCALE
|
RENDER_SCALES,
|
||||||
|
TEXTURE_OPTIONS.mipmap_mode
|
||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -66,25 +76,56 @@ impl TextCache {
|
||||||
|
|
||||||
/// Get or create a cached texture for the given text.
|
/// 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(
|
pub fn get_or_create(
|
||||||
&mut self,
|
&mut self,
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
text: &str,
|
text: &str,
|
||||||
nominal_font_size: f32,
|
nominal_font_size: f32,
|
||||||
|
zoom: f32,
|
||||||
) -> &CachedText {
|
) -> &CachedText {
|
||||||
// Round font size to reduce cache entries (0.5px granularity)
|
// Round font size to reduce cache entries (0.5px granularity)
|
||||||
let size_key = (nominal_font_size * 2.0).round() as u32;
|
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) {
|
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.insert(key.clone(), cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.cache.get(&key).expect("just inserted")
|
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.
|
/// Render text to a texture at high resolution.
|
||||||
fn render_text(
|
fn render_text(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -92,37 +133,107 @@ impl TextCache {
|
||||||
text: &str,
|
text: &str,
|
||||||
nominal_font_size: f32,
|
nominal_font_size: f32,
|
||||||
size_key: u32,
|
size_key: u32,
|
||||||
|
render_scale: f32,
|
||||||
) -> CachedText {
|
) -> CachedText {
|
||||||
let render_size = nominal_font_size * RENDER_SCALE;
|
let start_time = std::time::Instant::now();
|
||||||
let scale = PxScale::from(render_size);
|
trace!(
|
||||||
let scaled_font = self.font.as_scaled(scale);
|
"Rendering text '{}' at size {}px (scale {})...",
|
||||||
|
&text[..text.len().min(20)],
|
||||||
|
nominal_font_size,
|
||||||
|
render_scale
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate text dimensions
|
let mut warned_fallback = false;
|
||||||
let height = scaled_font.height();
|
let mut first_oversize_dims: Option<(u32, u32)> = None;
|
||||||
let ascent = scaled_font.ascent();
|
|
||||||
let width = Self::measure_text_width(text, &scaled_font);
|
|
||||||
|
|
||||||
// Early return for empty/invalid text
|
let mut selected = None;
|
||||||
if width <= 0.0 || height <= 0.0 || text.trim().is_empty() {
|
for candidate_scale in std::iter::once(render_scale).chain(
|
||||||
return Self::create_empty_texture(ctx, size_key);
|
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 Some((chosen_scale, scaled_font, scale, ascent, padding, img_width, img_height)) =
|
||||||
let padding = 2.0;
|
selected
|
||||||
let img_width = ((width + padding * 2.0).ceil() as u32).min(MAX_TEXTURE_DIM);
|
else {
|
||||||
let img_height = ((height + padding * 2.0).ceil() as u32).min(MAX_TEXTURE_DIM);
|
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 {
|
if warned_fallback {
|
||||||
return Self::create_empty_texture(ctx, size_key);
|
if let Some((w, h)) = first_oversize_dims {
|
||||||
}
|
log::warn!(
|
||||||
|
"Requested text scale {} for '{}' would create texture {}x{} (>{MAX_TEXTURE_DIM}); using scale {} instead",
|
||||||
let pixel_count = img_width as usize * img_height as usize;
|
render_scale,
|
||||||
if pixel_count > MAX_PIXEL_COUNT {
|
&text[..text.len().min(20)],
|
||||||
log::warn!(
|
w,
|
||||||
"Text texture too large: {img_width}x{img_height} for '{}', skipping",
|
h,
|
||||||
&text[..text.len().min(20)]
|
chosen_scale
|
||||||
);
|
);
|
||||||
return Self::create_empty_texture(ctx, size_key);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render glyphs to pixel buffer
|
// Render glyphs to pixel buffer
|
||||||
|
|
@ -144,15 +255,30 @@ impl TextCache {
|
||||||
};
|
};
|
||||||
|
|
||||||
let texture = ctx.load_texture(
|
let texture = ctx.load_texture(
|
||||||
format!("text_{size_key}_{}", text.len()),
|
format!(
|
||||||
|
"text_{size_key}_{}_{}",
|
||||||
|
text.len(),
|
||||||
|
Self::render_scale_key(render_scale)
|
||||||
|
),
|
||||||
image,
|
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 {
|
CachedText {
|
||||||
texture,
|
texture,
|
||||||
width: img_width,
|
width: img_width,
|
||||||
height: img_height,
|
height: img_height,
|
||||||
|
render_scale: chosen_scale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,19 +361,27 @@ impl TextCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a minimal 1x1 transparent texture for empty/invalid text.
|
/// 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 {
|
let image = ColorImage {
|
||||||
size: [1, 1],
|
size: [1, 1],
|
||||||
pixels: vec![Color32::TRANSPARENT],
|
pixels: vec![Color32::TRANSPARENT],
|
||||||
source_size: egui::Vec2::new(1.0, 1.0),
|
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 {
|
CachedText {
|
||||||
texture,
|
texture,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
|
render_scale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue