M5: cache text glyph geometry per node (Arc identity)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 07:17:05 -04:00
parent 515653f556
commit a2fd35cd32
2 changed files with 75 additions and 22 deletions

View File

@ -37,6 +37,14 @@ fn main() {
} }
d.insert(Some(l), usize::MAX, node).unwrap(); d.insert(Some(l), usize::MAX, node).unwrap();
} }
// Text objects (1 per 20 shapes).
for i in 0..n / 20 {
let x = rng(&mut seed) * 1500.0;
let y = rng(&mut seed) * 1150.0 + 20.0;
let id = d.alloc_id();
let t = drawcraft_doc::TextObject::point(Point::new(x, y), &format!("Label {i} — DrawCraft"), drawcraft_doc::CharStyle { size: 10.0 + (i % 5) as f64 * 4.0, ..Default::default() });
d.insert(Some(l), usize::MAX, Node::new(id, drawcraft_doc::NodeKind::Text(Box::new(t)))).unwrap();
}
let mut r = Renderer::new(); let mut r = Renderer::new();
let opts = RenderOptions::default(); let opts = RenderOptions::default();
for (label, w, h, view) in [ for (label, w, h, view) in [

View File

@ -95,6 +95,7 @@ struct GeomEntry {
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
pub struct Renderer { pub struct Renderer {
texts: HashMap<usize, (Arc<Node>, Arc<TextGeom>)>,
/// Single-threaded context used when the document has raster filters (vello's filters require it). /// Single-threaded context used when the document has raster filters (vello's filters require it).
ctx_st: Option<RenderContext>, ctx_st: Option<RenderContext>,
/// Worker threads for the multithreaded rasterizer (0 = single-threaded). /// Worker threads for the multithreaded rasterizer (0 = single-threaded).
@ -136,6 +137,7 @@ struct Frame<'a> {
impl Renderer { impl Renderer {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
texts: HashMap::new(),
ctx_st: None, ctx_st: None,
threads: default_threads(), threads: default_threads(),
geom: HashMap::new(), geom: HashMap::new(),
@ -308,6 +310,16 @@ impl Renderer {
self.stats.drawn += 1; self.stats.drawn += 1;
return; return;
} }
if let NodeKind::Text(t) = &a.kind
&& a.opacity >= 1.0
&& a.blend == drawcraft_color::BlendMode::Normal
&& !fx::has_fx(a)
{
let g = self.text_geom_of(a, t);
self.draw_text_geom(ctx, f, a, t, &g);
self.stats.drawn += 1;
return;
}
self.draw_node(ctx, f, a, true); self.draw_node(ctx, f, a, true);
} }
@ -525,44 +537,57 @@ impl Renderer {
} }
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
let layout = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); let g = text_geom(t);
self.draw_text_geom(ctx, f, n, t, &g);
}
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
let key = Arc::as_ptr(a) as usize;
if let Some((node, g)) = self.texts.get(&key)
&& Arc::ptr_eq(node, a)
{
return g.clone();
}
let g = Arc::new(text_geom(t));
if self.texts.len() > 4096 {
self.texts.clear();
}
self.texts.insert(key, (a.clone(), g.clone()));
g
}
fn draw_text_geom(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) {
let xf = f.view * t.xf; let xf = f.view * t.xf;
if f.opts.outline { if f.opts.outline {
for g in &layout.glyphs { let mut p = g.all.clone();
let mut p = g.outline.clone();
p.apply_affine(xf); p.apply_affine(xf);
ctx.set_transform(Affine::IDENTITY); ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.0)); ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::BLACK); ctx.set_paint(peniko::Color::BLACK);
ctx.stroke_path(&p); ctx.stroke_path(&p);
}
return; return;
} }
let tb = t.xf.transform_rect_bbox(layout.bounds); let tb = t.xf.transform_rect_bbox(g.bounds);
// Object-level appearance fills/strokes apply on top of character fills (like Illustrator). // Object-level appearance fills/strokes apply on top of character fills (like Illustrator).
for (i, run) in t.runs.iter().enumerate() { for (i, run) in t.runs.iter().enumerate() {
let mut path = BezPath::new(); let Some(path) = g.runs.get(i) else { continue };
for g in layout.glyphs.iter().filter(|g| g.run == i) {
path.extend(g.outline.iter());
}
if path.elements().is_empty() { if path.elements().is_empty() {
continue; continue;
} }
ctx.set_transform(xf); ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero); ctx.set_fill_rule(peniko::Fill::NonZero);
if paint::set_paint(ctx, &run.style.fill, layout.bounds, f.doc) { if paint::set_paint(ctx, &run.style.fill, g.bounds, f.doc) {
ctx.fill_path(&path); self.fold_alpha(ctx, &run.style.fill);
ctx.fill_path(path);
} }
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 && paint::set_paint(ctx, &run.style.stroke, layout.bounds, f.doc) { if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 && paint::set_paint(ctx, &run.style.stroke, g.bounds, f.doc) {
ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width)); ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width));
ctx.stroke_path(&path); ctx.stroke_path(path);
} }
} }
if !n.appearance.items.is_empty() { if !n.appearance.items.is_empty() {
let mut all = BezPath::new(); let mut all = g.all.clone();
for g in &layout.glyphs {
all.extend(g.outline.iter());
}
all.apply_affine(t.xf); all.apply_affine(t.xf);
for item in &n.appearance.items { for item in &n.appearance.items {
ctx.set_transform(f.view); ctx.set_transform(f.view);
@ -637,6 +662,26 @@ fn default_threads() -> u16 {
} }
} }
/// Glyph outlines grouped by run, plus the whole text as one path.
struct TextGeom {
runs: Vec<BezPath>,
all: BezPath,
bounds: Rect,
}
fn text_geom(t: &TextObject) -> TextGeom {
let layout = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
let mut runs = vec![BezPath::new(); t.runs.len()];
let mut all = BezPath::new();
for g in &layout.glyphs {
if let Some(r) = runs.get_mut(g.run) {
r.extend(g.outline.iter());
}
all.extend(g.outline.iter());
}
TextGeom { runs, all, bounds: layout.bounds }
}
fn rects_overlap(a: Rect, b: Rect) -> bool { fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
} }