glyph.rs
raw
//! 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<RasterKey, Option<CachedGlyph>>,
hinters: HashMap<HinterKey, Option<HintingInstance>>,
}
/// 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<Option<Rect>, RenderError> {
let mut bounds: Option<Rect> = 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<HinterKey, Option<HintingInstance>>,
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<NormalizedCoord> = 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<Option<CachedGlyph>, 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<Option<tiny_skia::Path>, 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<NormalizedCoord> = 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<Option<Rect>, 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();
}
}