3.7 KiB
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).
Related
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.