//! Glyph rendering for the software backend. //! //! vello rasterizes glyphs itself on the GPU; here we do the equivalent on //! the CPU. Two routes: //! //! - **Hinted, cached** — when a run asks for hinting, the current //! transform is a uniform axis-aligned scale, and the brush is a solid //! color: each glyph is rasterized once (hinted by skrifa) into a small //! premultiplied pixmap and blitted per use. This mirrors vello's rule //! exactly (vello_encoding 0.9 `resolve.rs`): the scale folds into the //! font size, each glyph's device translation is //! `(tx + x·s, round(ty + y·s))` — y snapped to the pixel grid, //! x kept fractional (quantized here into subpixel buckets so rasters //! are reusable). //! - **Outline fill** — everything else (unhinted runs, rotated/skewed //! transforms, non-solid brushes): glyph outlines are collected into one //! path and filled, as vello falls back to unhinted rendering under the //! same conditions. A hintable run with a non-solid brush still hints //! its outlines so geometry agrees with vello; it just skips the cache. use std::collections::HashMap; use guiduck_scene::geom::{Affine, Rect}; use guiduck_scene::paint::Brush; use guiduck_scene::{GlyphRun, RenderError}; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::outline::{ DrawSettings, Engine, HintingInstance, HintingOptions, OutlineGlyphCollection, OutlinePen, SmoothMode, Target, }; use skrifa::{FontRef, GlyphId, MetadataProvider}; /// vello's hinting options (vello_encoding 0.9 `glyph_cache.rs`), verbatim /// and on the same skrifa: hinted outline geometry is identical across /// backends, so only anti-aliasing differs in the parity comparison. const HINTING_OPTIONS: HintingOptions = HintingOptions { engine: Engine::AutoFallback, target: Target::Smooth { mode: SmoothMode::Lcd, symmetric_rendering: false, preserve_linear_metrics: true, }, }; /// Fractional-x quantization for cached rasters: a glyph at x = 10.3 reuses /// the raster drawn for subpixel offset 0.25. const SUBPIXEL_BUCKETS: u8 = 8; /// Safety valve, not a tuning knob: a UI's working set (a few fonts × a few /// sizes × its glyph repertoire × colors) stays far below this; only a /// pathological session (e.g. animating font size) would grow past it, and /// clearing simply re-rasterizes. const MAX_RASTERS: usize = 4096; /// The per-backend glyph store: rasterized hinted glyphs and the hinting /// instances (one per font/size/variation) that produce them. Persists /// across frames on [`TinySkiaBackend`](crate::TinySkiaBackend). #[derive(Default)] pub struct GlyphCache { rasters: HashMap>, hinters: HashMap>, } /// A rasterized glyph: premultiplied pixels and the placement of their /// top-left corner relative to the glyph's integer device position. struct CachedGlyph { pixmap: tiny_skia::Pixmap, left: i32, top: i32, } #[derive(Clone, PartialEq, Eq, Hash)] struct RasterKey { blob_id: u64, font_index: u32, glyph: u32, size_bits: u32, coords: Box<[i16]>, subpixel: u8, color: [u8; 4], } #[derive(Clone, PartialEq, Eq, Hash)] struct HinterKey { blob_id: u64, font_index: u32, size_bits: u32, coords: Box<[i16]>, } /// How a run's glyphs meet the pixel grid under the current transform. pub enum RunPlacement { /// Uniform axis-aligned scale and hinting requested: hint at /// `size × scale`, place at device positions with rounded y. Hinted { scale: f32, tx: f32, ty: f32 }, /// Anything else: raw outlines in run-local space, positioned by the /// full transform at fill time. Raw, } impl RunPlacement { /// Apply vello's applicability rule to the current transform. pub fn of(run: &GlyphRun, transform: Affine) -> Self { let [xx, yx, xy, yy, tx, ty] = transform.as_coeffs(); if run.hint && xx == yy && yx == 0.0 && xy == 0.0 { Self::Hinted { scale: xx as f32, tx: tx as f32, ty: ty as f32, } } else { Self::Raw } } } impl GlyphCache { /// Draw a run through the raster cache. Only valid for /// [`RunPlacement::Hinted`] with a solid brush — the callers' fast /// path; everything else goes through [`run_to_path`]. #[allow(clippy::too_many_arguments)] pub fn draw_hinted_run( &mut self, run: &GlyphRun, scale: f32, tx: f32, ty: f32, color: [u8; 4], pixmap: &mut tiny_skia::Pixmap, mask: Option<&tiny_skia::Mask>, ) -> Result<(), RenderError> { self.placed_rasters(run, scale, tx, ty, color, |x, y, cached| { pixmap.draw_pixmap( x, y, cached.pixmap.as_ref(), &tiny_skia::PixmapPaint::default(), tiny_skia::Transform::identity(), mask, ); }) } /// The exact device-pixel bounds a hinted run's rasters cover, through /// the same cache the draw uses — a prepass that computes bounds /// populates rasters the subsequent draw reuses. pub fn hinted_run_bounds( &mut self, run: &GlyphRun, scale: f32, tx: f32, ty: f32, color: [u8; 4], ) -> Result, RenderError> { let mut bounds: Option = None; self.placed_rasters(run, scale, tx, ty, color, |x, y, cached| { let rect = Rect::new( f64::from(x), f64::from(y), f64::from(x) + f64::from(cached.pixmap.width()), f64::from(y) + f64::from(cached.pixmap.height()), ); bounds = Some(match bounds { Some(acc) => acc.union(rect), None => rect, }); })?; Ok(bounds) } /// Resolve each glyph of a hinted run to its cached raster and integer /// device position, rasterizing on miss — the single definition of how /// hinted glyphs meet the pixel grid, shared by drawing and by damage /// bounds. fn placed_rasters( &mut self, run: &GlyphRun, scale: f32, tx: f32, ty: f32, color: [u8; 4], mut placed: impl FnMut(i32, i32, &CachedGlyph), ) -> Result<(), RenderError> { let font = FontRef::from_index(run.font.data.as_ref(), run.font.index) .map_err(|e| RenderError::Backend(Box::new(e)))?; let outlines = font.outline_glyphs(); let size = run.size * scale; let coords: Box<[i16]> = run.normalized_coords.as_slice().into(); if self.rasters.len() > MAX_RASTERS { self.rasters.clear(); } for glyph in &run.glyphs { let dx = tx + glyph.x * scale; let dy = (ty + glyph.y * scale).round(); let base_x = dx.floor(); let subpixel = (((dx - base_x) * f32::from(SUBPIXEL_BUCKETS)) as u8).min(SUBPIXEL_BUCKETS - 1); let key = RasterKey { blob_id: run.font.data.id(), font_index: run.font.index, glyph: glyph.id, size_bits: size.to_bits(), coords: coords.clone(), subpixel, color, }; if !self.rasters.contains_key(&key) { let hinter = Self::hinter( &mut self.hinters, &outlines, &run.font, size, &run.normalized_coords, ); let raster = rasterize(&outlines, glyph.id, size, subpixel, color, hinter)?; self.rasters.insert(key.clone(), raster); } if let Some(Some(cached)) = self.rasters.get(&key) { placed(base_x as i32 + cached.left, dy as i32 + cached.top, cached); } } Ok(()) } /// The hinting instance for a font/size/variation, cached. `None` when /// skrifa cannot build one (the caller falls back to unhinted scaling, /// matching vello's glyph cache doing the same). fn hinter<'a>( hinters: &'a mut HashMap>, outlines: &OutlineGlyphCollection<'_>, font: &guiduck_scene::paint::FontData, size: f32, normalized_coords: &[i16], ) -> Option<&'a HintingInstance> { let key = HinterKey { blob_id: font.data.id(), font_index: font.index, size_bits: size.to_bits(), coords: normalized_coords.into(), }; hinters .entry(key) .or_insert_with(|| { let coords: Vec = normalized_coords .iter() .map(|c| NormalizedCoord::from_bits(*c)) .collect(); HintingInstance::new( outlines, Size::new(size), LocationRef::new(&coords), HINTING_OPTIONS, ) .ok() }) .as_ref() } } /// Rasterize one glyph at a subpixel x offset into a premultiplied pixmap. /// `None` when the glyph has no coverage (whitespace). fn rasterize( outlines: &OutlineGlyphCollection<'_>, glyph: u32, size: f32, subpixel: u8, color: [u8; 4], hinter: Option<&HintingInstance>, ) -> Result, RenderError> { let Some(outline) = outlines.get(GlyphId::new(glyph)) else { return Ok(None); }; let mut pen = RunPen { builder: tiny_skia::PathBuilder::new(), offset: (f32::from(subpixel) / f32::from(SUBPIXEL_BUCKETS), 0.0), }; let settings = match hinter { Some(instance) => DrawSettings::hinted(instance, false), None => DrawSettings::unhinted(Size::new(size), LocationRef::default()), }; outline .draw(settings, &mut pen) .map_err(|e| RenderError::Backend(Box::new(e)))?; let Some(path) = pen.builder.finish() else { return Ok(None); }; // A pixel of padding on every side keeps anti-aliased edges inside. let bounds = path.bounds(); let left = bounds.left().floor() as i32 - 1; let top = bounds.top().floor() as i32 - 1; let width = (bounds.right().ceil() as i32 - left + 1).max(1) as u32; let height = (bounds.bottom().ceil() as i32 - top + 1).max(1) as u32; let Some(mut pixmap) = tiny_skia::Pixmap::new(width, height) else { return Ok(None); }; let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8( color[0], color[1], color[2], color[3], )), ..Default::default() }; pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tiny_skia::Transform::from_translate(-left as f32, -top as f32), None, ); Ok(Some(CachedGlyph { pixmap, left, top })) } /// Build one path containing every glyph outline in the run — the fallback /// route. For [`RunPlacement::Hinted`] the path is in *device* coordinates /// (fill it under the identity transform); for [`RunPlacement::Raw`] it is /// in run-local coordinates (fill it under the current transform), exactly /// as before hinting existed. Returns `None` when nothing is drawable. pub fn run_to_path( run: &GlyphRun, placement: &RunPlacement, cache: &mut GlyphCache, ) -> Result, RenderError> { let font = FontRef::from_index(run.font.data.as_ref(), run.font.index) .map_err(|e| RenderError::Backend(Box::new(e)))?; let outlines = font.outline_glyphs(); let coords: Vec = run .normalized_coords .iter() .map(|c| NormalizedCoord::from_bits(*c)) .collect(); let location = LocationRef::new(&coords); let mut pen = RunPen { builder: tiny_skia::PathBuilder::new(), offset: (0.0, 0.0), }; match placement { RunPlacement::Hinted { scale, tx, ty } => { let size = run.size * scale; let hinter = GlyphCache::hinter( &mut cache.hinters, &outlines, &run.font, size, &run.normalized_coords, ); for glyph in &run.glyphs { let Some(outline) = outlines.get(GlyphId::new(glyph.id)) else { continue; }; // `DrawSettings` is not `Clone`; build it per glyph from // the (Copy) hinter reference. let settings = match hinter { Some(instance) => DrawSettings::hinted(instance, false), None => DrawSettings::unhinted(Size::new(size), location), }; pen.offset = (tx + glyph.x * scale, (ty + glyph.y * scale).round()); outline .draw(settings, &mut pen) .map_err(|e| RenderError::Backend(Box::new(e)))?; } } RunPlacement::Raw => { for glyph in &run.glyphs { let Some(outline) = outlines.get(GlyphId::new(glyph.id)) else { continue; }; pen.offset = (glyph.x, glyph.y); outline .draw( DrawSettings::unhinted(Size::new(run.size), location), &mut pen, ) .map_err(|e| RenderError::Backend(Box::new(e)))?; } } } Ok(pen.builder.finish()) } /// The device-space bounds of a glyph run under `transform`, mirroring the /// route the draw takes: exact raster extents for the hinted-and-solid fast /// path, outline path bounds for everything else. pub fn run_device_bounds( run: &GlyphRun, transform: Affine, cache: &mut GlyphCache, ) -> Result, RenderError> { let placement = RunPlacement::of(run, transform); match (&placement, solid_color(&run.brush)) { (RunPlacement::Hinted { scale, tx, ty }, Some(color)) => { cache.hinted_run_bounds(run, *scale, *tx, *ty, color) } _ => { let Some(path) = run_to_path(run, &placement, cache)? else { return Ok(None); }; let bounds = path.bounds(); let bounds = Rect::new( f64::from(bounds.left()), f64::from(bounds.top()), f64::from(bounds.right()), f64::from(bounds.bottom()), ); Ok(Some(match placement { // Hinted outline paths are already in device space. RunPlacement::Hinted { .. } => bounds, RunPlacement::Raw => transform.transform_rect_bbox(bounds), })) } } } /// Whether a brush is one solid color, as rgba8 (the raster-cache key). pub fn solid_color(brush: &Brush) -> Option<[u8; 4]> { match brush { Brush::Solid(color) => { let rgba = color.to_rgba8(); Some([rgba.r, rgba.g, rgba.b, rgba.a]) } _ => None, } } /// Pen translating skrifa's y-up, glyph-local coordinates into y-down space /// at the current glyph offset. struct RunPen { builder: tiny_skia::PathBuilder, offset: (f32, f32), } impl RunPen { fn map(&self, x: f32, y: f32) -> (f32, f32) { (self.offset.0 + x, self.offset.1 - y) } } impl OutlinePen for RunPen { fn move_to(&mut self, x: f32, y: f32) { let (x, y) = self.map(x, y); self.builder.move_to(x, y); } fn line_to(&mut self, x: f32, y: f32) { let (x, y) = self.map(x, y); self.builder.line_to(x, y); } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { let (cx0, cy0) = self.map(cx0, cy0); let (x, y) = self.map(x, y); self.builder.quad_to(cx0, cy0, x, y); } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { let (cx0, cy0) = self.map(cx0, cy0); let (cx1, cy1) = self.map(cx1, cy1); let (x, y) = self.map(x, y); self.builder.cubic_to(cx0, cy0, cx1, cy1, x, y); } fn close(&mut self) { self.builder.close(); } }