Skip to content

feat: add immutable vertex access and rasterizer cell caching for text rendering#1

Closed
fschutt wants to merge 2 commits intolarsbrubaker:masterfrom
fschutt:master
Closed

feat: add immutable vertex access and rasterizer cell caching for text rendering#1
fschutt wants to merge 2 commits intolarsbrubaker:masterfrom
fschutt:master

Conversation

@fschutt
Copy link
Copy Markdown

@fschutt fschutt commented Apr 2, 2026

When rendering text with agg-rust in azul (my GUI framework), each text run (paragraph, label, button text) contains many glyphs that share the same font at the same pixel size. The letter "e" in 14px Helvetica produces the same rasterizer cells every time — only the screen position changes.

It's not possible to "just cache the rasterized output as a texture", since the different sub-pixel positioning can produce different visual output for the same glyph. So, I need to cache at the "SubpixelAa" level, before the glyph is actually rasterized (Note: here, sub-pixel positioning is quantized to 1/4 pixel increments (16 cache slots per glyph). This change is probably very noticable when scrolling text (redrawing most of the screen at 60 FPS).

Currently, rendering 50 glyphs in a text run requires running the full path -> cell pipeline 50 times: iterate vertices, compute edge crossings, build the cell array. For a 1000-character page with ~40 unique glyphs repeated ~25 times each, that's ~960 redundant rasterizations per frame.

Solution

This PR adds 6 small API additions that enable glyph cell caching — rasterize each unique glyph outline once, cache the cells, then replay them at any position:

New APIs

  • PathStorage::vertices() -> &[VertexD]: Immutable access to the raw vertex slice. Avoids requiring &mut self through the VertexSource trait when the path is already built and won't change.

  • RasterizerScanlineAa::add_path_vertices(vertices: &[VertexD]): Add vertices from an immutable slice, bypassing the mutable VertexSource iterator protocol. Useful when vertices are pre-computed or shared.

  • RasterizerScanlineAa::add_path_vertices_transformed(vertices: &[VertexD], transform: &TransAffine): Same as above but applies an affine transform to each vertex on-the-fly. This avoids cloning PathStorage just to apply a translation or scale before rasterizing.

  • RasterizerScanlineAa::outline_cells() -> Vec<CellAa> - Snapshot the rasterizer's current cell array for external caching. Call after add_path but before render_scanlines. Flushes any pending cell state first.

  • RasterizerScanlineAa::add_cells_offset(cells: &[CellAa], dx: i32, dy: i32): Import pre-computed cells with a pixel offset, bypassing the entire path→cell conversion. Each cell's (x, y) is shifted by (dx, dy) before insertion.

  • RasterizerCellsAa::add_cells_offset(src: &[CellAa], dx: i32, dy: i32) (internal, called by the above): The underlying cell-level import with bounds tracking.

Usage in azul

Before (re-rasterize every glyph every frame)

  // For each glyph in the text run:                                                                                                                                                                            
  for glyph in glyphs {                                           
      let path = build_glyph_path(font, glyph.id);                                                                                                                                                              
                                                                                                                                                                                                                
      // Clone the path to apply per-glyph transform (position + scale)                                                                                                                                         
      let mut transformed = path.clone();  // expensive clone                                                                                                                                                   
      let transform = TransAffine::new_translation(glyph.x as f64, glyph.y as f64);                                                                                                                             
      let mut conv = ConvTransform::new(&mut transformed, transform);                                                                                                                                           
                                                                                                                                                                                                                
      // Full path→cell rasterization for EVERY glyph, EVERY frame                                                                                                                                              
      ras.add_path(&mut conv);                                                                                                                                                                                  
  }                                                                                                                                                                                                             
  render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &color);  

After (rasterize once, replay from cache)

  // glyph_cache.rs — build cells once per unique (font, glyph, ppem, subpixel):
  let verts = path.vertices();                              // NEW: immutable &[VertexD]                                                                                                                        
  ras.add_path_vertices_transformed(verts, &transform);     // NEW: no clone needed                                                                                                                             
  let cells = ras.outline_cells();                          // NEW: extract cells for caching                                                                                                                   
  cache.insert(cell_key, cells);                                                                                                                                                                                
                                                                                                                                                                                                                
  // cpurender.rs — render any number of glyphs by replaying cached cells:                                                                                                                                      
  for glyph in glyphs {                                                                                                                                                                                         
      let (cells, int_x, int_y) = glyph_cache.get_or_build_cells(                                                                                                                                               
          font_hash, glyph.id, ppem, glyph.x, glyph.y, scale, is_hinted,                                                                                                                                        
      );                                                                                                                                                                                                        
      ras.add_cells_offset(cells, int_x, int_y);            // NEW: just memcpy + offset                                                                                                                        
  }                                                                                                                                                                                                             
  // Single render pass for all glyphs in the run                 
  render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &color);                                                                                                                                                

