Text rendering
How a label gets from a style's text-field to pixels, and why the path is not the
same for every script.
The three kinds of text
Every label falls into one of three cases, decided per line, and a single label can contain all three.
| Comes from | Cached as | Shaping | |
|---|---|---|---|
| Latin, Greek, Cyrillic, and the rest | PBF glyphs from the style's glyphs endpoint |
one entry per codepoint, shared by every label | one glyph per codepoint |
| CJK, kana, hangul | drawn on the device | one entry per codepoint, shared by every label | one glyph per codepoint |
| Myanmar, Khmer, Thai, Lao, Indic, Arabic, Hebrew | shaped by a separate module from an outline font | one entry per run of text | reordering, marks, ligatures, bidi |
The first is what the renderer always did. The other two exist because that path cannot produce them: the server has no glyphs for the second, and the third cannot be expressed one codepoint at a time.
What the glyph server actually serves
The style's glyphs URL serves 256-codepoint ranges of pre-rendered SDF glyphs. Two
things about it shape everything below.
It only ever gets asked for what a label needs. Until recently only range 0
(U+0000-U+00FF) was fetched, so any label outside Latin-1 silently lost its glyphs, and
words that survived lost characters: Kanda Keisatsu Dōri rendered as Kanda Keisatsu
Dri. Which codepoints a tile's labels contain is not known until the tile is
extracted, so extraction reports what it could not resolve and the main thread fetches
it (see The demand loop).
It does not carry the ideographs. A CJK font is megabytes, so those ranges come back valid and empty:
19968-20223 200 552 bytes, 0 glyphs # CJK unified ideographs
26368-26623 200 552 bytes, 0 glyphs
44032-44287 200 552 bytes, 0 glyphs # hangul syllables
12352-12607 404 # hiragana
Nor does it carry every script it appears to. Range 11520-11775 returns 117 glyphs,
but they are the Georgian part of that block: Tifinagh, which Libyan and Algerian
places are labelled in, is not there.
The demand loop
Extraction runs on workers that cannot fetch anything, and it is synchronous. So it
never waits: it records what it could not resolve in ExtractedTextData.demands, which
travels back with the tile, and draws what it can.
worker: extract tile -> glyph missing -> TextDemands { ranges, locals, labels }
main: record demands (only for tiles the viewport wants)
main: fetch range / draw character / shape run
main: pack into the atlas, republish, re-extract the tiles that asked
Three kinds of demand come back:
ranges: a PBF range to fetch.locals: a character to draw on the device.labels: a run of text to shape.
Demands are only taken from tiles the viewport actually wants. A coarse prefetch tile at low zoom covers a subcontinent, and taking its labels at face value pulled down a font for every script in it: a Myanmar view was fetching Bengali, Thai, Khmer and Lao.
Re-extraction reuses the existing path: the tile is dropped from
pre_extracted_tiles, which makes it miss the cache, and its result is accepted
normally because its style version has not changed. No style bump, no tile-slot churn,
no blank frame.
Complex scripts
Myanmar, Khmer, Thai, Lao, the Indic scripts, Arabic and Hebrew reorder, stack marks, join and run right to left. The glyph atlas is keyed by codepoint and cannot express any of that.
Only the runs that need it are shaped. Street 390 ផ្លូវ ៣៩០ keeps its Latin on
the ordinary path, in the style's own font, sharing glyphs with every other label,
and shapes only the Khmer. The shaped run is packed as a single entry and drawn as
one quad.
Right-to-left lines are the exception and are shaped whole, Latin included: the
bidirectional algorithm decides the order across the boundary, so laying the parts out
one after another would put the 12 of شارع 12 on the wrong side.
A newline is a line break, not a character. Styles write both of a place's names into one field separated by one. Each line is shaped separately; on a line label, where two names cannot both follow one road, the first is used.
On a line label a shaped run is one quad, turned to the tangent, rather than bent glyph by glyph along the curve. That is what the major renderers do for these scripts.
The shaper itself is crates/nimbus-shaper: rustybuzz for GSUB/GPOS, unicode-bidi
for run order, zeno for the outlines, and sdf_glyph_renderer for the field. It is
free of I/O and of any renderer type, which is what lets the same code be linked into
the native build and compiled to a separate wasm module for the web.
See Building for WASM & iOS for how that module is loaded and how its fonts are configured.
Characters drawn on the device
For CJK there is nothing to shape, one character being one glyph, but there is nothing to fetch either. A missing glyph in those ranges is reported as a character to draw rather than a range to fetch. The main thread draws it with Canvas 2D at the 24px base, crops it to its ink, turns the coverage into the same distance field a PBF glyph carries, and packs it under the ordinary font stack and codepoint. Nothing downstream can tell the difference, and the glyph is reused by every label containing it.
This is localIdeographFontFamily by another name, and it costs no download: the font
is the one the device already uses for the script.
Conventions that have to match
Three of them, and getting any wrong is invisible until it is very visible.
The distance field. Buffer 3, radius 8, cutoff 0.25, so the outline falls at
192/256, which shaders/icon.wgsl hardcodes and compute_text_halo_buff assumes.
Both the shaper and the local rasteriser use sdf_glyph_renderer, the library behind
Mapbox's build_pbf_glyphs, so they are that encoding by construction rather than by a
constant somebody has to keep in step.
The baseline. A PBF glyph's top is measured from a fixed ascender line, not from
the baseline: a 17px capital stores top: -9, an 18px ascender top: -8. Anything
packed into the atlas converts to that convention (PBF_ASCENT), or it sits a line away
from the text beside it.
The base size. Everything is rasterised at 24px and scaled by font_size / 24.
Because these are distance fields, that scaling is free: zooming never re-rasterises
anything. Measured over a zoom from 11 to 15.5, the number of shaped entries went
219, 229, 229, 229.
The atlas
One texture holds all three kinds of glyph, keyed by (fontstack, codepoint). Shaped
runs live under their own font stack key (complex:<fontstack>) with ids above every
Unicode codepoint, so one lookup finds either kind and a run can never be mistaken for
a glyph.
It starts at 1024x1024 and doubles in height, to a limit of 4096 rows.
Growing invalidates everything. Growing recomputes every UV rect, so geometry extracted beforehand points at the wrong glyphs, which looked like labels drawn as a mush of other labels' pixels. When the atlas grows, the whole extraction context is republished rather than only the tiles waiting on the new glyphs.
Filling up used to be terminal. One shaped label is a bitmap of its own, around 8000 pixels, so a few hundred fill the texture; a full atlas then refuses everything after it, glyph ranges included, and a map panned from Myanmar to Egypt lost its labels everywhere and never got them back.
So it evicts, oldest first. Every glyph keeps the 8-bit bitmap it was blitted from (an eighth of
what the texture costs, and #[serde(skip)] so it never crosses to a worker) and when
there is no room the oldest half of the shaped labels is dropped and the rest laid out
again from those bitmaps, with no network involved.
What it drops is whatever it can produce again locally, by age: a shaped run and a character drawn on the device are both local work, and neither is favoured over the other. Dropping only the shaped runs would have meant a map that had crossed Japan keeping its ideographs for ever while throwing away its Arabic on every fill, which is a choice between scripts nobody should have to make. Fetched glyphs are always kept: there are few of them, every label shares them, and each one costs a round trip. A repack moves everything, so it bumps the layout generation like a growth does.
debugStats() reports complexLabels and glyphAtlasHeight, which is how any of this
gets diagnosed.
What is not drawn
- A script nothing has a font for. Tifinagh is in neither the PBF font nor the
Noto per-script files. Such characters are left out rather than drawn as the font's
empty box, which is what
.notdefgives you. - CJK on native. The local rasteriser needs a canvas, so the offscreen renderer and iOS have no ideographs yet.
- Font-stack fallback. Only
text_font[0]is ever used. - Dictionary line breaking. Thai, Khmer, Lao and Burmese are written without spaces; a long name breaks at a cluster boundary rather than a word.
Where the code is
src/script.rs |
which scripts need shaping, and splitting a label into runs |
src/local_glyphs.rs |
which characters are drawn on the device, and drawing them |
src/text_shaper.rs |
the ordinary codepoint-per-glyph path, and what it could not resolve |
src/glyph_atlas.rs |
packing, growth, eviction, the PBF conventions |
src/renderer/glyph_demand.rs |
the range and character queues, and fetching off-thread |
src/renderer/complex_text.rs |
the shaping queue, font configuration, the web bridge |
src/renderer/geometry/text.rs |
layout, line stacking, quad emission, line placement |
crates/nimbus-shaper |
shaping and rasterising, free of I/O |
crates/nimbus-shaper-wasm |
the same, as a separately loaded wasm module |