Files
amethyst/tools/icon-font/README.md
T
Vitor PamplonaandClaude Opus 5 f54194f70d perf(icons): draw Amethyst's own icons from a generated icon font
Icon(imageVector = …) calls rememberVectorPainter, and a VectorPainter rasterises
its paths into a cached graphics layer per instance, so the feed re-rasterised the
same glyphs once per card. A font glyph is a blit from the shared text atlas,
shared across every call site for free.

tools/icon-font/build_icon_font.py converts the Kotlin ImageVector DSL to SVG paths
and builds a TTF with fontTools. Font metrics mirror the bundled Material Symbols
font (upem 960, ascent 1056, descent -96, advance 960) so glyphs align with existing
call sites; generated outlines land within a few units of Google's own.

Measured on the uniform-corpus feed benchmark (SM-T220, three arms A/B/A, 0.2%
identical-arm noise floor, gate 18/18/18 cards):

  frame duration P90   -10.7%
  frame overrun  P90   -17.4%
  DrawReactions        114.8 -> 76.7 ms/iteration

For reference, ablating the reaction icons entirely gives frame P90 -13.5%, so this
captures ~84% of the available headroom. It supersedes the shared-VectorPainter
approach (-8.2%), which needed CompositionLocal plumbing and hand-scoping to avoid
cross-size cache thrashing; glyphs are atlas-shared automatically.

Artwork is unchanged: this converts Amethyst's existing vectors rather than
substituting Google's glyphs. Verified on device by pixel comparison -- unconverted
icons are 0-diff, and the converted ones differ only by sub-pixel antialiasing
between the text and vector rasterisers.

Stroked icons are deliberately NOT converted. A glyph outline can only be filled, so
converting Zap (strokeLineWidth 1.2) turned a thin outline bolt into a solid one; the
build script now detects a stroke and skips the icon, leaving Following, Zap and
ZapSplit on their ImageVectors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 10:21:59 -04:00

47 lines
2.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Amethyst icon font
Builds `amethyst_icons.ttf` from the Kotlin `ImageVector` icons in
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`.
## Why
`Icon(imageVector = …)` calls `rememberVectorPainter`, and a `VectorPainter`
rasterises its paths into a cached graphics layer **per instance**. A feed therefore
re-rasterised the same handful of glyphs once for every card scrolled in. A font glyph
is a blit from the shared text atlas instead, shared across every call site in the app
for free — no `CompositionLocal` plumbing, no per-screen scoping.
Measured on the uniform-corpus macrobenchmark (SM-T220, three arms, 0.2% noise floor):
| approach | frame P90 | overrun P90 | artwork |
|---|---|---|---|
| one shared `VectorPainter` per icon | −8.2% | −14.2% | unchanged |
| MaterialSymbols glyph substitutes | −10.4% | −16.0% | **changes** |
| **this font** | **−10.7%** | **−17.4%** | unchanged |
| ceiling: draw no icons at all | −12.7% | −22.9% | n/a |
## Usage
pip install fonttools
python3 tools/icon-font/build_icon_font.py <icons-dir> <out.ttf> <out.kt>
See the "Amethyst's own icons are also a font" section of `.claude/CLAUDE.md` for the
mandatory regeneration step and why both outputs must be committed together.
## How it works
The `ImageVector` builder DSL maps 1:1 onto SVG path commands (`moveTo` → `M`,
`curveToRelative` → `c`, …; none of the icons use `arcTo`), so the script extracts the
path data, emits an SVG `d` string, and draws it into a TrueType glyph via fontTools —
converting cubics to quadratics and flipping the y axis, since SVG grows downward and
font outlines grow upward from the baseline.
Font metrics deliberately mirror the bundled `material_symbols_outlined.ttf`
(unitsPerEm 960, ascent 1056, descent −96, advance 960) so the glyphs align with
existing MaterialSymbols call sites and `Icon()` sizing. Generated outlines land within
a few units of Google's own: our `Like` spans (78,94)–(882,851), their heart
(80,120)–(880,854).
An icon whose path data the parser cannot reach is reported and skipped rather than
silently emitted empty; it must keep using its `ImageVector`.