Files
photocraft/docs/text-raster-performance.md
T

3.7 KiB
Raw Blame History

Text rasterization: what a type render costs

Measured 2026-10-17 on Windows 11, i5-10300H (4 cores / 8 threads, 16 GB), release build, rustc 1.98.1, with a probe that renders one paragraph layer through photocraft_text::render::rasterize_warped and times each stage of it.

Document: 300 dpi, a 2400 × 1600 px paragraph box holding 4000 characters at 14 pt — the worst case a type edit has (a whole page of text at print resolution; 1851 laid-out glyphs, 2329 × 1610 px of ink = 3.75 MP).

Stage ms What it does
ink rectangle (ink_rect_warped) 4.1 its own pass over every glyph, to size the surface
draw (draw_pass into Coverage) 18.6–22.9 glyph outlines, curve flattening (0.03 px tolerance), winding scanlines
prefix sum (Coverage::finish) 7.0–8.1 resolves the winding accumulator to coverage 0…1, one running sum over 3.75 M pixels
composite 22.6–33.4 each colour's coverage over the premultiplied accumulator
unpremultiply 11.2–14.0 divides the accumulator back to straight colour
Surface::write_region 23.8 converts to the document format and fills the tiles
Surface::prune 3.1 drops tiles that came back the layer's default
total 86–104

So the glyph geometry — what one would expect to dominate — is under a quarter of it. The rest is scalar work over the pixel buffers, and every one of those passes is per pixel: a pixel's maths reads and writes only its own slots.

What runs in parallel now

composite, unpremultiply and the tile conversion run on all cores (rayon, native only; the web build keeps the serial loops, since wasm has no threads). The rasterizer writes its tiles with Surface::write_region_par; Surface::write_region stays serial, because mask builds call it inside a OnceLock initializer, which must not enter rayon. Nothing about them is order-dependent, so each pixel still sees the same operations on the same inputs — the pixels are identical, not merely close.

before after
the 3.75 MP case above 98.3–103.9 ms 60.3–61.8 ms

crates/text/src/tests.rs::parallel_raster_matches_a_single_thread renders the shapes the pipeline can take (plain, faux bold italic, no antialias, wide gamut, a wrapping paragraph box; 8/16/32-bit) on a one-thread pool and on the default pool and compares the surfaces; crates/raster's existing region_writes_match_scalar_bytes_for_every_format compares write_region and write_region_par against a scalar reference implementation, tile for tile.

What stays on one thread, and why

  • draw (19–23 ms): the winding accumulator carries across scanlines and rows — a pixel's value depends on the rows above it, and one scanline can spill into the next row's first pixel. A band split can be exact (bands keep absolute coordinates, carry the spill in band order, and are assembled before the prefix sum), but each band re-traverses every glyph — the traversal is not free, so the win is bounded by the band count. Worth doing when the draw matters more than it does now.
  • prefix sum (7–8 ms): a running f32 sum over the whole image. Splitting it per row needs the row's starting sum, which is that same sequential chain — the only exactly-known starting sums are the ones the chain computes.
  • ink rectangle (4.1 ms): a second full traversal into a bounds sink. Per-glyph bounds reduce by min/max, which is associative and exact, so this is a candidate for the same treatment as the stages above (a glyph-range split, not a band split).

Byte-identical output matters here beyond taste: text pixels reach a PSD's saved cache and the round-trip corpora. cargo xtask test-corpus and the Photoshop oracle tests are the gate.