Labels
Scope: text and icon labels from decoded tiles to screen. Contour labels specifically are in 07-hillshade-contours.md.
The pipeline
Two invariants live in this picture and both have caused visible bugs: LabelCuller::process must
not clear the grid (layers must collide with each other), and snapPlacement must prefer the
same (tileId, localId) copy. Details below.
Where a label wraps
text-wrap-width is in style pixels, and is the one length in TextSymbolizer::getFormatterOptions
that is NOT multiplied by fontScale. TextFormatter::splitLines accumulates a word's width from
glyph.advance * _fontSize, where _fontSize is the style size — so a threshold in device pixels
was compared against a width in style pixels and every label wrapped fontScale times too late. On
a 2.6x screen a 8-em maximum became a 21-em one, which is to say nothing wrapped at all: MapTiler's
"Médiathèque Annecy - Bonlieu" stayed on one line where the same style wraps it to two in a browser.
Where a label breaks is a property of the style, not of the display, so the offsets beside it
(dx/dy, the secondary run's) scale and this one does not.
Building the label set
On every tile-set change, buildLabelMaps recreates all vt::Label objects from the current
tiles. Labels with the same globalId from different tiles are merged (mergeGeometries — one
geometry copy per tile, identified by (tileId, localId)), and visibility, opacity and placement
are carried over from the previous object via snapPlacement.
Placement stability invariant. snapPlacement / findSnappedPointPlacement /
findSnappedLinePlacement prefer the geometry copy with the same (tileId, localId) as the previous
placement. Without that, re-snapping picks a winner by merged-list order — which changes with the
tile set — and a placement rebuilt from a differently clipped copy of a line can fail line fitting
(buildLineVertexData), after which the culler hides an already visible label. That is what
"labels jump and disappear while panning" looked like. Keep the invariant when touching Label or
LabelCuller.
Draw order is one list per pass, sorted by priority → layer index → global id, and the batch
changes glyph atlas mid-list. It runs bottom to top — what is drawn last is on top — which is the
OPPOSITE of the culler's order, where the most important label is placed FIRST so it claims its slot
(LabelCuller::process). The draw list used to copy the culler's direction, so of two labels that
overlap the LESS important one was drawn over the other: a road name covering the town name it
crossed. The layerIndex tie-break had always run bottom-to-top, which is what gave it away. Grouping the labels by atlas first — which is what a
unordered_map<Bitmap, ...> did — put two labels from different atlases in pointer-hash order,
so an icon and the small label meant to sit on it were drawn in whichever order the allocator
happened to give. It only showed below the raster-ladder boundary, where the smaller label lands in
another atlas than the icon: text-size: 11 drew over the icon and text-size: 10 under it.
Known remaining cost: buildLabelMaps reallocates every label on every tile-set change during
panning. Reusing unchanged labels is the next win here, and it is delicate — it relies on fresh
caches and snapPlacement semantics.
Placement and culling
VTLabelPlacementWorker runs on MapRenderer::vtLabelsChanged (i.e. whenever draw data changes),
creates one fresh LabelCuller per pass, and calls TileRenderer::cullLabels for every vector
layer sequentially. The culler's screen grid accumulates across layers on purpose, so labels of
different layers collide with each other. LabelCuller::process therefore must not clear the
grid.
LabelCuller::process:
- captures
wasVisible; Label::updatePlacement— only re-places a label when its envelope has fully left the frustum; resets opacity;- projects envelopes to screen space;
- sorts by priority →
wasVisible→ layer index → size → opacity; - greedily inserts into a 16×32 screen grid with SAT polygon-overlap tests.
A visible label keeps its slot unless a strictly higher-priority label overlaps it — that hysteresis is what stops labels flickering when the camera moves slightly.
Fork-specific rules, all comparing the placement's localId (hence the stability invariant
above): allowOverlapSameFeatureId, sameFeatureIdDependent, and group ids with
minimumGroupDistance.
Line runs: line lies flat, billboard-line stays upright
Three placements walk the line at text-spacing. Two of them lay a glyph run out along it
(Label::isLineRun) and differ only in the plane buildLineVertexData picks; the third lays out no
run at all:
text-placement | LabelOrientation | run laid out on | drawn as |
|---|---|---|---|
line | LINE | the placement's own tangent frame (placement->xAxis/yAxis), no projection | flat on the surface, WORLD_OFFSET |
billboard-line | LINE_BILLBOARD_3D | the camera axes, through the view-projection | upright, CAMERA_AXIS_OFFSET |
billboard-line-repeat | (never reaches vt — becomes BILLBOARD_3D) | not laid out on the line: one label per step, in its own box | upright, CAMERA_AXIS_OFFSET |
line is the map-like one: the text lies in the ground plane like the line it names, so it tilts and
foreshortens with the map. billboard-line keeps its size and its shape at any tilt, at the cost of
no longer lying on the surface — which is what a road name usually wants and a route distance usually
does not. billboard-line-repeat is the road-shield case: the repeat along the line is wanted, the
run following it is not.
billboard-line-repeat is resolved in the symbolizers, not in vt: they repeat it like the other
line placements, hand the label a BILLBOARD_3D style and, unlike a run, no line — Label snaps
a placement to _tileLines in preference to _tilePoints, and TileLayerBuilder tesselates a line
a billboard would never read. On a feature that is not a line it is the plain billboard its name
says (repeat is decided per feature). It is also what nutibillboardline now maps to, which is
what that spelling meant in 5.x.
This was a regression between 5.x and 6.0. LINE was moved to the camera-axis layout wholesale
("lay line labels out in screen space"), so text-placement: line silently became a billboard and
there was no way back to the flat run; the only thing still drawing text flat was text-clip, which
is not a label at all (see below). The two layouts are now separate orientations, line is flat
again, and billboard-line — which used to place one upright point label per text-spacing step,
ignoring the line direction — takes the run that follows the line. That 5.x behaviour is
billboard-line-repeat, added once the split turned out to have removed the only way to repeat a
shield along a road without turning it with the road.
Consequences of the split, all keyed on isLineRun() / isScreenLineRun():
- The layout cache is keyed on the view-projection only for a screen run. A flat run does not depend on the camera, so keying it on the view rebuilt it every frame for nothing.
- The envelope is the run's bounds put back on the label's own axes (
setupCoordinateSystem), which is the camera basis for one and the tangent frame for the other. - Anchor pixel-snapping is skipped for both: it snaps the anchor to a quarter-pixel grid, which is meaningless once the glyphs are laid out along a line rather than in a box.
- How far the run sits off the line comes from the CR pseudo-glyph, like it does for a point
label:
TextFormatter::layoutLinesputs the block's vertical alignment and itstext-dyin that glyph'sadvance(1), andbuildLineVertexDataapplies it perpendicular to the run. Ignoring it — which it did until 2026-08 — laid every line label on its baseline, so the text floated above its road by about an ascender andtext-dydid nothing at all on a line. The culler saw the centred box all along: its box comes from the point pen walk (walkGlyphs), which never dropped it. TextSymbolizerfeeds all three the same anchors,text-spacingand measured run length (repeatAlongLine), and hands the line itself only to the two that lay a run out (lineRun). The bare vertex with an empty vertex list — whybillboard-linecould not follow anything before the split — is now exactly whatbillboard-line-repeatasks for.
A layout fails in two different ways, and they are held differently (Label::LineLayout):
NO_ROOM— the run does not fit, or nothing projects. That is a transient of this camera: fit is judged on the projected line, from two different view states (the culler works on its pass's snapshot, the renderer on the current camera), so a run at the edge of what fits alternates between them and blinks at frame rate. A run already on screen rides outLINE_LAYOUT_FAILURE_GRACEof these.UNREADABLE— the glyphs turn far enough to pile up on the inside of a corner (MIN_LINE_SEGMENT_DOTPRODUCT,MAX_LINE_RUN_ANGLE_SPREAD), or the run doubles back. That is a property of the line's shape at this scale, not of the camera. It gets no grace, and its quads are dropped rather than kept: the grace used to cover both, so a run the layout had just measured as illegible stayed on screen for several passes — the reported "characters over each other at a bend".
text-spacing: 0 means one run for the WHOLE line on both paths. The clip path used to restart
the pen at the middle of every segment, so the same style drew one label per line when it was culled
and one per bend when it was clipped (generateLinePoints). A segment outside the tile now still
advances the pen, so the spacing is constant along the line instead of restarting at each tile
border. Clipped text still gets one run per TILE the line crosses — the geometry is per tile and
there is no label to merge them.
text-spacing places anchors by ground distance, as mapbox's symbol-spacing does; under tilt
they project closer together in the far field. The screen-space guarantee is text-min-distance,
which gives the label a group id the culler enforces in pixels.
A line label's group is its TEXT, so the distance thins repeats of one name and leaves a
different name alone; only a point label shares one group with every other. shield-min-distance
used to fall in the second case whatever its placement, which made it a distance between DIFFERENT
roads: a style setting it to its own shield-spacing — what mapbox2css writes, so that a road cut
into many ways is not shielded on each — lost most of its shields instead.
A candidate stretch of line is measured in the plane its run will be laid out in, and the same
measure both ranks the candidates and decides whether the run fits (findClippedLinePlacement). A
screen run has to be compared on screen — a line running away from a tilted camera is worth a
fraction of its ground length there, and measured on the ground it wins the placement and then does
not fit. A flat run is compared on the ground, because that is where it lies: it used to be judged
against a screen-HORIZONTAL run of the same world length, which asks for several times the room it
needs as soon as the view tilts. That is what "the label only appears if I zoom in a lot, and once
it is gone I have to zoom much further to get it back" was — the asymmetry is the layout hysteresis
(LINE_LAYOUT_FAILURE_GRACE, *_KEEP thresholds) holding a placed run that a fresh search could no
longer win.
The run never leaves the line. pointAt clamps at both ends and the fit test is exact —
runLength > total is NO_ROOM, and the pen start is clamped to [0, total - runLength]. This is
tangram's rule (LineSampler::advance clamps, CurvedLabel::updateScreenTransform rejects
start < 0 || start + width > sumLength()). Until 2026-08 both ends were given PLACEMENT_ROOM_FACTOR
of slack — a run up to 1.5x the line was accepted, and pointAt continued the line straight past
its last vertex to carry it. The slack was there to stop a run at the edge of what fits blinking as
the projected line changes length under the camera; the cost was a street name running off the end of
its road, and off the curve on a bent one, so what absorbs the flapping is now
LINE_LAYOUT_FAILURE_GRACE alone. The per-glyph direction window slides back to stay on the line
rather than being clamped against its end, where it would collapse and read as UNREADABLE.
Covered by tests/vt/LineLabelTest.cpp.
Text drawn as geometry (text-clip)
text-clip takes the text off the label pipeline entirely: TextSymbolizer builds vt::TextStyle
glyph quads into the tile geometry (createTextProcessor), clipped to the tile. What that costs is
everything the label pipeline is:
| label path | text-clip: true | |
|---|---|---|
culling, text-allow-overlap, text-min-distance | yes | none — there is no vt::Label to cull |
| placement along the line | per glyph, follows the run (buildLineVertexData) | whole run rotated by its segment's angle |
text-spacing: 0 (the default) | ONE label for the whole line | one at the midpoint of every segment |
| orientation | line / billboard-line / billboard-line-repeat / billboard / point | always flat — geometry has no camera basis |
| tile border | the label is placed across it | glyphs are cut, that is what clip means |
It used to default to text-allow-overlap (upstream, 2019, no rationale recorded), so a style
that only wanted its labels to overlap silently got the geometry path — and with it every row of that
table. It now defaults to false, its own declared default, and the same applies to
shield-clip. marker-clip is left coupled: an icon has no run to lay out and no name to place, and
marker-allow-overlap: true on a dense POI layer is exactly the case the geometry path is cheap for.
Under clip, billboard-line is treated as line — the run is rotated by its segment's angle rather
than left horizontal. Clipped text cannot be a billboard at all, and leaving it unrotated was the one
combination that neither followed the line nor stayed upright. billboard-line-repeat is the
exception: it has no run to follow the line with, so its repeats keep the style's own angle
(generateLinePoints(..., applyAngle = lineRun)), which is the closest the geometry path gets to a
billboard.
Over 3D terrain its glyph quads take the line shader's rule: the terrain is sampled at the
extruded corner (applyTerrain(pos + delta) in pointVsh), not at the anchor. A quad placed at
the anchor's height and then offset sideways is a flat plate, and on a slope its uphill half sits
under the ground, where the depth test against the terrain surface eats it — letters cut in half.
The line shader learned the same lesson earlier, on a route casing breaking up on a cross-slope.
That path shares the SDF encoding with labels and has to be kept in step with it: its antialias ramp
is 2 · GLYPH_SDF_UNIT · (glyphRenderSize − GLYPH_RENDER_SPREAD) / _fullResolution, the same rule
renderLabelBatch uses, and its halo reaches it in screen pixels like a label's. The
form written for the single 27-texel raster and a spread of 4 survived the raster ladder and the
spread change (4 → 8) and ended up measuring a ramp up to six screen pixels, which is a glyph
dissolving into its own halo.
Label size and the camera (fork-specific)
In a planar projection a label keeps a constant on-screen size: its world size comes from the
zoom alone, so the perspective divide would otherwise blow it up towards the camera on a tilted view
(and 3D terrain, which lifts anchors by a kilometre, made that obvious). calculateTerrainScaleFactor
cancels it by scaling the label with viewDepth / focusDepth.
focusDepth is the distance the zoom is calibrated at — the camera-to-focus distance, which
TileRenderer puts in ViewState::focusDistance for both the render and the cull pass. vt used to
guess it as the point where the view axis meets the z=0 plane; that is the same number only while
the focus sits ON the ground. Lift the viewpoint (free roam, a panorama from 2600 m) or aim at the
horizon and the guess runs away, shrinking every label on screen as the camera rises or flattens —
which is what "labels get smaller as I go up or pan" was. focusDistance 0 falls back to the old
guess, so a host that does not set it behaves as before.
How much of the divide is given back: labelPerspectiveScaling
Cancelling the divide entirely is not what the reference renderers do. maplibre and mapbox damp
it: symbol_sdf.vertex.glsl multiplies the glyph size by
clamp(0.5 + 0.5 * distance_ratio, 0.0, 4.0), so a label past the focus point does shrink — at half
the rate the projection alone would shrink it. VectorTileLayer::setLabelPerspectiveScaling is that
blend, 0 to 1, reaching vt as ViewState::labelPerspectiveScaling:
| value | factor | what a label at 3× the focus depth looks like |
|---|---|---|
0 | depth / focusDepth | full size — a constant on-screen size, what this SDK did before 6.1 |
0.5 (default) | 0.5 + 0.5 · depth / focusDepth | ⅔ size — maplibre's own damping |
1 | 1 | ⅓ size — fixed to the map, shrinking as fast as the geometry |
Two carve-outs. The damping applies only past the focus point (factor > 1); nearer than it the
same blend scales a label up, and a name lunging at the camera across the near half of a tilted
map is worse than the distant shrink is good — maplibre has this and we do not want it. And a
callout is exempt whatever the setting: it is a screen object whose whole point is to stay
readable at any range (see below).
This is not only a size. A line label must find textLength × factor of world line to sit on
(findClippedLinePlacement), so at 0 a road at 3× the focus depth was asked for 3× the road and
its name was dropped. Measured at Grenoble z15.57 tilt 59 in the style preview, massif-local drew
6 road names at 0 and 9 at 0.5, against maplibre's ~12 — so the damping recovers part of the
gap, not all of it. What is left is architectural: maplibre decides whether a name fits its line
once at tile build time, in tile space, with no camera in the question at all.
tools/style-preview forwards ?labelperspective= to the massif row, which is how that A/B is run.
A callout is sized differently: it is a screen object, so one glyph unit is size pixels taken
off the projection (calculateLabelScale → calculatePixelToWorld), not off the zoom. The
zoom-derived scale keeps a constant screen size only while the camera distance follows the zoom,
and free roam breaks that — lift the viewpoint or tilt and the names grow or shrink on screen.
Callout labels (fork-specific)
LabelOrientation::CALLOUT — style text-placement: callout — is a point label lifted away
from its anchor in screen space and joined back to it by a leader line. It exists because a
panorama is the case the ordinary rules answer badly: hundreds of summits within a few degrees of
the horizon, all wanting the same band of pixels, and hiding all but a handful of them loses exactly
the information the view is for.
What changes, and only for this orientation:
LabelCuller::placeCalloutLabelreplaces the hide. The label is placed at its band (text-callout-screen-anchor, a fraction of the screen height from the top; below 0 it stacks from its own anchor instead), and while the grid says it is taken it moves up onetext-callout-stepat a time, for at mosttext-callout-max-rowsrows. Everything else — the priority sort, thewasVisiblehysteresis, the shared grid — is untouched, so callouts collide with ordinary labels and with each other in the usual way.- The lift is in SCREEN PIXELS, and it stays there. Two things make that true, and both had to
be fixed before a row of names was a row:
- The conversion is read off the projection, not off the label's scale. One pixel is
depth / (projection scale × half the screen height)world units at the label's own depth (Label::calculatePixelToWorld). Converting with the label's scale instead — which comes from the zoom — makes the same stored lift mean a different number of pixels whenever the camera tilts, rises or zooms, so the labels slide up and down the screen between placement passes. - The anchor moves; the label does not follow it. Labels are re-anchored on the GL thread as
elevation tiles arrive (
updateElevation), and a tilt slides the anchor up or down the screen, while a placement pass only runs when the draw data changes. The culler therefore records the anchor's screen position along with the lift (setCalloutPlacement) and the draw path corrects by how far it has moved since (calculateCalloutLift), so the label holds its LINE rather than its distance from a summit that has meanwhile moved.
- The conversion is read off the projection, not off the label's scale. One pixel is
- The leader line is one more quad in the label's own vertex stream, textured from a 4×4 white
cell loaded into the glyph atlas (
TileLabel::Style::calloutLineGlyph). It is built per frame rather than cached with the text — its length is the offset, which changes with everything else on screen — and only once, after both text passes, since a halo copy would just draw it twice. Sampling the cell's interior matters: the outer texels blend into the atlas padding under linear filtering, which thins the line and fades its ends. - A callout has to fit on the screen ABOVE its feature, or it is dropped. Every other label is
evidence of its own visibility; this one is drawn where it is not anchored, so the culler measures
the lift against a constant
SCREEN_EDGE_MARGINshort of the top edge. The margin is a constant on purpose: it also caps a label the band placed correctly, and a margin proportional to the label's own height then pushes long names further down than short ones — the row stops being a row. A summit already so high in the frame that its name does not fit above it loses the name; the rows never descend belowtext-callout-offseteither. Pulling the lift back down to the cap instead (what this did before) put the label BELOW its own summit: off the band the style asks for, and at a negative lift with no leader line at all — which is what a panorama looked like whenever the ridges sat high in the frame. - Rotation (
text-orientation) stays with the CALLOUT placement instead of downgrading to POINT — angled names over a horizon is the whole look. - The grazing-view gate does not apply (
Label::isSurfaceFacingView). Every other orientation is laid out against the surface it is anchored on and is dropped once the view meets that surface edge-on (MIN_BILLBOARD_VIEW_NORMAL_DOTPRODUCT, 0.1 — no labels at all below ~6° of tilt). A callout faces the camera and is lifted along the camera up axis, so the angle says nothing about whether it can be read, and a panorama — tilt near 0, or negative when the camera looks above the horizon — is that view by definition. Before this, a peak-finder view lost every name as soon as it was levelled at the ridges.
Which point of the label is anchored. Two style properties, both naming a point of the label's
own box — center, left, right, top, bottom, top-left, top-right, bottom-left,
bottom-right — rotated with the text, so on a name tilted 55° the bottom-left corner is where
its first letter starts:
#mountain_peak {
text-callout-line-anchor: bottom-left; /* held over the summit; where the leader line ends */
text-callout-align: top-right; /* the point put on the band line */
}
text-callout-line-anchor moves the label so that point lands on the anchor's vertical
(Label::calculateCalloutShift, applied to the glyph offsets, the plate and the envelope alike) —
which is what keeps every leader line vertical while the text starts exactly above its summit.
text-callout-align only decides what the band line is measured against (LabelCuller reads that
point off the screen envelope by bilinear interpolation of its four corners). The pair is what the
two panorama looks are made of: names pinned to the top of the screen hang from their top-right
corner so the text stays under the edge, names in a band lower down line up on the same
bottom-left corner they are anchored by. Unset (the default) keeps the old behaviour: the text
laid out around its own anchor, the band measured against the bottom of the bounding box.
A second run of text (fork-specific)
A summit name and its elevation are one label with two type sizes. TextFormatter::Options carries
an optional second run, so it is laid out with the first one — one baseline, one bounding box,
one background plate, one colour:
#mountain_peak {
text-name: [name];
text-secondary-name: [ele]+'m';
text-secondary-scale: 0.62; /* of the main font size */
text-secondary-dx: 3; /* gap before it, pixels */
text-secondary-dy: 0; /* baseline shift, pixels, down positive */
}
TextFormatter::appendSecondaryRun lays the run out on its own (left aligned), scales its glyph
geometry, and rebases its CR pseudo-glyphs — which carry an absolute pen position, see
Label::buildPointVertexData — onto the end of the first run. Both runs then shift by the share of
the extra width the alignment asks for, so text-horizontal-alignment still applies to the pair.
The glyphs come from the same atlas raster as the main text, so a very small scale is a magnified
raster: this is for a suffix, not for a second paragraph.
Ranking with the view (fork-specific)
text-placement-priority is evaluated once, at decode time, from the feature. A panorama also wants
to rank by something only the frame knows — how far the summit is — so a label style may carry a
rank function, evaluated by the culler once per label per placement pass and added to the
priority:
#mountain_peak {
text-placement-priority: [ele]; /* the higher summit claims the row ... */
text-rank: 0 - [view::distance]/100; /* ... and the nearer of two equals wins it */
}
All three label symbolizers carry it — text-rank, shield-rank, marker-rank. Shields inherit the
property from TextSymbolizer and markers declare their own; both end up as TileLabel::Style::rankFunc,
so the culler reads one field whatever built the label (PointLabelStyle::rankFunc for a marker).
view::distance is metres from the camera to the label being ranked. It is defined only in this
evaluation: ViewState::labelDistance is 0 everywhere else, because the renderer evaluates a style
function once per batch (GLTileRenderer::renderLabelPass packs colour and size into a 16-slot
parameter table) and a per-label value there would cost a batch per label. Ranking never changes how
a label looks — only which of two colliding labels keeps its slot.
Two notes for style authors:
-
Prefer an expression that reads only the view state. It has no feature dependency, so
FloatFunctionProperty::getFunctionhands every feature the same function object and the label style is not rebuilt per feature. An expression that reads no view state at all is as good: it folds to a constant at decode, and two features answering alike share one function object — that holds for a style parameter read together with a field (text-rank: get([param::poi_rank], [class], 100)), which the property resolves at decode rather than keeping behind the store, since such a parameter can never be live anyway (GenericFunctionProperty::foldsStyleParams). Mixing the two — a field andview::distance— is what costs a closure and a label style per feature. -
0 - x, not-x: CartoCSS'sliteralrule accepts-as a first character, so a leading minus in front of a field is read as the literal string"-"and the declaration fails to parse. -
A callout keeps the row it holds. The offset is carried across label rebuilds (
snapPlacement— it belongs to the label, not to the tiles it was built from; without that, a rebuilt label drops onto its own anchor until the next placement pass, which is a whole screen of names jumping every time tiles stream in), and a label that was visible tries its previous row before any other.text-callout-persist: <passes>goes further: a name already on screen may fail placement that many consecutive passes before it is hidden, so it does not blink out and back in while the camera moves. Default 0 — hide on the first failure, as before. A held-over name may sit closer to its neighbours thantext-min-distanceallows, but never on top of one: a placement pass only runs when the draw data changes, so an overlap granted here would stay on screen until something else moved. It is held on the band's own line too, never at the lift it happened to have — a name kept off the row is what the row exists to avoid. -
The step's sign is the stacking direction. A negative
text-callout-stepstacks the rows DOWNWARDS, which is what a band pinned to the top of the screen needs: there is no room above it, so stepping up piles every row that loses its slot into the top edge — and since the ranking puts the nearest summits first, the effect reads as "the closer the label, the lower it sits". -
The group's minimum distance is tested at every row.
text-min-distanceis what thins a crowded ridge out, and testing it only after placement would put a label on a free row and then hide it for being too close to a neighbour — the one outcome the stacking exists to avoid.
Picking is unchanged and needs nothing new: GLTileRenderer::findLabelIntersections tests the
placed geometry, so a callout is clicked where it is drawn, at the end of its leader line.
Anchored shields: the name takes a free side (fork-specific)
A shield is ONE label whose glyph run is [icon glyphs] CR [text glyphs …]: the icon comes before
the first line break and the text after it, and buildPointVertexData resets the pen at that break.
Everything below rests on that split — the icon stays on the feature, only the text moves.
#poi {
shield-name: [name];
shield-file: url(shields/place.svg); /* a bitmap icon, as before */
shield-icon-name: '<PUA char>'; /* AND/OR a font icon: one glyph of an icon face */
shield-icon-face-name: 'osm';
shield-icon-size: 15; shield-icon-fill: #b5651d;
shield-anchors: 'right,left,top,bottom'; /* sides, in preference order */
shield-text-optional: true; /* no side free -> draw the icon alone */
shield-text-dx: 2; /* gap from the icon, MIRRORED per side */
}
-
The sides are precomputed, the choice is per pass.
TileLayerBuildermeasures the text once and stores oneTileLabel::Variantper anchor — avec2shift of the text pen and adrawTextflag. No extra glyph run, no extra formatting: the block is placed against the icon's edge, so the style's ownhorizontal-alignmentdoes not have to be mirrored, anddx/dyare re-applied as a gap along the anchor direction (a name pushed 2 px right of the icon is pushed 2 px LEFT on the left side). A style with neithershield-anchorsnorshield-text-optionalbuilds no variants and takes exactly the old path. -
LabelCuller::placeAnchoredLabeltries the side the label already holds first and then the style's order, taking the first free one — tangram'sdo { … } while (isOccluded() && nextAnchor())(labelManager.cpp), with their anchor set and order. Keeping the current side is what stops a name swapping sides under a moving camera; the exception is the icon-only variant, which is smaller than every other one and therefore always fits, so a label that fell back to it once would keep it for good and its name would never come back. It is never the preferred side. -
Along the side's own axis the text is placed against the icon's edge, and
dx/dybecome a gap pushed away from the icon. Across that axis it is centred on the anchor — a name above the icon has to sit over it, and the formatter's own alignment is derived from the sign ofdx, which means nothing oncedxis a gap. -
shield-text-radial-offsetplaces it mapbox's way instead, which is what a converted mapbox style wants: the distance runs from the anchor to the near edge of the text, on the side's own axis and zero across it, a corner taking it on the diagonal (evaluateVariableOffset,variable_text_anchor.ts). Read asdxinstead, the same style came out with the icon's half-width added to every side gap, and with a name centred under its icon sitting onedxto the right of it. The model lives inLabelVariants.hand is pinned bytests/vt/LabelAnchorAlignTest.cpp. -
shield-text-optionalappends a last variant that draws the icon alone. That is mapbox'stext-optional; here it costs one more variant, not a second label. It does not needshield-anchors— a list of one placement plus the icon-only fallback is a valid list, and it is the common case: no MapTiler style states a variable anchor, but 25 layers of outdoor-v4 statetext-optional.buildLabelVariantsused to return early on an empty anchor list and never reached the fallback, so those layers drew nothing where MapTiler drops the name and keeps the pin. -
The side is carried across rebuilds by
snapPlacement, so a label recreated by a tile-set change does not start at side 0 for one frame before the next pass moves it back. -
Placement is re-run when the camera zooms (
MapRenderer::viewChanged, ¼ of a zoom level). A pass is otherwise only asked for when the TILE SET changes, and a label's envelope is screen-space: zooming in makes room nothing notices. The pass is postponed rather than queued (VTLabelPlacementWorker::postpone), so a zoom gesture places once when it settles instead of re-deciding at every step — placing mid-gesture is what made labels fade in and straight back out. -
…and never while the last pass is still fading (
VTLabelPlacementWorker::MIN_PLACEMENT_INTERVAL), which is maplibre'sPlacement.stillRecent: it will not begin a placement beforecommitTime + fadeDuration. Every tile that arrives asks for a pass, so a pan at tilt 80 ran several a second while a label's fade takes a whole second at the default blending speed — the label never reached full opacity before the next pass could drop it. Measured on the Grenoble preview at z16/tilt 80: 10–14 labels flipped on and off per pass, 4–13 of them mid-screen, none of them losing a placement (offInvalid=0) — they lost a collision to a label that had just arrived.A/B over the same six-step pan, timing every pass:
passes / pan step shortest gap median gap gaps < 200 ms interval 0 10.7 0 ms 73 ms 48 of 62 interval 1000 4.5 999 ms 1005 ms 0 of 25 _lastPassTimeis stamped when a pass starts, under the lock that claims it. Stamped when it ended instead, aschedule()arriving while the pass ran read the previous pass's time, found the interval already spent, and fired again the moment it finished — 202 ms between passes, measured.The interval and the fade are ONE duration: 300 ms, which is maplibre's
fadeDurationand nowTileRenderer's default label blending speed (1/0.3 per second). A second of fade was long enough that a name was still arriving when it had reached the middle of the screen.
A label is placed BEFORE it reaches the screen
maplibre pads the collision viewport by viewportPadding = 100 px (collision_index.ts) and sizes
its grid width + 2 × padding, so a symbol is placed while it is still outside. Its own comment: the
padding exists "so that the bulk of the changes occur offscreen… increases label stability, but it's
expensive". A symbol placed there gets JointPlacement::skipFade, and OpacityState starts it at
opacity 1 instead of 0 — "if a subsequent viewport change brings them into view, they'll be fully
visible right away".
Both halves are ported. ViewState::labelFrustum is the frustum grown by labelPadding pixels, and
it is what Label::updatePlacement places against — the frustum tests AND the clip planes the point
and line searches use. Missing that last part is the whole difference: the padded frustum admitted
the label, then findClippedPointPlacement clipped it against the plain one and produced nothing, so
the band measured 0 labels until the clip planes were switched too. LabelCuller::isOffscreen then
hands anything placed outside the true viewport full opacity, and getGridIndex spans the padded
viewport so those labels get their own cells instead of clamping into the border ones.
The band shrinks with tilt, which maplibre's does not. 100 screen pixels at the horizon are
kilometres of map and thousands of labels; at top-down they are 100 pixels of map. labelPadding is
therefore 100 × sin(tilt), floored at 20 — tilt 90 is straight down here, so sin(tilt) IS the
foreshortening of the ground plane, and the band stays roughly constant in WORLD terms. Measured
live: padding=100.0 top-down, padding=50.0 at pitch 60, and 1–4 labels per pass taking the
skip-fade path.
The band is worth nothing without the tiles that fill it. A label cannot be placed early if it
does not exist, and it does not exist until its tile reaches the renderer. TileLayer sorted every
leaf tile into two buckets — inside the plain frustum it is a visible tile, otherwise a
preloading one, and preloading is off by default — so the first Label for the ground scrolling
in was built the frame its tile touched the viewport edge, on screen, at opacity 0.
There is now a third bucket between them: outside the viewport but inside
ViewState::getLabelFrustum(), the same band the culler places against, built from the same
vt::ViewState::paddedProjectionMatrix. Those tiles are fetched and handed over whatever
isPreloading() says.
They are handed over for their labels only. GLTileRenderer::setVisibleTiles takes a second
map: it joins labelTiles, so buildLabelMaps sees it, and it never reaches tileIds — no tile
surface, no render tile. TileRenderer::refreshTiles splits the draw datas on isPreloadingTile(),
which is exactly "the visTile was outside the view frustum", so the preloading ring stops being
drawn too. Skipping that split cost 4.3% of the frame rate (below).
Measured on the style preview (Grenoble, z16, top-down, 381×444 pane), counting every label that becomes visible on screen while still undrawn, bucketed by what it was on the previous pass:
| without the band tiles | with them | |
|---|---|---|
| passes with an on-screen entry | 85 | 0 |
| entries: never existed / had no placement / lost a collision | 165 / 81 / 33 | 0 / 0 / 0 |
| pan distance | 600 px | 230 px |
Normalised, ~46 undrawn entries per 100 px of pan against 0. The pan was very slow (the harness throttles timers), which is the condition that most favours the baseline: tiles had unlimited time to arrive and it still lost.
What the band costs, in tiles. Counted at rest, same pane, camera jumped rather than panned:
| camera (SDK tilt) | visible | label | preloading |
|---|---|---|---|
| z16, tilt 90, centred on a tile | 4 | 0 | 0 |
| z16, tilt 90, straddling two | 2 | 0 | 4 |
| z16, tilt 30, straddling | 12 | 1 | 5 |
| z13.5, tilt 30, straddling | 16 | 0 | 6 |
| z13.5, tilt 90, straddling | 2 | 0 | 2 |
At rest the band costs 0–1 tiles. It fills only while the viewport crosses a tile edge, and
during the pan above it held 2–3 against 2–4 visible. Those are tiles the pan is about to need
anyway, so the fetch is pulled forward rather than added; the only wasted work is a camera that
enters the band and turns back. The upper bound is geometric: 100 px on each side of a ~888 px
viewport is ~1.2× the visible ground at top-down, and less when tilted, since labelPadding scales
with sin(tilt) (tilt 30 above: padding 50, one extra tile on twelve).
What it costs on the device, and why the labels-only split is the whole point. Crosscall
1cba1468, -PnativeConfig=Release -PabiFilters=arm64-v8a -PprofileRender, default bench camera
(Grenoble z16.22 tilt 26), 8 swipes, three interleaved pairs from prebuilt APKs, idle windows
(>1600 ms) dropped. Two series, each against its own baseline — the device drifts between series, so
only within-series pairs mean anything.
| band tiles drawn | band tiles labels-only | |
|---|---|---|
| fps vs its baseline | 18.7 vs 19.5 (−4.3%, 3/3 same sign) | 18.9 vs 18.8 (+0.4%, mixed sign) |
renderTiles /s | +14.9% | −3.2% |
geomDraws /s | +2.6% | −10.3% |
bakes /s | +3.0% | −16.9% |
live labels | +61% | +88% |
labelMapsMs /s | +37% | +24% (2.8 → 3.5) |
cullMs /s | +5% | +3% (2.4 → 2.5) |
The labels are nearly free; the drawing was the cost. Twice as many live labels move cullMs by
0.1 ms/s and labelMapsMs by 0.7 ms/s against a 53 ms frame. Drawing the band cost 15% more render
tiles that are, by construction, off screen — which showed up as drape +1.7 ms, prelude +0.5,
cover +0.4 per frame. Routing them to buildLabelMaps alone takes the whole regression back, and
renderTiles ends up slightly below baseline because the preloading ring stopped being drawn with
them.
Which labels keep their text. The culler sorts by priority → wasVisible → layer index → size →
opacity and inserts greedily, so a label sorted earlier claims its side first;
shield-placement-priority is the knob for "these names matter more than those", and a bigger
shield-size sorts earlier among equal priorities.
Insertion is greedy and single-pass: a label takes the first of its sides that is free, and a name placed there may leave a neighbour's icon nowhere to go — the neighbour is hidden. That is what mapbox does, and it is what makes a dense POI field readable: names win space from icons.
Two alternatives were tried and reverted, both on the device and both worth not re-deriving:
- Yielding up front — before taking a side, a label that can still shrink tests it against the smallest layout of every equal-priority label after it, and drops to its icon rather than cost that label its place. Correct on two labels; on two thousand every name overlaps some neighbour's icon, so every name yields and the map shows icons only. It also cost ~25% more culler time and raised visibility flips per pass from 4.4 to 6.9.
- Two passes — commit every label at its smallest layout, then grow each back where the screen is still free. No collapse, and the same cost as greedy (14.5 vs 15.4 ms per pass), but pass 1 keeps every icon that fits, so the dense camera showed ~18 names where greedy shows ~60. Icons are not worth that much.
So a name still displaces a lower-ranked icon, and shield-placement-priority is the only lever over
who wins.
Justifying a wrapped name (fork-specific)
shield-text-horizontal-alignment: 'left' | 'middle' | 'right' | 'auto' justifies the LINES of a
wrapped name inside its block. auto follows the side the culler chose — flush left when the name
is to the right of its icon, flush right when it is to the left — and an explicit value is mirrored
the same way. Unset keeps every line centred, which is what the formatter always did.
It costs no second glyph run: the formatter still centres each line, and Label measures each
line's ink extent once (measureTextLines) and shifts the pen per line at draw time
(calculateLineShift). Single-line labels — nearly all of them — shift by zero.
This is also what fixes "the gap looks bigger on one side": for a single-line name the gap is symmetric by construction (the text is placed against the icon's edge, and it measures the same 17 px either way on screen), but a centred short line inside a wider block sits away from the icon, and only justification can close that.
Cost of the sides. All sides share the placement, the scale and the label's screen axes, so
Label::calculateVariantEnvelopes builds all of their envelopes in one call and the culler only
repeats the cheap part (project + grid test) per side. Measured on the emulator with a deliberately
extreme style (every POI anchored, ~2300 live labels, 4 sides + icon-only): 20.8 ms per culler
pass against 16.3 ms with the property unset, and the frame time moves by under 1 ms — which is
the extra labels text-optional lets through, not the placement. Building one envelope per side
instead cost 27.8 ms per pass, so the shared setup is worth keeping. A style that does not use
shield-anchors measures identical to before. The pass runs on the placement worker; no frame
section shows it, which is why RenderStats::cullerNs (cullMs= in the RenderStats: line) exists.
Font icons
shield-icon-name is a run of glyphs from an icon face, drawn before the text with its own colour
(shield-icon-fill, a third style slot next to the halo and the second text run) and its own size.
It is not a bitmap: the glyphs are SDF like the text, so they stay sharp at any zoom and cost one
atlas cell each.
The face has to be reached through the label font — getFont(labelFont->getName(), iconFace) —
because FontManagerFont::shapeGlyphs rasterizes a fallback's glyphs into the atlas of the font it
was called on, and one label can only be drawn from one atlas. Resolving the icon face on its own
and shaping with it gives glyphs in a different atlas and the label renders nothing. The face is
also re-requested at the render size the ICON is drawn at, not the text's, so a large icon next to
small text is not a magnified small raster.
A shield may carry both: the bitmap (shield-file) is the first prefix glyph and the icon run
follows it, so they sit side by side rather than on top of each other.
The icon run has its own antialias ramp
The icon run keeps a fixed PIXEL size of its own — Label::calculateVertexData scales it by
iconRefSize / size and then by iconScaleFunc, so it does not ride the text's size ramp. The
antialias ramp has to follow it: labelVsh reads uSDFRamp / uWidthTable[styleIndex], so an
icon sharing the text's style slot is smoothed as if it were the text's size.
renderLabelPass therefore gives the icon run a slot of its own whenever that ramp would differ,
not only when the icon has a colour, and puts the icon's own em size in the width table. Sharing the
text's, a 20 px POI icon was smoothed with a 13 px ramp — half again too wide — and every thin
stroke fattened until it closed: a fork's tines fused into one blob.
The remedy is only half of it. What the ramp cannot invent is resolution: a notch one texel wide
never reaches full coverage and closes at the size the icon is drawn. mapbox2css takes the densest
sprite sheet the provider serves (@4x for MapBox) for the same reason.
Plates behind the text and behind the icon (fork-specific)
A label may carry two plates — one behind the text, one behind the icon run — each a rounded rectangle with its own colour, corner radius, padding and border:
#road_label {
text-background-fill: #ffffff; text-background-opacity: 0.85; /* also on a shield: */
text-background-radius: 3; /* shield-background-* */
text-background-padding-x: 4; text-background-padding-y: 2; /* shield-icon-background-* */
text-background-border-fill: #444444; text-background-border-width: 1;
}
- A plate is nine-sliced from one atlas cell (
LabelPlateBitmap.h, cached by radius and border width, the glyph map dedupes by pointer): four corner cells keep the radius, the edges stretch along one axis and the centre fills — which is what keeps the corners round at any name width. - Fill and border come out of that one cell, in one quad: the cell's
rchannel is the fill's coverage and itsathe whole plate's, so the ring between them is the border, andlabelFsh(draw modePLATE) mixes the two colours before the single blend —vColor * r + vBorderColor * (a - r). The border's colour is the batch slot right after the fill's (LabelPlateIndices), which is why a reused pair has to be consecutive. Drawing the border as a second quad behind the fill is what this replaced: both quads carry the label's own alpha, so fill-over-border leavesborder * a * (1 - a)showing through whereveralpha < 1— a yellow shield with a black border went dark for the length of its fade-in, and a translucenttext-background-opacitystayed dark for good. Punching the fill's shape out of the border cell (the ring) fixes the middle of the plate but not the seam where the two meet: any overlap darkens, and ending the ring exactly on the fill's edge leaves an antialiasing hole instead. One quad is the only form with neither. - The cell is drawn at 4 texels per style pixel (
PLATE_SUPERSAMPLE) and the quad is snapped to that same grid (PlateCell::radius/borderWidth, carried on the plate). A border thinner than a quarter pixel keeps a texel rather than rounding to none — rounded away it left a cell with no ring at all, i.e. a filled plate behind the fill, which is the darkening above. - Both plates are part of what the label covers, so
calculatePlatedBBoxgrows the envelope by their padding and border — the culler tests what is actually drawn, and a callout's leader line ends outside the plate rather than inside it. The box is in glyph units, so the padding is converted with1 / sizeand not with the world-units-per-pixelscale / sizethe drawn plate uses (appendPlateadds it after multiplying the box byscale). Getting that wrong grows the padding by a further factor ofscale— tens to hundreds for a callout, which keeps its pixel size by takingscaleoff the projection: labels then reserve far more screen than they cover (a panorama loses half its names to collisions) and the leader line stops short of the plate by that same amount. - Each colour is one slot in the label batch, like the halo (
LabelBatchParameters::MAX_PARAMETERSis 16; a style using both plates with borders takes 4 of them plus text, halo, secondary and icon). The quad count halved with the single-quad form: a bordered plate is one nine-slice, not two.
Cost, measured on the emulator with a plate behind the name AND behind the icon on ~2100 labels: vertex build 4.6 → 5.2 µs per label per frame (+13%) and batch time 12.8 → 17.8 µs per label (+39%), i.e. about +1 ms per frame at 2100 plated labels, and no change in draw-call count (≈60/s either way). It is proportional to the plated labels on screen: each plate is a handful of quads built in the same loop as the glyph quads, so a screen of road shields — tens, not thousands — is far below what the frame-time noise on this emulator can even resolve. (Measured when a plate was three quads and a border was a second plate; it is a nine-slice and one quad now, so read it as an order of magnitude, not a current number.)
Max distance (fork-specific)
A label glyph is screen-space: a street name 5 km away is drawn at the same size as one 50 m away,
so a tilted view fills its horizon band with labels nobody can read. Which labels a tile carries is
already decided by the style at the tile's zoom (TileReader sets adjustedZoom = tileId.zoom + bias), and coarser far tiles — see 02-tiles.md — thin them out a lot. The second
half of it is how far the labels that do exist may be seen:
#transportation_name { text-max-distance: 2000; } /* meters; 0 (default) = no limit */
#poi { marker-max-distance: 800; }
#shield { shield-max-distance: 1500; }
It lives on the label style (TileLabel::Style::maxDistance), so it costs one comparison per
label per placement pass: LabelCuller::process measures the label's world anchor against
ViewState::origin and calls setVisible(false) beyond the limit. Hiding rather than skipping is
deliberate — the GL thread already animates opacity toward isVisible(), so the label fades
out when it passes the limit and fades back in when it returns, with no per-frame work.
Metres are converted with the mercator stretch at the view's own latitude
(VTLabelPlacementWorker), because 1/cos(45°) is a factor of 1.4 — too much to ignore in a number
a style author writes in metres. Tangram has no equivalent: their only control is the same per-tile
zoom filter.
...and a cut that is always on, in camera-relative units
text-max-distance is opt-in, so a style that does not set it caps nothing — measured at Paris
z16.5, tilting from 80 to 30 took live labels from 950 to 5001 and the culler from 59 to 668 ms a
second (performance log 27). Both GL-JS renderers cut unconditionally
instead, and not in metres:
| constant | cuts past | |
|---|---|---|
mapbox minPerspectiveRatio | 0.55 | 10 × camera-to-centre |
maplibre perspectiveRatioCutoff | 0.6 | 5 × camera-to-centre |
vt::LabelDistance ports maplibre's, the tighter of the two, as
ratio = 0.5 + 0.5 · (cameraToCentre / distance). The unit is the point. Camera-to-centre
shrinks with zoom and grows with pitch, so one constant holds everywhere; a metre cap cuts a city
block at z18 and nothing at all at z10. Measured: at tilt 80 it cuts nothing (the view never
reaches 5× the centre distance looking down, so it costs nothing and removes nothing), at tilt 30 it
drops 29% of labels before they cost a placement.
It runs before updatePlacement and the variant envelopes on purpose — those are the per-label
cost, and the horizon band is where most of the labels are. It bounds the cost of placing labels,
not the number of them: labelsLive is unchanged, because the labels are still built by
buildLabelMaps upstream. Tangram is the one that attacks the count, by colliding each tile against
itself on the tile worker at style-zoom + 2 and marking the losers State::dead for good
(LabelCollider::process, plus a hard 4096-label cap per tile) — neither GL-JS renderer has that.
The pass itself is rationed
The cut bounds what each label costs; the budget bounds what a pass costs. Splitting
LabelCuller::process shows where to cut: collect (updatePlacement + the variant envelopes)
is ~90% of it at ~14.6 us a label, the sort ~2%, the grid insertion the rest. Collect runs before
the sort, so stopping it short costs no ordering among the labels that were collected.
beginSlice(ms) opens a wall-clock budget shared by every process call in a pass; each layer
keeps a cursor (GLTileRenderer::_labelCullCursor) and resumes there next pass. This is mapbox's
PauseablePlacement — 2 ms, three cursors, nothing published until the cycle commits. Two things
have to hold across a cycle, and both bit in the writing:
- the collision grid persists, or the second half of the labels reuse slots the first half took;
- the view is frozen, or the second half is collided against a grid built for another screen.
The pacing is the other half of the guarantee, and it is a duty-cycle gate rather than a fixed
delay: after a pass costing C ms, no pass may start for C · (1000/TARGET − 1) ms. That holds
placement to TARGET (90) ms of every second whatever the tilt, the cycle size, or how often the
camera asks — a cap, not a quota, so a still map spends none of it. A fixed delay was tried first
and does not work: it bounds one cycle, and once placement got cheap enough to run whole it simply
ran more often, back to 120 ms/s (performance log 29). Measured worst cases
are 62 ms/s at tilt 30 and 57 at tilt 80, against a 100 ms objective.
The cut is applied in TWO places, and the second is the one that matters. In the culler it can only
HIDE a label, and it reads the label's placement - which an unplaced label does not have, so the
~9000 unplaced labels a second bypassed it. In Label::updatePlacement, next to the existing
frustum reject and measured off the geometry bbox, it stops the placement SEARCH instead: 12 295
searches a second became ~250 (performance log 29). A frustum does not
bound distance; pitched at the horizon it reaches kilometres.
Rationing is only worth it when there is something to ration. Slicing a cheap cycle costs more
than it saves — each slice re-sorts and re-inserts its own subset, and the pacing stretches it over
many passes; applied unconditionally it took the culler from 59 to 75 ms/s at high tilt. Both
references have a bypass (mapbox isFullPlacementRequested / fadeDuration == 0, maplibre
_forceFullPlacement); ours is self-tuning rather than tilt-aware — a cycle is timed, and the next
one is rationed only if the last exceeded FULL_PLACEMENT_MS. Looking down, a cycle is ~10 ms and
runs whole; pitched, it is 60-100 ms and gets sliced.
What it costs while rationed: a cycle takes several hundred ms, so labels settle behind a fast camera rather than with it (they keep their previous visibility meanwhile, so nothing flickers), and the placement SELECTION shifts, since an early slice claims grid slots before a later, higher-priority one. mapbox has the same property.
Geometry gets no such cut, in any of the three. The cutoff lives only in symbol/collision_index.ts
and the only thing that reads a collision index is symbol placement; fills, lines and extrusions
never go near it. Distant buildings keep drawing at any pitch. The asymmetry is the whole point —
a building shrinks with perspective and a glyph does not.
Font names (fork-specific)
A face name is a CSS-like list, most preferred first, and an entry may say which platform it is
for. vt::parseFontNames (FontNames.cpp) is the one
parser; TextSymbolizer::getFont runs it over text-face-name and over every name of a font set,
so a mapnik font set and a single comma-separated name end up as the same chain:
#poi {
text-face-name: "Roboto, Helvetica Neue, sans-serif"; /* one string */
text-face-name: "android:Roboto", "ios:Helvetica Neue", "Arial"; /* or a real CSS list */
}
Names are tried back to front, so the first one that resolves becomes the main font and the rest its
glyph fallbacks; a list where nothing resolves keeps the style's fallback font. android:, ios:,
macos: and windows: are the tags — an entry tagged for another platform is dropped at parse
time, an untagged entry is kept everywhere.
The system loader has to be strict for this to mean anything. SystemFontUtils::LoadFont takes
an allowFallback flag: MBVectorTileDecoder installs it as the FontManager's data loader with
false, so a name the device has no font for fails and the chain moves on. With the permissive
behaviour the first entry always resolved — to the platform default — and entries 2..n were dead
code. The style's fallback font (Arial, resolved permissively, outside the loader) still catches a
chain where nothing matched, so an inline CartoCSS style with no font of its own is unchanged.
Resolution is cached both ways in FontManager (_fontDataMap for a hit, _missingFontSet for a
miss), so a list costs one lookup per name once per style load, not per tile. That is why there is
no separate per-platform option — the tag inside the list is enough and does not repeat work.
Vector elements do not use this path. BalloonPopup, Text and the popup buttons draw into a
BitmapCanvas with the platform text API (Android Typeface, iOS CoreText, UWP DirectWrite), not
FreeType — none of the alias mapping applied to them before, so Typeface.create silently returned
the default for every name and no font name a style set had any visible effect.
BitmapCanvas::setFont now runs the list through SystemFontUtils::MatchFont, which resolves it
once (cached) to what the platform accepts:
- Android: an alias table (
FONT_ALIASES) maps the name to a family (sans-serif,sans-serif-light,serif,monospace) — preferred over a font file, because a family keeps the per-script fallback chain a single.ttfdoes not. Only a font Android has no family name for falls back toTypeface.createFromFile, cached, since it re-parses the file on every call. - iOS/UWP: the name is checked against CoreText / the DirectWrite collection before use, because both substitute the default font instead of failing.
One visible consequence: the default popup face HelveticaNeue-Light now maps to sans-serif-light
on Android instead of rendering as plain Roboto Regular.
Bold and italic are asked for by NAME, and only the shortfall is drawn on
parseFontStyle reads the weight and the slant out of the face NAME ("DIN Pro Bold" → 700,
"… Medium Italic" → 500 + italic), because Android reaches Roboto's bold and italic through
variable-font axes rather than separate files. applyVariationAxes sets whatever axis the face has
(wght, wdth, ital, slnt), and whatever is left is synthesised per glyph —
FT_Outline_Embolden and a shear matrix in addFreeTypeGlyph.
A static face that is ALREADY the style asked for has no axis to satisfy the request, so the request
survived to the synthesis step and was drawn on top of it. A style shipping real
DINPro-Bold.woff2 and DINPro-MediumItalic.woff2 files — which the bundled one does, matched by
normalizeFontName turning both "DIN Pro Bold" and DINPro-Bold into dinprobold — got double
bold and a real italic sheared a second time over its own slant. Measured on the eink style: 32-38%
more ink in a label than it should have had.
subtractFaceStyle takes the face's own style off the request first. The weight comes from OS/2
usWeightClass, not from FT_STYLE_FLAG_BOLD, because the flag only knows bold and a Medium file
is 500; only the difference is emboldened, and only upwards, since an outline cannot be thinned.
On the GL thread
-
Fades.
updateLabelmoves opacity towardvisible ? 1 : 0; invisible-but-fading labels stay rendered until they reach 0. Over terrain it also fades a label out when its anchor is behind a ridge — a stale occlusion buffer queried with the wrong camera is what made labels blink while zooming, see 04-terrain.md. -
Occlusion by 3D content. The ridge test above is a CPU query per label against a read-back terrain depth buffer, and terrain is all it knows: a label behind a building was drawn in front of it. The extra test is mapbox's, ported by semantics: the occluders are drawn once more into a half-resolution buffer from the same camera, and
labelVshsamples a small square around the label's anchor and fades the whole label by how much of that square is in front of it. It is never clipped — a wall crossing a word does nothing, and a word is never cut in half. See Per-label occlusion below. -
Terrain anchoring. Label geometry is built flat at decode time, so a label must be re-anchored onto the terrain: once when it is new, and afterwards only when the elevation under one of its tiles changed.
invalidateLabelElevation(tileIds)marks exactly those; the blanket version exists for whole-data-set changes. Anchoring costs one elevation sample per label vertex, and a whole screen of labels goes dirty at once while elevation streams in. It still has to run to completion: a label left dirty is drawn and culled at its old height, which reads as labels popping in at the wrong place and settling. This is the most expensive thing on the render thread over 3D terrain — measured at ~750 000 elevation samples per frame on the north pan with a full style, 82% of the render thread. Two faults made it that:TileRendererclassified every arriving DEM tile as a scale-only change and took the blanket path (see 04-terrain.md), and each sample re-derived its tile and its latitude scale from scratch. Two more, found on the day-cycle-light example at 2 fps with a city of bridges (2026-09-03): every sample askedspanHeightAtwhether the vertex stands on a deck, against every span UNION — hundreds in a city, most of them the same chord — so one label cost milliseconds; the unions are now folded into a deduplicated chord list with bounds (_spanChords), 1.5–2.8 s of anchoring per twelve-second pan down to 65–140 ms. And the anchoring of a new tile set's labels no longer waits for the next frame:setVisibleTilessamples them on the cull thread with the renderer's lock released (Label::sampleElevation) and writes them back under it (applyElevation); the frame keeps only the DEM-tile re-anchors.debug.massif.labelanchor 0restores the in-frame path for an A/B — on the emulator it was 19–31preparespikes of 130–190 ms per pan against none. A label that has already been anchored and is neither placed nor on screen then defers its re-anchor (Label::isElevationDirty): it keeps the heights it has, at worst one LOD step out, and reports itself dirty again the moment the culler gives it a placement. A label that has never been anchored never defers — its geometry is still flat, and placing it at sea level under a mountain is what makes labels pop. That deferral measured +6% and no more, because most dirty labels do hold a placement. The bigger cut was the vertex count itself. A label's line went through the same terrain tesselation as a drawn line — including the lattice split, which cuts a segment at every surface cell edge and diagonal so that a painted line stays inside one triangle. A label line is never painted: it is read, to place glyphs and to anchor them. It now takesTileTransformer::tesselateLabelLineString, which halves to the surface cell and skips the lattice split — the profile a glyph run follows cannot carry more detail than the surface it is laid on.prepare157 → 72 ms, 1.75 → 2.10 fps. The bound was measured first withdebug.massif.linesourcedensity 1(no line subdivision at all):prepare154 → 68, so the label path gives up almost nothing by keeping the surface-cell step. Visible cost: a glyph run can shift a few pixels along its line, and it may sag by the surface's own chord error where a segment crosses a cell diagonal. Contour labels — the most sensitive class, laid along a line the whole way — still track their line at the ridge camera. What remains is genuine volume: a changed DEM tile at z12 intersects every label tile beneath it, so "targeted" still means most of the screen. -
A halo is an OUTLINE, not a backdrop: the glyph's own coverage is punched out of it. The halo is still its own quad, drawn before all of the label's ink (that order is what stops one glyph's halo covering its neighbour — tangram's
u_passdoes the same), butlabelFshsubtracts the glyph's coverage from the halo's, so the two are disjoint and the ink never composites over a second copy of itself. With a solid halo underneath, ink-over-halo leaveshalo * a * (1 - a)whereveralpha < 1: a yellow road shield with a darkshield-icon-halo-fillwent dark for the length of its fade-in, and a translucenttext-fillstayed dark for good. Same rule, and the same reason, as a plate's border above; mapbox and tangram both carry the artifact. Consequence worth knowing: a style that set a transparent fill and an opaque halo used to get a SOLID blob and now gets a hollow outline — which is what "halo" means. Text drawn as geometry (text-clip) lays its halo down as a separate pass too (TileLayerBuilder, pass 0 oftesselateGlyph), andpointFshpunches the ink out the same way. -
Halo width is measured in antialias ramps, not in glyph texels.
labelFshshifts the coverage ramp outward by the halo, and the ramp is one screen pixel of signed distance, so a halo reaching the renderer in screen pixels is as wide as the style asks whatever raster the label landed on. It used to be converted with the glyph's RENDER size instead — harmless while every glyph was rastered at 27 texels, wrong the moment the raster ladder (16/28/40, see 10-performance.md) made that size depend on the label. The factor isrenderSize / (renderSize - spread): 3.0 on the smallest raster against 1.2 on the largest, so onetext-halo-radiusdrew a halo two and a half times wider on a small label than on a large one, and up to five times what the single-raster build drew — a soft white glow instead of an outline, reported as "halo huge at radius 2, fine at 1". If halos ever look wrong again, check that term before the style. -
text-halo-radiusis a STYLE pixel, liketext-size— the symbolizer multiplies it by the pixel scale, and onlyMAX_HALO_PIXELS(where the encoded field runs out) caps it. It did not used to be: a back-compat factor ofHALO_RADIUS_SCALE · HALO_PIXELS_PER_UNIT= 1.4725 handed the renderer device pixels, so a halo kept its absolute width as the dpi rose while its text grew, and shrank against its own glyphs.text-size,text-dxandtext-dynever had the problem — they reach the renderer in ems, which scale with the label. Measured at La Clusaz on a 2.625x screen,text-halo-radius: 1.2: 1.8 px before, 3.1 px after, against the 3.2 px MapBox draws fortext-halo-width: 1.2.text-min-distancecarried the same defect and was fixed with it. This widens every existing style's halos on a hi-dpi screen (×1.8 at 2.625, ×2.0 at 3). -
A style
transformno longer ends the batch. The translate was a factor of the batch'slabelMatrix, and the batch broke both when a label carried one and when the previous label did — so every transformed label was a draw of its own, 30 of a city frame's 68 label draws. Conjugated by the tile matrix the translate is a pure world translation, so it is added to that label's vertices aftercalculateVertexDatainstead, and the label batches with its neighbours. Measured on the 2D city pan: 68.4 → 38.7 label draws a frame,labels2D4.89 → 4.24 ms, CPU frame 45.0 → 43.2. It also fixes a latent bug: several same-style labels did share a batch, and the matrix was built from the first label's tile, so the rest were translated by the wrong tile's frame. -
One glyph atlas shared by every font at a render size — measured and dropped.
FontManagerkeys_glyphMapMapby the full font name, so every (family, size) pair owns an atlas and the 16/28/40 ladder makes ~32 of them on a city screen; a batch ends whenever the atlas changes, which is the other half of the label draws. Keying by render size instead took the draws to 32.9 and bought no frame rate, and it makes overflow a live risk: a 2048² atlas is ~1764 cells at render size 40 against ~200 glyphs per family, andGlyphMap::loadBitmapGlyphreturns 0 when full — glyphs vanish silently. Not worth a silent-text-loss failure mode for an unmeasurable gain. If it is retried, widen the atlas first and measure the fill. -
The glyph atlas can be replaced under the batch loop.
GlyphMap::getBitmapPattern()returns the pattern by value, and a tile thread loading a new glyph resets_bitmapPattern— so the render thread's temporary can be the last owner.renderLabelPassbound itslabelBitmapreference through that temporary's->, which extends nothing: the pattern died at the end of the line and the batch then compared and copied a freedshared_ptr. Crashed about half of the startups at a label-dense city camera, as either a nullbitmapinrenderLabelBatchor aScudo ERROR: invalid chunk state. Hold the pattern in a namedshared_ptrfor the iteration. -
Vertex data. The glyph layout is already cached per label (
_cachedVertices,_cachedValid) and the shader already expands the billboard fromoffsets+uLabelAxisX/Y. What runs every frame is re-emitting the batch: the anchor is camera-relative (placement->position − viewState.origin), so every vertex changes as soon as the camera moves.Measured on the city pan with draping on (Crosscall, 5.724/45.188 z15 t45, 27 fps, 28 frames per interval):
pass3D labels3D82.9 ms/interval (2.96 ms/frame),labels2D46.3 (1.65),buildMs45.7 (1.63),batchMs21.6 (0.77), 3693 labels rebuilt (132/frame). The build splits placement 4.4 / line 10.3 / transform 10.8 / attrib 13.9 ms per interval. That is ~4.6 ms of a 31.6 ms CPU frame, against a 37 ms wall frame and the 33.3 ms two-vsync boundary — the right size to matter, which is why it was tried.Caching the two per-glyph loops does NOT help (tried 2026-08-13, reverted): keeping the scaled
offsetsand the per-frameattribsper label, keyed on scale / camera axes / style slots / opacity, measured 26.6–27.4 fps against 27.1, withtransformMsandattribMsunchanged. Two reasons, and the first is visible in the code:scalecarriescalculateTerrainScaleFactor=depth / focusDistance, which changes past its 1% quantum for most labels on every panned frame, so the cache misses; and copying N cached entries into the batch writes the same bytes the loop did, so a source-side cache cannot win if the cost is the batch write.What is left is the batch itself — and it has a prerequisite that has to come first.
A persistent label batch, and what blocks it
The batch can only be kept across frames if nothing in it changes. Two things do:
- The anchor,
placement->position − viewState.origin, moves with the camera. Solvable: quantize the origin to a grid and put it inlabelBatchParams.labelMatrix(which already carries atranslate). Absolute world coordinates are not an option —WORLD_SIZEis 2²⁰, so float32 jitters ~2 m at the world edge, which is why the camera-relative form exists. - The scale, and this is the real blocker.
offsetsholdcachedVertex * scale, andscalecarriescalculateTerrainScaleFactor=depth / focusDistance, quantized to ~1.09% steps. At z15 the view is ~1 km wide and a pan moves ~0.5 km/s, so a label 500 m out changes depth by ~18 m per frame ≈ 3.6% — past the quantum. Most labels change scale on most panned frames.
So a persistent buffer, or any cache of the scaled offsets, rebuilds constantly — which is what the
reverted experiment above measured. The prerequisite is to stop scaling on the CPU: the perspective
cancel is a division by view depth, and the vertex shader can do it from gl_Position.w, which is
tangram's screen-space label model. CPU offsets then hold glyph units × zoom scale, constant through
a pan, and the batch becomes reusable.
The order to do it in, then: (1) move the perspective cancel into labelVsh, keeping
calculateTerrainScaleFactor on the CPU for the culler's envelopes only (collision is decided in
screen space and must not change); (2) cache the offsets, which step 1 has made view-independent;
(3) quantize the batch anchor and keep the batch arrays and GL buffers across frames, invalidated by
the label set, placements, opacities, style slots and the anchor.
Step 1 was done, then reverted with step 3 (see Reverted at
the end of this section). Label::CAMERA_AXIS_DEPTH_OFFSET (attribs[3] = 2) told labelVsh to scale the
offset by clamp(anchorClip.w * uLabelDepthScale, 0.05, 8.0) — clip w is the view depth the CPU
factor was a ratio of, and uLabelDepthScale is 1 / camera-to-focus distance. The CPU then emits
offsets divided by that factor, so what is in the buffer depends on the zoom and the style, not on
where the camera is. It applies to BILLBOARD_3D and LINE_BILLBOARD_3D under a planar projection;
callouts keep the CPU factor (their lift and shift are measured against the same scale), and LINE
labels are view-dependent by construction.
It changes no frame rate on its own — 27.0–27.5 fps against a 27.1 baseline, and the per-glyph loop still runs — which is the expected result: it is the enabler, not the win. Device-checked: label sizes hold through tilt and pan. One cosmetic consequence: label plates still take the CPU factor, which is quantized to ~1.09% steps where the shader's is exact, so a plate can sit ~1% off its text.
Step 2 then measured nothing, and that settles the mechanism. With the offsets now
view-independent, caching them per label gave transformMs 10.3–11.4 ms/interval against a 9.6–10.8
baseline and 24.9–27.0 fps against 27.1 — reverted. Together with the first attempt (which missed
because the key moved), this rules out the source side entirely: the timed region is dominated by
writing into the batch arrays, and copying N cached entries writes exactly the bytes the loop
wrote. No cache of what goes into the batch can win.
Step 3, the batch kept across frames, was done and is now reverted — and there was a second view
dependency the plan above missed. Label::setupCoordinateSystem snaps the anchor to a quarter of the screen pixel
grid (that is what keeps glyphs at a stable subpixel phase), and it did so by projecting the anchor
with the view-projection and inverting it back. So the anchor moved on every camera translation
whatever the scale did — and it cost a 4x4 double inverse per label per frame, on the culler
thread too. Both are fixed: ViewState now carries viewProjMatrix/invViewProjMatrix, and for
the shader-cancel modes labelVsh does the snap itself on anchorClip.xy against
uLabelScreenSize. The CPU anchor is then placement->position − viewState.labelOrigin, where
labelOrigin is latched and only re-based once the camera has moved LABEL_ORIGIN_LATCH_DISTANCE
(256 internal units, ~10 km); the residual rides in the label matrix, which already carried a
translate. The culler keeps viewState.origin — its envelopes are camera-relative by construction.
What survives a frame, then, is the whole 3D pass: GLTileRenderer::LabelBatchCache holds one
PersistentLabelBatch per batch (its VBOs, its parameter tables, and the label matrix without the
camera), and a hit re-issues the draws with new uniforms only. Validity is two counters, not a
per-label scan:
Label::getDrawGeneration()— bumped by placement, layout and elevation changes, and by a label appearing or disappearing. Only 3D-orientation labels bump it, and only while they are actually drawn: measured over one city pan, 4184 re-anchors of unplaced labels against 24 of visible ones, so an unguarded counter is stale every frame for work no batch ever held.Label::getOpacityGeneration()— a fade in progress. It does not invalidate: the batch keeps a CPU copy of its attribs plus each label's vertex range, andpatchLabelBatchOpacitiesrewrites one byte per glyph of the labels that moved andglBufferSubDatas the dirty span.
Plus the view: zoom, rotation, tilt and planarProjection are baked into the buffers (the
glyph scale, and the style functions that read view::). Everything else the camera does —
position, focus distance, screen size — only reaches uniforms. That last one mattered: over
terrain focusDistance changes on every frame, and while it was in the test the cache never once
hit.
Measured (Crosscall, assets style, 5.724/45.188 z15 t45, scripted pan, debug.massif.labelcache
A/B): pass3D labels3DMs 67–76 → 25–33 ms/interval, labels rebuilt 3900 → 1900 per interval,
~200 batches a second replayed and ~80 patched. Frame rate: unchanged — three interleaved
rounds gave medians 26.4/26.0/25.6 fps with the cache against 26.4/25.8/25.8 without. The pass is
~1.5 ms of a ~38 ms frame, and this bench's run-to-run spread is larger than that. Screenshot diff
against the cache off is 1.38% where two runs with it ON differ by 1.06% — no systematic shift.
So it removes the work it was designed to remove and buys no frames at this camera. The label cost
that is left is 2D: labels2DMs is 48–94 ms/interval, dominated by LINE layout
(lineMs 28 of buildMs 44), which follows the projected line and cannot be kept — see
updateLineVertexData. Note also that only a style using text-placement: billboard puts
labels in the 3D pass at all; with the demo's inline style the 3D pass is 1.2 ms/interval and this
whole mechanism measures nothing.
Reverted: labels jump in the sky
Steps 1 and 3 shipped as mobile-sdk #83 / libs-massif #41 and were reverted the same day: reported on device (iOS, city camera) as labels jumping out of position, in 2D as well as 3D. Since the measured gain was zero frames, there was nothing to weigh against it. What the code review found, for whoever retries this:
- The generation is snapshotted too late.
renderFramesets_labelBatchCache.generation = Label::getDrawGeneration()after the batch is built. The culler runs on its own thread and bumps that counter during the build, so a placement change landing mid-build is swallowed and the batch stays stale until some unrelated label changes it. Snapshot before the loop. - Plates froze while their text kept scaling. Glyphs went out as
CAMERA_AXIS_DEPTH_OFFSETand are rescaled by the shader every frame;appendLabelPlatesemitsCAMERA_AXIS_OFFSETwith the build-timecalculateTerrainScaleFactorbaked in. Within one frame that is the ~1% quantum step 1 documented, but in a kept batch the plate scale never updates at all while the text does, over the whole 0.05–8 factor range. Plates have to move to the shader mode too, which means the callout lift and shift (measured against the same scale) move with them.
Neither is proven to be the reported jump — no repro was captured before the revert.
Against tangram
Tangram's labels are built into the tile's styled mesh at tile build time and placed by their own
LabelManager; ours are re-merged and re-placed from the live tile set. Their icon and its name are
two labels linked by setRelative, with optional deciding whether the parent survives the
child being occluded; a shield here is one label with several text layouts, so the anchor retry loop
is theirs but the object model is not (see
11-tangram-diff.md). The visible difference is
where the work happens: theirs is amortised into tile building, ours falls on tile-set changes during
panning. Their contour labels are generated from the elevation texture with no geometry at all,
which this fork now also does (07-hillshade-contours.md).
Per-label occlusion by 3D content
A label is occluded by the terrain on the CPU, per label, by a read-back depth buffer
(TerrainRenderer::isOccludedByTerrain, fed to vt as setLabelOcclusionTest). That buffer holds
terrain only, so a label standing behind a building was drawn straight over it.
The extra test is mapbox's model, ported by semantics (their text-occlusion-opacity /
icon-occlusion-opacity; the spec's own wording for the absent value is "full occlusion against
terrain only", which is exactly what this codebase already did):
MapRendererdraws every visible extrusion once more into a half-resolution buffer between the 2D and the 3D layer passes, from the same camera. It is the shadow caster path pointed at the camera instead of the sun, so the occluders are the extrusions as the frame draws them — same shader, same terrain anchoring, the tile's own fade — with their window depth packed into rgb.labelVshprojects the label's anchor, samples a 30 px square around it (4 taps), compares each against the anchor's depth through a soft ramp, and averages them into one opacity for the whole label.
The ground is deliberately not in the buffer: terrain occlusion is already answered on the CPU, per label, and drawing the surfaces here would only pay for it twice.
Per label, not per fragment. A depth test on the label pass is the obvious alternative and it is
wrong twice over: a building crossing a word cuts it in half, and — the reason it was abandoned — a
label's anchor sits ON the ground, so the ground eats it. Nearly every label on a terrain camera
disappeared. mapbox's small NDC offset (-0.0001, "to prevent coplanar symbol/geometry cases") plus
the multi-tap average is what makes the coplanar case a non-event.
Packed colour, not a depth texture. The first version attached a GL_DEPTH_COMPONENT24 texture
and sampled it with texture2D in the vertex stage. Every tap came back 1.0 on the emulator, with
the pass demonstrably drawing into the buffer. The packed-rgb encoding is what TerrainShadowMap
falls back to for the same reason, and it costs one dot per tap.
Cost: 0.80–0.90 ms of GPU on an Adreno 610 at a city camera (frame total 10.2 → 10.7 ms), and zero when nothing asks for it — the pass does not run. See the performance log.
Asking for it
TerrainOptions::setTextOcclusionOpacity(float) — the opacity an occluded label keeps. 1, the
default, is no occlusion at all and the pass does not run; 0 hides such a label completely. The
style's text-occlusion-opacity (a Map-block property) wins over it wherever it sets one, as
every other style/option pair here does (resolveTextOcclusionOpacity).
A style layer may set its own text-occlusion-opacity (a TextSymbolizer property, so
#roads { text-occlusion-opacity: 0; }), and that wins for its labels — a style can hide its road
names behind a building and leave its POI labels alone, which is mapbox's granularity. It also turns
the pass on by itself: hasStyledLabelOcclusion is noted while the pass label lists are rebuilt,
because that loop already touches every label once, and the owner needs the answer before the
layer passes.
So three scopes, each overriding the one above it: TerrainOptions → the style's Map block →
the style layer.
The name and the meaning are mapbox's. Theirs defaults to 0; ours defaults to 1 at every scope, because turning it on by default would spend ~0.85 ms of every frame for a map that may have no 3D content at all.
The per-label value rides on the batch, not on a per-style slot in the parameter tables: it is a property of the style layer, so consecutive labels share it, and a slot would have to join the colour/size key every label is matched on. A change of occluded opacity ends a batch the way a change of glyph atlas does.
- A layer whose
LabelRenderOrderis notLASTdraws its FLAT labels in the 2D pass, before the buffer for this frame exists, so it samples the previous frame's — one frame of lag while the camera moves. Its billboards follow the buildings (see below) and sample the current one. - The anchor decides for the whole label. A long line label whose anchor is in a gap between two buildings stays fully drawn; that is mapbox's behaviour too, and it is the price of never cutting a word.