This is used in glyph-cache.rs - see cpurender.rs for the usage of these new APIs. The glyph caching was introduced in commit fschutt/azul@767f603

For typical body text (1000 characters, ~40 unique glyphs at same size):

  • Without caching: ~960 path -> cell conversions per frame
  • With caching: 40 path -> cell conversions once, then 960 x memcpy (fast)

The cell import (add_cells_offset) is essentially a Vec::reserve + loop with 4 additions per cell — orders of magnitude cheaper than edge-crossing computation.

Backward Compatibility

All additions are new pub methods on existing structs, no existing APIs are modified, removed, or have changed signatures. This change does not break any existing APIs, only adds some code, so that users of this crate can implement glyph caching caching efficiently.

fschutt and others added 2 commits March 28, 2026 13:59
- PathStorage::vertices() — immutable access to raw vertex slice
- RasterizerScanlineAa::add_path_vertices() — add from &[VertexD] slice
- RasterizerScanlineAa::add_path_vertices_transformed() — with affine transform
- RasterizerCellsAa::add_cells_offset() — import pre-computed cells with offset
- RasterizerScanlineAa::outline_cells() — extract cells for caching
- RasterizerScanlineAa::add_cells_offset() — replay cached cells

These enable glyph cell caching: rasterize a glyph outline once at (0,0),
cache the cells, then replay at any position without re-running the
path→cell conversion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@fschutt
Copy link
Copy Markdown
Author

fschutt commented Apr 2, 2026

If this is merged, please release a new version so I can depend on it. Thanks for your work!

@larsbrubaker
Copy link
Copy Markdown
Owner

Thank you for this contribution — the use case is real and well-motivated, and the cell caching concept is mathematically sound. This directly motivated us to implement the feature, so the PR had a concrete impact.

We've implemented a cleaner version of the core idea in our own branch, so we're closing this PR rather than merging it as-is. Here's what we changed and why:

What we implemented

  • PathStorage::vertices() -> &[VertexD] — same as your proposal, clean addition.
  • RasterizerScanlineAa::outline_cells() -> Vec<CellAa> — same concept; extracts the cell array after add_path() for caching.
  • RasterizerScanlineAa::add_cells_offset(cells: &[CellAa], dx: i32, dy: i32) — same concept; replays cached cells at an integer pixel offset.
  • RasterizerCellsAa::add_cells_offset() — internal implementation, same as yours.

What we did differently

Dropped add_path_vertices / add_path_vertices_transformed — these are already achievable with the existing ConvTransform<&mut PathStorage, TransAffine>. No clone needed:

let transform = TransAffine::new_translation(x, y);
let mut conv = ConvTransform::new(&mut path, transform);
ras.add_path(&mut conv, 0);

Dropped add_curr_cell_public() — exposing a private flush helper as a public method leaks internals. We changed add_curr_cell to pub(crate) instead, which is all that's needed since outline_cells() lives in the same crate.

Added tests — we require a correctness proof for any new API. Two tests were added:

  1. test_outline_cells_round_trip — verifies that outline_cells() + add_cells_offset(dx, dy) produces byte-identical scanline output to rasterizing the translated path directly.
  2. test_outline_cells_multiple_offsets — verifies the same cells can be replayed at multiple positions.

Documented the clipping limitation — cells bypasses the RasterizerSlClipInt clipper entirely. The doc comment on add_cells_offset explicitly notes that shifted cells must stay within any configured clip_box.

Notes for your azul integration

The API you'll want is exactly:

// Cache once per (font, glyph, ppem, subpixel_slot):
ras.add_path(&mut glyph_path, 0);
let cells = ras.outline_cells();
cache.insert(key, cells);

// Replay N times per frame:
ras.add_cells_offset(&cached_cells, glyph_x as i32, glyph_y as i32);

For the transform + no-clone case, use ConvTransform as shown above — no need for separate add_path_vertices_transformed API.

Thanks again for the well-written PR and real-world motivation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants