Skip to content

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 .notdef gives 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