Skip to main content

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.

The previous objects are released first, all those faded out to opacity 0 — except one shown and not yet faded in. A pass that has just placed a label leaves it visible at opacity 0 until the next frame, and a tile arriving in between released it: its rebuild started hidden and waited for the next pass, 300 ms or more (below), with every tile of a zoom out reopening that window. It is kept for one more rebuild, inactive, so the next one still finds it.

A point label whose id changed but whose anchor did not is carried over too (LabelSpot, same layer, the same 2^-22 cell (~10 m) or one next to it). The shield text is hashed into the id, and Massif draws most POIs icon-only below a label minzoom (shield-name: ''), so at the tile level a name appears the same POI came back under a new id: the old label faded out at once and the new one waited, hidden, for the next placement pass. A cycle costs 90-150 ms on the emulator and the duty-cycle gate below then waits 10x that, so the POIs on screen blinked out for 1-1.5 s at every level of a slow zoom out — measured in a 2D run at Grenoble 5.72722/45.19172 z17.5 -> 15.3: labels drawn 85 -> 34 -> 17 at the z17 -> z16 step, with 0-22 carried by id against 100-290 fresh. The icon keeps its state now; only the new text fades in, from the next pass.

The cell is not the id's 2^-28 one: an id-less feature (OpenFreeMap place, a city) is read from a tile's 4096 grid, which snaps one point ~2 m apart in a z11 and a z12 tile, so the exact cell missed.

A successor already built, hidden, is handed the old label's state when the old one goes inactive. Crossing z12 the z12 tile's labels are built while the z11 ones (a stand-in) still draw, so each new label exists under its own id, hidden, before the old one is dropped; the spot match above only runs for a label with no entry, never saw it, and the old one faded out alone for the 300 ms+ until the next pass placed its successor. commitLabelMaps now pairs each dropped visible label with a hidden new one at the same spot and moves visible, opacity, occlusion, placement and variant across, the name's opacity too when both draw one. Measured at Grenoble, zoom 11.6 -> 12.4 in 0.005 steps (-zoomStep 0.005, 120 frames, analyze-label-flicker.py): the blink events at z12.0 went from 98 (frames 82-84) to none; 21 visible labels were dropped at op 1.0 with a hidden successor 40 m or less away.

Where globalId comes from. The symbolizer builds it from the feature's id in the tile, xored with a hash of the symbolizer itself (Symbolizer::combineId), so one feature cut across several tiles yields one label. A tile whose features carry no id breaks that: MBVTFeatureDecoder returns 0 for all of them (it also accepts an id, osm_id or cartodb_id attribute), every point label in the layer lands on the same globalId, and the merge above collapses them into a single label — two POIs a few metres apart draw as one, with whichever the tile decoded first supplying the icon and text, and the winner flipping as tiles rebuild.

For a point label with no feature id, and for every point of a MultiPoint, the anchor is folded in instead (Symbolizer::combineAnchorId, used by the markers, text and shield symbolizers): the anchor is quantised to 2^-28 of the world, ~0.15 m at the equator. That is bit-identical across overzoom levels of one source tile and across a tile-border duplicate, so a point still merges with itself, while two distinct POIs stay distinct. It does not survive a change of source zoom — a z13 and a z14 tile snap the same POI to different cells — so during the streaming overlap of a zoom step such a point can exist twice; both copies draw the same icon at the same place. Line and polygon labels are untouched: their cross-tile merge is what stitches a road name, and an anchor cannot key it. A single point with a feature id takes none of this path.

A MultiPoint keys each point on its anchor, even under a feature id, because sources merge every housenumber or tree of a tile into one feature under one id. The part index used to tell them apart (10 * id + index), but it collided with the next feature's id from index 10 on, and a point in the buffer of two source tiles got two ids, so it drew twice. PointGeometry::isMultiPoint holds for the source feature: overzoomed, the decoder drops the parts outside the target tile (02-tiles.md), and a MultiPoint clipped to one point keeps its anchored id.

The part index is still the clicked point's featurePosIndex, read against the unclipped feature (PointGeometry::getPartIndex), for labels and for points drawn as tile geometry (marker-clip, point-*) alike. It is stored as 16 bits: past 65535 points in one feature it wraps.

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:

  1. captures wasVisible;
  2. Label::updatePlacement — only re-places a label when its envelope has fully left the frustum; resets opacity;
  3. projects envelopes to screen space;
  4. sorts by priority → wasVisible → layer index → size → opacity;
  5. 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.

A stand-in tile's labels sort after all others, whatever their priority. While a tile loads, TileLayer draws an ancestor up to MaxStandInLevel levels coarser under it (02-tiles.md), and that tile's labels join the set like any other — a decode several levels coarser, with what that zoom's style names and how it ranks it. A name it draws under another text is another globalId, so nothing merges it with the real one. Ranked above a label on screen, one evicted it until the real tile landed and the stand-in went, and the evicted label came back — a name blinking in place on a zoom out (read from the code; not yet measured on a device). setVisibleTiles now tells a stand-in apart — keyed by the finer tile it stands in for, never by its own id — and a label no other tile contributes to (Label::isStandIn) is ranked after every other label of its map layer (the culler sorts per layer) and reserves no room, so it only fills what is free. Alone, as over a tile that never loads, it is placed as before. A stand-in that becomes a real tile (the zoom reached it) changes the label tile signature, so the flag is recomputed. standIn in the stats line counts these labels per pass; LabelYieldTest pins the ordering.

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-placementLabelOrientationrun laid out ondrawn as
lineLINEthe placement's own tangent frame (placement->xAxis/yAxis), no projectionflat on the surface, WORLD_OFFSET
billboard-lineLINE_BILLBOARD_3Dthe camera axes, through the view-projectionupright, 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 boxupright, 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::layoutLines puts the block's vertical alignment and its text-dy in that glyph's advance(1), and buildLineVertexData applies 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 and text-dy did 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.
  • TextSymbolizer feeds all three the same anchors, text-spacing and 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 — why billboard-line could not follow anything before the split — is now exactly what billboard-line-repeat asks for.

A layout fails in two different ways, and they are held differently (Label::LineLayout): neither applies when only the run's heights moved. A 2D/3D ramp re-anchors every placed run each frame (applyElevation → _placementReanchored), and re-judging fit each time doubled the NO_ROOM rate and made names UNREADABLE mid-ramp (#353, web, Zermatt z17: 369 → 176 over a rise, the same as tilting a flat map). Such a run is laid out as placed; the culler or a view change judges it again.

  • 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 out LINE_LAYOUT_FAILURE_GRACE of 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.

Where the anchors fall is maplibre's getAnchors (mapnikvt/LineAnchors.h, from generateLinePoints). A line starting in the tile takes its first anchor half a label plus two ems from its start; one continued from the next tile, half a step in. An anchor must fit the whole label between the line's ends and pass checkMaxAngle (the style's text-max-char-angle-delta, mapbox text-max-angle, default 45°, summed over 3/5 em, run in tile pixels), and only when none does is the middle tried. It used to be one anchor at the middle of any line shorter than the step: Rue de la Viscose in Échirolles jogs 90° exactly there, so once the renderer's own max-angle test landed, that street lost its name at every zoom though either straight stretch holds it. The step between repeats stays text-spacing plus the label's length. The label is tested at HALF its size (the offset stays half the WHOLE label plus two ems; halving it too put Rue Émile Zola's name across its bend, short of the straight stretch maplibre uses): a tile is drawn at 1× to 2× its own pixels and the text is in screen pixels, so at 1× the name overran the stretch it fits on screen (Rue de la Viscose unnamed below z17); the layout, which knows the scale, drops what then does not fit.

The layout lays each glyph on the line itself, after a Douglas-Peucker pass (PLACEMENT_SIMPLIFY_TOLERANCE, 0.6 em) that drops a DEM contour's cell zigzag, about half an em, so the max-angle test does not read it as corners. At one em it also erased a street's real bend: Rue du 19 Mars 1962 bends 28° about 0.9 em off its chord at z15, and its name ran straight across the neighbouring streets. maplibre does no simplification; its contours are smoothed at source. The simplification may only FORGIVE a turn: a run is dropped when both the simplified and the source line fail the max-angle test. Tested on the simplified line alone, Rue Émile Zola's 18° and 25° bends, half an em off one chord, folded into one 34° corner, and text-max-angle: 30 dropped a name maplibre keeps.

The run is centred on its anchor, as maplibre's. The layout used to START it there while clampPlacementAnchor kept the anchor 1.25 runs from each end, so a name sat a whole run in from the line's start: Rue du 19 Mars 1962 at z15.97 began at the bend instead of ending before it, and read at the wrong angle next to maplibre's.

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. It tests only the pairs whose bounds, grown by that distance, meet: two axis-aligned boxes passed to testRecordOverlap with no buffer count as overlapping wherever they are, so a group with distance 0 used to show only its first label. No CartoCSS style builds one — a symbolizer gives a group only with a distance above 0.

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 pathtext-clip: true
culling, text-allow-overlap, text-min-distanceyesnone — there is no vt::Label to cull
placement along the lineper glyph, follows the run (buildLineVertexData)whole run rotated by its segment's angle
text-spacing: 0 (the default)ONE label for the whole lineone at the midpoint of every segment
orientationline / billboard-line / billboard-line-repeat / billboard / pointalways flat — geometry has no camera basis
tile borderthe label is placed across itglyphs 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:

valuefactorwhat a label at 3× the focus depth looks like
0depth / focusDepthfull 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
11⅓ 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::placeCalloutLabel replaces 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 one text-callout-step at a time, for at most text-callout-max-rows rows. Everything else — the priority sort, the wasVisible hysteresis, 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 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_MARGIN short 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 at all (minLift > maxLift) loses the name; the rows never descend below text-callout-offset either. Pulling the lift back down BELOW the summit (what this did first) put the label off the band and at a negative lift with no leader line — what a panorama looked like whenever the ridges sat high. A band asking for more room than the screen has is the other case, and the name is kept: the lift is clamped to maxLift. Dropping it too emptied the whole row in landscape, where a name is ~2.2× taller as a fraction of the screen while the band's anchor stays the same fraction.
  • 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::getFunction hands 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 and view::distance — is what costs a closure and a label style per feature.

  • 0 - x, not -x: CartoCSS's literal rule 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 than text-min-distance allows, 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-step stacks 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-distance is 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. TileLayerBuilder measures the text once and stores one TileLabel::Variant per anchor — a vec2 shift of the text pen and a drawText flag. No extra glyph run, no extra formatting: the block is placed against the icon's edge, so the style's own horizontal-alignment does not have to be mirrored, and dx/dy are 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 neither shield-anchors nor shield-text-optional builds no variants and takes exactly the old path.

  • LabelCuller::placeAnchoredLabel tries the side the label already holds first and then the style's order, taking the first free one — tangram's do { … } 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/dy become 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 of dx, which means nothing once dx is a gap.

  • shield-text-radial-offset places 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 as dx instead, 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 one dx to the right of it. The model lives in LabelVariants.h and is pinned by tests/vt/LabelAnchorAlignTest.cpp.

  • shield-text-optional appends a last variant that draws the icon alone. That is mapbox's text-optional; here it costs one more variant, not a second label. It does not need shield-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 state text-optional. buildLabelVariants used 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.

  • The 2D/3D ramp re-anchors every label every frame, and asks for one pass once it stops. Each ramp step changes the exaggeration, so the labels are re-anchored to the new ground; that no longer schedules a pass per step (TileRenderer::consumeLabelReanchorOwed, then MapRenderer::vtLabelsPostponed, ¼ s of quiet). A camera flight's continuation pass honours MIN_PLACEMENT_INTERVAL too, and the terrain-occlusion verdict comes from the elevation ray alone while FlattenRatio > 0: the depth snapshot lags the ramp and disagrees with the ray on the same label (TileRenderer::updateLabelOcclusionTest). Labels blinked through every switch before this. The exaggeration is compared in TileRenderer::prepareFrame, before startFrame anchors: compared in onDrawFrame, the frame a ramp starts or stalls on drew every label a step behind the ground the camera already stood on — a 76 px drop and return on one 75 ms frame, in the roof probe below. The label elevation provider is pushed there too: installed only in onDrawFrame, the first frame of a rise had none at startFrame, so every CPU-anchored label — the roof POIs — stayed flat for a frame over a ground already rising (8-10 px down and back, measured on the same probe). So is the extrusion elevation provider, withdrawn while flat: without it the first frame measured the roofs on the flat map, and a roof POI stood barely over the risen ground — its height over the ground read 0.52, then 0.07 on that frame, then 0.59 (8-16 px down and back). The fall back to 2D had the mirror fault: the labels were dropped flat on roofs still standing on the last ramp step's bases, so roof POIs hung ~3% of the relief over the flat map for a frame (1-7 px near the centre). The extrusion provider is now withdrawn first and the roofs re-measured flat before the drop.

  • …and never while the last pass is still fading (VTLabelPlacementWorker::MIN_PLACEMENT_INTERVAL), which is maplibre's Placement.stillRecent: it will not begin a placement before commitTime + fadeDuration. Every tile that arrives asks for a pass, so a pan at tilt 80 ran several a second while a label's fade took a whole second at the old 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 stepshortest gapmedian gapgaps < 200 ms
    interval 010.70 ms73 ms48 of 62
    interval 10004.5999 ms1005 ms0 of 25

    _lastPassTime is stamped when a pass starts, under the lock that claims it. Stamped when it ended instead, a schedule() 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, vt::LABEL_FADE_DURATION_MS in vt/LabelFade.h: 300 ms, maplibre's fadeDuration. VectorTileLayer's default label blending speed (1/0.3 per second) is derived from it, and so are TileRenderer's and GLTileRenderer's. The layer pushes its value every frame, so its default is the one that reaches the screen: until it read the shared constant it stayed at 1.0 and labels faded over a full second. A second 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.

And a view that turns rather than pans is the case that rule gets wrong. It reasons about the ground, so it is right for a map being dragged; a panorama sits at tilt ~0, takes the floor of 20, and packs only what it can see. A summit name therefore reaches the screen edge with the row already full, evicts a neighbour to claim it, and that eviction — several times a second while the view is turned — is what reads as names blinking in and out. The ground it wants was already loaded: the band it needs is a horizontal one, over the same horizon.

Options::setLabelPadding overrides the formula outright (negative keeps it), and calculateLabelPadding(tilt, override) is the single place both graphics::ViewState and TileRenderer::cullLabels take it from, so the tile bucket below and the culler's grid never disagree about where the band is. Half a screen width is the useful setting for a panorama — every name in it is then a placement and a collision test per pass, which is the price.

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 tileswith them
passes with an on-screen entry850
entries: never existed / had no placement / lost a collision165 / 81 / 330 / 0 / 0
pan distance600 px230 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)visiblelabelpreloading
z16, tilt 90, centred on a tile400
z16, tilt 90, straddling two204
z16, tilt 30, straddling1215
z13.5, tilt 30, straddling1606
z13.5, tilt 90, straddling202

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 drawnband tiles labels-only
fps vs its baseline18.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. On a fresh screen 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.

But a name does not evict a label already on screen. Every label shown on the last pass reserves the box it held (_reservedGrid, per layer). A side whose name covers a reservation still to be inserted is skipped: the label tries its other sides, then its icon alone. Only when none of those fits does it take the first side that did, evicting as before, so yielding never hides it. The icon alone ignores reservations: a higher-ranked icon still wins. Measured on the style preview (Grenoble z16.5, pitch 50, terrain, two-finger rotate and mouse pan, 4 alternating page loads): mid-screen collision flips 47 → 36 per rotation and 69 → 45 per pan, and slightly MORE labels per pass (65–75 → 69–82). LabelYieldTest pins the four cases.

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.

Yielding only to what the user already sees is what avoids the first one's collapse: on a fresh screen nothing is reserved and greedy fills it, and from then on the screen only changes where space is free.

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 of a -PprofileRender build) 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;
}
  • The icon plate's fill and border colours may follow the view: a shield-icon-background-fill or -border-fill reading [view::brightness] (a POI disc that goes pastel with a dark ring at night, as Mapbox's does) is kept as a ColorFunction on LabelPlateStyle and evaluated per frame where the plate's batch slot is written. Read at decode instead, where the brightness is unset, every disc took the ramp's first stop — the night one — at noon. The decode-time colour stays, at daylight, for enabled().
  • 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 r channel is the fill's coverage and its a the whole plate's, so the ring between them is the border, and labelFsh (draw mode PLATE) 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 leaves border * a * (1 - a) showing through wherever alpha < 1 — a yellow shield with a black border went dark for the length of its fade-in, and a translucent text-background-opacity stayed 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 calculatePlatedBBox grows 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 with 1 / size and not with the world-units-per-pixel scale / size the drawn plate uses (appendPlate adds it after multiplying the box by scale). Getting that wrong grows the padding by a further factor of scale — tens to hundreds for a callout, which keeps its pixel size by taking scale off 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_PARAMETERS is 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:

constantcuts past
mapbox minPerspectiveRatio0.5510 × camera-to-centre
maplibre perspectiveRatioCutoff0.65 × camera-to-centre

vt::LabelDistance ports maplibre's, the tighter of the two, as ratio = 0.5 + 0.5 · (cameraToCentre / distance). Options::setLabelViewDistance sets it in multiples of the camera-to-centre distance: default 5 (ratio 0.6), 0 = no cut, which a panorama or first-person view wants. 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​

Current state: slicing is OFF (PLACEMENT_BUDGET_MS = 0 in VTLabelPlacementWorker.cpp), so every pass is a whole cycle; only the duty-cycle gate below still rations placement. A slice sorts and inserts only the labels it collected, so the grid went to whatever came first rather than to priority, and the selection churned on a still map. The machinery below still works — a non-zero budget restores it — and the self-tuning FULL_PLACEMENT_MS bypass described at the end is gone.

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.

A pass must make progress. The budget is one deadline shared by every layer, so a layer whose turn comes after it is spent enters with the deadline already gone — and the (index & 0x1f) mask that keeps the clock read off most iterations is true on its first iteration whenever the cursor sits on a multiple of 32. The pass then stopped with index == cursor, and a cursor that does not move is a layer that never places a label again: measured on the Crosscall at Grenoble z16.22 tilt 26, two layers of three sat at 2048 of 4075 and 96 of 172 for as long as the map was panned, while the far field kept a mass of labels overlapping each other and the panned-to ground had none. labelSliceMayStop (vt/LabelSlice.h) puts a floor of MIN_SLICE_LABELS under every call.

A cycle ends when every layer has wrapped, not when they all wrap together. Layers hold different numbers of labels and so wrap on different passes; requiring them to coincide meant finished was true in 0 of 230 passes, _cycleActive never cleared, and the grid — cleared only between cycles — held 1241 stale records indefinitely. The worker remembers which layers have wrapped (_cycleWrappedLayers).

A cycle whose camera has moved is abandoned, not finished. The frozen view is right within a cycle, but a rationed one lasts longer than a pan, so every label it has left to place is for a screen that is gone. Draining it first is what the user waits through. When the view no longer matches, the cycle is dropped outright — cursors back to zero (restartLabelPlacement), grid cleared, new view — which keeps the invariant that matters (a cycle is never resumed against a different view) and removes the wait.

And the cycle that replaces it runs WHOLE. Rationed, placement is paced to TARGET ms of every second, so a cycle costing a second of work reaches the screen tens of seconds later. A redo owed to a moved camera is precisely mapbox's isFullPlacementRequested, so _forceFullPlacement runs it unrationed. Measured in the near field after a four-swipe pan, as dark pixels in a fixed crop:

rationed redo+ run whole+ abandon stale
t = 1 s—20772
t = 10 s37725892623
t = 20 s255525892623

Labels start appearing at 1 s instead of 20, and the settled picture carries slightly more of them. Steady-state placement is still rationed exactly as before. The frame cost of that one unrationed pass is not yet measured.

A forced placement commits without a fade — only the one replacing an abandoned cycle, which with slicing off never happens; a redo for a moved camera now cross-fades. It replaces the whole screen in one pass, so blending it in drew every outgoing label on top of its replacement for the length of the fade — a pan came out of it with road names printed twice and half-faded POIs. mapbox commits a forced placement with fadeDuration == 0; snapLabelTransition is that, consumed once by the next startFrame. The overlap spike in the near field (7838 dark pixels at 5 s, against 2389 settled) disappears, and the labels are on screen from 1 s.

The cull order has to be global, not per slice. A rationed pass collects a slice, sorts that slice, and greedily inserts it — so a low-priority label in the first slice takes a slot from a high-priority one in the fifth, and a sliced cycle answers differently from a whole one for the very same scene. That is why the placement visibly changed between the forced first pass and the rationed ones that followed. _labels is therefore ordered by priority when it is built, and the slices walk that order; mapbox orders its symbols once and pauses over that order for the same reason.

Respecting the order costs density, measured on startup as dark pixels in a fixed crop: it settles at 12601 against 14981, about 19% fewer labels, because a high-priority label claiming its slot first is usually the bigger one and blocks more small road names. It also converges about 2 s sooner (stable from 6 s rather than 8 s), and two cold runs now land within 0.1% of each other. Note what this does NOT fix: both orders settle and stay, so the per-slice sort was never the cause of a label appearing and then vanishing — only of the two placements disagreeing.

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 gate scales with the pass, so an empty pass is not gated at all. A pass over no label costs microseconds, and so does its wait. Before the surface had a size, the worker ran 140 000 to 250 000 such passes a second (160 ms/s of CPU), each asking for a redraw. Seen on the web at startup and on a pane that stayed blank. A 0x0 surface makes the view NaN (ViewState::calculatePerspMat divides by the height), and the "placed for a camera that has since moved" check compares matrices with !=. NaN is never equal to itself, so every pass read as a moved camera and scheduled the next one at once. Now a pass does no work for a non-finite view. A pass that found no label anywhere neither redraws nor continues (LabelPlacementFollowUp.h, covered by LabelPlacementFollowUpTest). Labels that arrive schedule their own pass.

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. Both places read the app's labelViewDistance (LabelDistance::isTooFar); updatePlacement used to hard-code the 0.6 ratio, so a larger view distance let the culler accept labels that were never given a placement.

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 was self-tuning (FULL_PLACEMENT_MS, removed with the slicing) — a cycle was timed, and the next one rationed only if the last exceeded it. Looking down, a cycle is ~10 ms; pitched, 60-100 ms.

What it cost while rationed: a cycle took several hundred ms, so labels settled behind a fast camera rather than with it (they kept their previous visibility meanwhile, so nothing flickered), and the placement SELECTION shifted, since an early slice claims grid slots before a later, higher-priority one — mapbox has the same property, and it is why slicing is off now.

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:, windows: and web: 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 .ttf does not. Only a font Android has no family name for falls back to Typeface.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.

A font COLLECTION hides the same trap one level up. iOS resolves every Helvetica Neue name to the one file, HelveticaNeue.ttc, twelve faces with Regular first, and FreeType opens face 0 unless told otherwise: "Helvetica Neue Bold" came out as Regular emboldened by 1/8 em, three times FreeType's own amount, a heavy smear on every bold label. findCollectionFace opens the face whose PostScript name or family + style spells the request (fontFaceMatches in FontNames.cpp, host-tested).

On the GL thread​

  • Fades. updateLabel moves opacity toward visible ? 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. Outside that buffer's viewport the answer is a CPU ray march (TerrainOcclusion::isSegmentBlocked), and while the camera flies the buffer is refreshed about once a second, so most labels get the ray. The ray ends on the label's height above the live ground at its own position, and the DEM cell it stands in never blocks it. Without both, a 2D/3D switch hid labels by the hundred: each anchor trails the ramp by a step (~5 m at z17), the ray stopped only 1% short of the label, and the label's own slope "occluded" it — labels blinking during the animation, and all of them gone for a second at its end (#353, measured on the web build: 15–385 per cull pass, 0 after). The read-back buffer itself answers only at the exaggeration it was rendered at (TerrainDepthBuffer::exaggeration): during the switch it is seconds old, from another ramp step, and it hid ~100 labels per pass. Neither test judges a label whose re-anchor is still owed (Label::isElevationStale) — the same rule as an un-anchored one, since its height is a previous step's.

    Not judging is not "in the clear": the last verdict stands (Label::isTerrainOccluded). Both the culler and updateLabel used to treat an unjudgeable label as unoccluded, so every elevation arrival, ramp step or tile rebuild that left a hidden label un-anchored or stale for a moment faded it in, and the culler let it take the slot of the label beside it; once its height landed it was hidden again and the neighbour came back. Both blinked without moving while elevation streamed in. The verdict is carried to a label rebuilt on a tile-set change, dropped when the culler re-places the label elsewhere, and cleared while no occlusion test is set (flat map), so a switch back to 3D does not hide labels on a verdict from another camera. A label never judged still counts as in the clear. LabelOcclusionKeepTest pins the culler's half; occKept / occFlips in the stats line count the verdicts kept by the culler and the per-frame verdict changes on the GL thread.

    The slack is 5% of the distance (billboardOcclusionTolerance), not 20%. At 0.2 the Matterhorn's Zmuttgrat kept its name seen from the Italian side: anchor at w 225, the south face at its pixel 193 — 900 m in front of it, and inside the 20%. Measured on the web build over 360° orbits of the Matterhorn (z13.6, z12.5), Grenoble (z14.5, z13, z16.3) and Zermatt (z16), with the DEM ray (isSegmentBlocked) as the truth, per label test: of 1.8 M tests of labels the ray sees, 2 needed a slack between 0.05 and 0.2 and none between 0.03 and 0.05; of the labels it says are hidden, ~51 000 tests fell in that band and were shown. The few visible labels that need more need 1-7x (far ridges, the city edge behind the Bastille) and were hidden at 0.2 already. The 0.2 dates from before labels sat on the drawn surface (above): the error it absorbed is gone. A POI that blinks while orbiting is, more often, losing its collision slot: Hörnlihütte over a full orbit was occluded only from 270° to 310°, where the summit really stands in front of it, and dropped at 255°, 315° and 345° by the culler with no occlusion at all.

  • 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: rays from the eye to a small square around the label's anchor are tested against a CPU copy of the extrusions, and the whole label fades by how many are blocked. 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.

    "No elevation here" is not a height of zero. ElevationManager::getDisplayHeight returns 0 both for sea level and for "the grid holding this point is not cached", and a grid is routinely evicted while its texture keeps rendering. Anchoring a label on that 0 buries it under the terrain, the occlusion test then hides it correctly, and the caller marks it clean so nothing ever asks again. Measured in AlpiMaps on the Crosscall: no labels at all with 3D terrain on, and with BillboardOcclusionEnabled off the same labels were visibly under the ground. RenderStats named it in one line — elevUpdMs 1–180 ms with elevReanchor=0: the sampler ran every frame and never moved a single label, because the 0 it wrote already matched the 0 that was there.

    So the provider asks ElevationTextureCache::getDisplayHeight with smooth = false first, and the flag is the whole point: that path reads the grid the texture entry retains — the one the manager's LRU has already dropped out from under a tile that is still being drawn — walks to a bounded ancestor, and prefetches what is missing — and how far that bound reaches was the actual root cause. BASE_MAX_ANCESTOR_LEVELS is 1, while RenderStats' zoomGap measured 3: the elevation sits three levels coarser than the render tiles, so a one-level walk refused the only data there was and returned false for every label at every position. The cap is right for its original caller — an extrusion bakes its base into its vertices, so a far ancestor is a wrong answer rather than a coarse one — so the bound is now the caller's, and a label passes LABEL_MAX_ANCESTOR_LEVELS, the measured gap of 3 plus the frame the texture lags the grid by. Confirmed on the Crosscall: 19 misses in 746 584 queries, and elevReanchor non-zero for the first time. It was briefly unbounded, and that is the other half of the floating-POI bug below: an entry further up than the gap is one a previous camera left in the cache, and after a 2D/3D switch — nothing loads the DEM while the map is flat — one such texel answered for measured with it, the whole screen. Bounding it is only safe because a label that gets no answer is not worse off: with GPU draping labelVsh elevates it from the same texture the ground is drawn from, so refusing is better than answering wrong. Without a texture cache there is no GPU height either, so that device takes the manager LRU's own unbounded walk instead — coarse beats nothing when nothing else comes. smooth = true is the building-BASE variant and resolves through the manager's LRU alone, so it fails in exactly the case this is for. The provider returns NaN when neither source has data. Label::applyElevation leaves a non-finite vertex at the height it had and returns false, which leaves it un-anchored. Zero stays a legal height. Extrusions hit this first and were fixed the same way — see 04-terrain.md.

    The label is still marked clean, though. Only new elevation can change the answer, and markPendingLabelsDirty re-dirties the label when it arrives — so retrying every frame bought nothing, never converged, and was most of what elevUpdMs spends. What carries "height unknown" is isElevationAnchored(), not dirtiness. That is mapbox's split exactly: an approximate CPU height for collision (getAtPointOrZero, defaulting to 0, src/terrain/elevation.ts), the true height on the GPU, and no symbol ever hidden for missing elevation.

    A generous ancestor bound is a debt, and the payment is three invalidations, not one. The ancestor answer above is knowingly wrong by metres — it is only safe because the label is re-anchored when something better arrives, and TileRenderer used to watch a single source for that, ElevationManager's version. Two others move the same answer. The elevation texture lands a few frames after the grid, and the provider reads the texture entry first, so a label anchored on an ancestor while the grid was still in flight was never told; the extrusion bases already invalidated off getFrameContentChanges() and the labels now do too. And the exaggeration is inside every CPU height: a scale change alone was handled, but a frame that brought a DEM tile as well took the tile-scoped branch and left every label elsewhere on the previous ramp step. Both cases are a 2D/3D switch exactly: the ramp moves the exaggeration every frame while the DEM for the view streams in over the same frames, and a label that misses its re-anchor is a POI left hanging in the air over the ground — the whole screen at one height at first (one stale coarse texel answers all of them), most of them coming down as tiles land, a few never. TileRenderer now tracks the exaggeration itself and invalidates every label anchor when it moves, ahead of the branch chain that only ever names changed tiles.

    The level is the one DRAWN under the label, not the camera's. The provider asked at int(view zoom), but the ground in front of a tilted camera is drawn finer than that — the cover splits down to the finest layer's tiles (the terrain-3d example's imagery has zoomLevelBias 1) — and the node field is box-filtered per cell, so every coarser level lowers a sharp summit. Measured on the web, terrain-3d at its own camera (Matterhorn, zoom 11.6, tilt 14): the label read the z10 grid, 4162 m; the summit was drawn from the z12 grid (a z13 cover tile), 4422 m at the POI — the icon 260 m (325 m exaggerated) inside the mountain, under a summit that in 2D it sits on. ElevationTextureCache::getDrawnDisplayHeight now answers first: the finest texture entry used since the previous frame began (its lastUsed LRU stamp, FinestDrawnLevel.h), no coarser than the bound above; the camera-zoom path stays as the fallback, and keeps the prefetch. The stamp is what keeps out a finer texture an earlier camera left in the cache, which would float the label over a coarser drawn ground. This is maplibre's and mapbox's level too: a symbol is elevated from the DEM of its own tile, which is the level the terrain there is drawn from. Not mapbox's VALUE: they sample the DEM bilinearly, we keep the node field, since a label on the true 4478 m would hover over the drawn summit. What is left: the level is the one drawn when the label was anchored, and only a landing texture re-anchors it, so a cover that refines over textures already held leaves the label one level low until something lands.

    The fallback never reads finer than the label's own tile. The camera-zoom fallback also prefetched at the camera zoom, wherever the label stood: a label in a far z9 tile, or in one of the z5–z8 stand-ins kept under the view (02-tiles.md), fetched full-detail DEM at its position, all over an area 64 z11 tiles wide. The anchor function now carries the zoom of the label's tile (Label::sampleElevation), and the provider asks the fallback at min(camera zoom, tile zoom). Measured on the Crosscall, terrain-3d at Innsbruck (z12.05, tilt 25), cold cache, still camera, 60 s: DEM tiles 161–183 (89–103 of them z11, spread over 45 × 38 tiles where the view covers 2 × 3) down to 71–85 (12 at z11), app PSS 514–527 MB down to 432–444 MB; after two 360° turns 727–753 MB down to 675–690 MB, still no blank tile.

    A label whose height was never resolved is not isElevationAnchored(), and the terrain occlusion test skips it: it still carries its flat decode height, and judging that against the terrain hides it under ground it is not behind. A later miss un-anchors the label: it was sticky until #353, and a label that missed during a 2D/3D ramp kept a vertex at another step's exaggeration — drawn in the ground or in the sky; un-anchored, the GPU places it. Going flat un-anchors too. Withdrawing the provider drops every label to 0 (setLabelElevationProvider) and clears its anchor, because an anchored label draws at its CPU height, not the GPU's (labelBatchTileId): kept anchored, a label that missed its first sample back in 3D sat at the flat map's 0, under the ground, until its DEM tile landed and it jumped up. And while flat, TileRenderer counts the exaggeration as 0: an instant switch (flattenRatio 1 → 0 in one frame) restores the pre-flatten value, which compared equal and re-anchored nothing (#353).

    Every re-anchor is owed by generation, not by a flag someone may clear. Each whole-set invalidation bumps _labelElevationGeneration; a label records the generation its height was sampled at, and anchorDirtyLabels re-dirties any label behind it, every frame. The flag alone lost re-anchors: the cull thread's off-lock sample landed after a ramp step the render thread had already consumed, and marked the label clean at the old height. The generation re-dirtied it on the next frame, but the frame in between still drew it at the sample's height — taken before the cull thread walked every new label, so often several ramp steps back: through a 2D/3D switch, with the tiles re-decoding for 3D, labels dropped back and rose again. The cull thread now drops a sample whose generation has moved (labelAnchorStale counts them) and leaves the label to the frame's anchorDirtyLabels, which re-anchors the whole set on every ramp step anyway. And setVisibleTiles used to clear _labels before an unchanged label set skipped its commit (#255): the labels stayed drawn but left the cull and anchor list until the set changed — frozen at whatever height they had.

    The anchor's RENDER height now comes from the GPU, so none of the above can misplace a label on screen — it only decides what the culler sees. labelVsh calls the surface's own applyTerrain on the anchor and uses the result for both gl_Position and the occlusion tap. Three things make that possible: renderLabelPass groups labels by anchor tile when terrain is on (a batch elevates against one tile's uniforms, so interleaved labels would end the batch every time), the batch binds them with setupTerrainUniforms(prog, batch.tileId, translate4(origin), false) — which derives the elevation uv from the vertex frame it is handed, so no conversion is needed — and the label occlusion texture moves to unit 2, because elevation owns 1 and the node texture 5.

    A label on a bridge deck keeps its CPU height instead: a span chord is CPU-only data and is not the terrain's. attribs[3] carries that as bit 1 (ABSOLUTE_HEIGHT) next to the offset mode in bit 0, set by one chordHeightAt test per label at each anchoring site — the height function answers per vertex and cannot report which of its two sources it used. This is mapbox's u_elevation_from_sea, per label rather than per layer, and it is the same mechanism a label sitting on top of a 3D building will need (there with a non-zero z-offset rather than an absolute height).

    What is still CPU-side, and why the cost has not gone yet. symbol.vertex.glsl elevates a symbol in the vertex shader — z_offset + elevation(tile_anchor), where elevation() is a DEM texture fetch (_prelude_terrain.vertex.glsl) from the same texture the ground is drawn from. So a symbol is at the ground's height by construction, in the same frame, at no CPU cost, and "elevation not loaded" cannot bury it because the ground is not drawn there either. Their CPU getAtPointOrZero exists only for the collision index and defaults to 0 without ever hiding a symbol. Everything above — the sampling cost, the dirty/anchored bookkeeping, the unknown-height window — is the price of anchoring on the CPU instead. 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: TileRenderer classified 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 asked spanHeightAt whether 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: setVisibleTiles samples 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 0 restores the in-frame path for an A/B — on the emulator it was 19–31 prepare spikes of 130–190 ms per pan against none. Like debug.massif.linesourcedensity below, it is read only by a MASSIF_DEBUG_PROPERTIES build (runtime switches). That sample reads ElevationTextureCache, whose entries the GL thread inserts and evicts every frame, and the cache had no lock: a data race on the cull thread (as for the building and span bases read the same way). Its height queries now take _cacheMutex around every entry lookup; the GL thread, the only writer, takes it only around its writes (a texture lookup's LRU stamp included). 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 takes TileTransformer::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. prepare 157 → 72 ms, 1.75 → 2.10 fps. The bound was measured first with debug.massif.linesourcedensity 1 (no line subdivision at all): prepare 154 → 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_pass does the same), but labelFsh subtracts 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 leaves halo * a * (1 - a) wherever alpha < 1: a yellow road shield with a dark shield-icon-halo-fill went dark for the length of its fade-in, and a translucent text-fill stayed 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 of tesselateGlyph), and pointFsh punches the ink out the same way.

  • Halo width is measured in antialias ramps, not in glyph texels. labelFsh shifts 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 is renderSize / (renderSize - spread): 3.0 on the smallest raster against 1.2 on the largest, so one text-halo-radius drew 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-radius is a STYLE pixel, like text-size — the symbolizer multiplies it by the pixel scale, and only MAX_HALO_PIXELS (where the encoded field runs out) caps it. It did not used to be: a back-compat factor of HALO_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-dx and text-dy never 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 for text-halo-width: 1.2. text-min-distance carried 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 transform no longer ends the batch. The translate was a factor of the batch's labelMatrix, 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 after calculateVertexData instead, and the label batches with its neighbours. Measured on the 2D city pan: 68.4 → 38.7 label draws a frame, labels2D 4.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. FontManager keys _glyphMapMap by 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, and GlyphMap::loadBitmapGlyph returns 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. renderLabelPass bound its labelBitmap reference through that temporary's ->, which extends nothing: the pattern died at the end of the line and the batch then compared and copied a freed shared_ptr. Crashed about half of the startups at a label-dense city camera, as either a null bitmap in renderLabelBatch or a Scudo ERROR: invalid chunk state. Hold the pattern in a named shared_ptr for the iteration.

  • Vertex data. The glyph layout is already cached per label (_cachedVertices, _cachedValid) and the shader already expands the billboard from offsets + 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 labels3D 82.9 ms/interval (2.96 ms/frame), labels2D 46.3 (1.65), buildMs 45.7 (1.63), batchMs 21.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 offsets and the per-frame attribs per label, keyed on scale / camera axes / style slots / opacity, measured 26.6–27.4 fps against 27.1, with transformMs and attribMs unchanged. Two reasons, and the first is visible in the code: scale carries calculateTerrainScaleFactor = 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 in labelBatchParams.labelMatrix (which already carries a translate). Absolute world coordinates are not an option — WORLD_SIZE is 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. offsets hold cachedVertex * scale, and scale carries calculateTerrainScaleFactor = 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. The inverse is what survived the revert: vt::ViewState carries viewProjMatrix/invViewProjMatrix, so the snap no longer inverts per label. The rest went with it — labelVsh snapping the shader-cancel modes itself (uLabelScreenSize), and a CPU anchor taken against a latched viewState.labelOrigin re-based every LABEL_ORIGIN_LATCH_DISTANCE (256 internal units, ~10 km), with the residual in the label matrix. The culler kept viewState.origin.

What survived a frame, then, was the whole 3D pass: GLTileRenderer::LabelBatchCache held one PersistentLabelBatch per batch (its VBOs, its parameter tables, and the label matrix without the camera), and a hit re-issued the draws with new uniforms only. None of these names exist in the code now. Validity was 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 bumped it, and only while they were 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 did not invalidate: the batch kept a CPU copy of its attribs plus each label's vertex range, and patchLabelBatchOpacities rewrote one byte per glyph of the labels that moved and glBufferSubDatad the dirty span.

Plus the view: zoom, rotation, tilt and planarProjection were 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 reached 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 — a switch removed with the revert): 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 removed the work it was designed to remove and bought 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. renderFrame sets _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_OFFSET and are rescaled by the shader every frame; appendLabelPlates emits CAMERA_AXIS_OFFSET with the build-time calculateTerrainScaleFactor baked 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):

  1. Each extrusion geometry keeps a CPU copy of its mesh, vt::ExtrusionOccluder: tile-local xy, raw height units and the triangles, binned in a 32 x 32 grid over the tile. It is built on the decode thread (TileLayerBuilder), because the GPU upload releases the indices it needs.
  2. Per frame, GLTileRenderer::calculateLabelVisibility lists the visible extrusions with their tile frame and height scale - the zoom ramp and the tile's own grow-in, the shader's arithmetic. The base comes from the vertex bytes resolveExtrusionBases writes, so a building rises with its DEM; one not yet resolved occludes nothing. With no DEM at all (a flat map) a base never resolves, and there the ground is the base: read as "not yet", no building hid a label until something else, the preview's shadows, loaded a DEM. A wall's foot is drawn on the ground under it, not at the base, so its slot carries the lowest ground under the footprint instead (resolveExtrusionBases; polygon3DVsh ignores it there). Before, the occluder's walls started at the base: at Collioure 38% of footprints stand more than 2 m above their lowest ground, up to 13.9 m, and the bottom of every such face hid nothing.
  3. For a label whose opacity asks for it, four rays go from the eye to the corners of a 30 px square around the anchor, at the anchor's distance (mapbox's square of taps). A ray stops 1 m short of its target, so a label on a roof is not hidden by that roof. The fraction of rays no triangle blocks is the label's visibility, multiplied into its opacity on the CPU (Label::setOcclusion).

The ground is deliberately not tested: terrain occlusion is already answered on the CPU, per label, above.

Why not on the GPU. It used to be: every visible extrusion drawn once more into a half-res packed-depth buffer and sampled in labelVsh. That pass redrew the whole building geometry every frame and broke the screen pass on a tiling GPU. mapbox's own route, copying the screen depth (blitDepth), was tried too: one copy after the buildings holds the ground as well, so road names faded against their own road, and a second copy of the ground alone costs as much as it saves - each copy stops the tiler and reloads the screen. Crosscall, Grenoble z17.2 tilt 45: the redraw pass 16.6 fps, one copy 22.3 vs 20.0 (wrong labels), two copies +2%, the CPU rays 16.6 -> 18.25 shadows off and 11.25 -> 12.35 on, for about 1.3 ms of render-thread CPU.

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.

A label standing in a building is tested from its roof. A POI's anchor is on the ground inside its own footprint, so every ray to it crossed that building's roof and nearly every shop vanished at any tilt. mapbox draws such a label on the roof (symbol-z-elevate); here only the TEST moves: the target is lifted to the highest roof triangle over the anchor (ExtrusionOccluder::roofAt), and the 1 m margin keeps that roof from hiding it. The tap square then stands ON the roof rather than centred on it: centred, its two lower taps sat inside the building, its own roof took them, and every such label drew at half opacity. A building in front still hides it.

A hidden label claims no room. The occlusion is a draw-time fade, so the culler still placed a road name behind a building, at opacity 0, and the POI on the roof in front lost its slot to it. A label left at 0 by the last frame (Label::isFullyOccluded) stays placed but adds no collision record; once it shows again, the next pass weighs it as any other.

A partly hidden label gives way to every label in the clear. Half-transparent behind a building, a street name still claimed its whole box, and the POI under it stayed off screen until a little more tilt hid the name completely. A label with any ray blocked (Label::isPartlyOccluded) now sorts after all unoccluded labels of its layer, whatever their priorities, and reserves nothing (above). The occlusion is computed only for labels being drawn, so a label hidden while partly blocked keeps that value, and its low rank, until it is drawn again: sticky, but it cannot flip every pass, which treating an unknown occlusion as clear does.

The occlusion eases; it is not set. Each frame's verdict comes from four taps against the extrusions drawn that frame, and while building tiles load that set moves under a still camera: tiles swap, buildings grow in (renderLayer.blend), and a building whose base is not resolved yet blocks nothing. Applied outright, the verdict jumped by quarters from frame to frame - a half-hidden POI flickered, and through the ranking above it lost and won its slot pass after pass, seen as POIs showing and hiding while rotating over 3D terrain with 3D buildings until the tiles settled. The value now moves toward the verdict at the label fade step (_labelOcclusionStep; endFrame asks for frames while one still moves), a rebuilt label carries it over, and "partly occluded" holds only once it has eased a quarter of the way to its occluded opacity - one tap of four, settled - so a passing dip cannot re-rank it. LabelYieldTest pins the threshold.

Hides fast, shows slowly, and a label is judged before it is shown. Easing from "clear" had a cost of its own: a label's occlusion is only computed while it is drawn, so a new label (or one back from opacity 0, whose value had gone stale) started at 1 and eased down at the same step its opacity rose - a label wholly behind a building drew at up to a quarter opacity for a dozen frames before vanishing, seen as POIs blinking in 3D with buildings. Now (easeLabelOcclusion, LabelFade.h): a label whose occlusion was never judged, or not since it last stood at opacity 0 (Label::isOcclusionJudged, cleared in renderLabelPass, carried over to a rebuilt label), takes its first verdict outright, so it starts at the verdict instead of at "clear". After that the value follows it through a first-order low-pass that hides faster than it shows (time constants about 100 ms down, 330 ms up): a covered label is gone in a few frames, while a verdict that reads clear for a frame or two as building tiles swap or an unresolved base blocks nothing barely lifts it.

A half-hidden label holds still while the camera looks around. The same verdict wobbles by a tap or two as rays move over a roof edge or the occluder set changes by a tile, and a linear slew followed every wobble. Three things now keep a semi-occluded label steady (LabelFade.h): the verdict is held through changes under a 0.15 dead band (holdLabelOcclusionVerdict; fully hidden and fully clear always pass); the low-pass above is exponential, so a verdict flipping every frame ripples the opacity by a few percent; and when the four corner taps disagree, five more (edge middles and centre) are cast, so the verdict moves in ninths, not quarters, as an edge sweeps the box. "Partly occluded" (the culler's rank) is a Schmitt trigger: it sets at one blocked tap of four and clears only when nearly clear, so a label hovering at the threshold no longer flips rank in every placement pass. LabelFadeTest and LabelYieldTest pin these.

A label is judged before it competes, not once drawn (the Catalyst demo loop, docs/internals/catalyst-demo-loop.md: a 90-frame turn at 0.15 deg a frame, Grenoble z17.5 tilt 60, 3D buildings, counted 110 blink events). The cause was the placement, not the fade: a label's occlusion was judged only while it was drawn, so a label rebuilt on a tile-set change - or hidden for a while, its verdict stale - entered the next placement pass as "in the clear", ranked ahead of every settled label, took a free slot from a tree or a neighbouring POI, was then drawn once, judged behind a building and fell to the back of the next pass, which gave the slot back: the evicted label faded out and in again, and the POI itself flashed. In the turn log 1600 visibility flips in 90 frames, 590 of them hides by an overlap with exactly such a label. Two changes (GLTileRenderer::judgeLabelOcclusion, startFrame):

  • startFrame judges, within a budget of 32 labels a frame (a hidden label again every 60 frames), the on-screen labels nobody draws (hidden or just built, unjudged or stale), so the culler's isPartlyOccluded / isFullyOccluded are current for the pass that follows. A judgement carries a frame stamp (Label::getOcclusionStamp, carried to a rebuilt label); a stamp older than 120 frames is replaced outright, not eased from. Measured on the Catalyst loop (Grenoble z17.5 tilt 60, 3D buildings): about 8 us a judgement, the pre-pass 0.05 ms a frame on average and 0.37 ms at p95 (it was 0.10 / 0.68 ms at a budget of 96); drawing the labels already judged about 190 labels a frame for 0.45 ms.
  • a label never judged (Label::isOcclusionKnown) ranks after every judged label in the clear and before the occluded ones, whatever its priority, so a rebuilt label of unknown standing cannot evict a settled one in the frames before it is judged. LabelYieldTest pins it. The turn dropped to about 45 events (the rest is ordinary collision between moving labels: a street name sliding over a POI, a name falling back to its icon-only variant); the still camera stays at 0.

A line run collides by its glyphs, as maplibre's by collision circles along the line (Label::calculateEnvelope's glyphEnvelopes): the bounds of a run round a bend covered the corner beside it (405x320 device px for Place Grenette, tilt 60) and hid what stood there. Neighbouring glyphs share a box while it stays within 1.2x their own area, so a straight run is one box and a bend a few: a box per glyph cost the Crosscall 3-5 ms a placement pass (Grenoble z19.2 tilt 31). A point label with an icon and a name collides as two boxes, as maplibre's text and icon boxes.

A label on its roof. text-z-elevate / shield-z-elevate (mapbox symbol-z-elevate, the Massif poi_on_roof parameter, on by default) draws the label there instead. Each frame the renderer gathers every loaded roof at full growth (refreshRoofSurfaces); when that set or the building height scale moves, the z-elevated labels are re-anchored on the render thread with roofAt over their terrain height, and flagged absolute so labelVsh keeps the roof height. Not in the cull thread's bulk anchoring: a roof reads extrusion bases the render thread resolves. A flat map anchors these labels too, and nothing else. A building growing in carries its label at the full height from the start.

The roof is measured on this frame's bases. A base carries the exaggeration, and it is resolved when its building is drawn — after startFrame has anchored the roof labels. Through a 2D/3D ramp a roof label therefore stood on the ground of this step and on the roof of the last one; a step (~0.4 internal units at Grenoble) is taller than a house (~0.27), so max(ground, roof) flipped between a lagging roof and the bare ground every frame, and the label leapt to the real roof when the ramp stopped. Measured with a per-frame probe on the Mac Catalyst app (Grenoble, tilt-triggered switch): roof POIs moved ±10-25 px a frame at a constant tilt while ground labels held still, and their height over the ground read 0, .36, .26, .27, .33, .27, .05, .27, .22, then .71 at rest. refreshRoofSurfaces now resolves the on-screen bases of this renderer and its peers first, when it has a z-elevated label; the draw then finds them current.

Such a label, once on its roof, is tested over the box it is drawn in (Label::calculateEnvelope, the four taps at half its extent, none below the roof plane) rather than over a square at its anchor. Tested at the anchor, it lost most of them at street tilt: measured on the emulator at Grenoble z19.2 tilt 23, the blocked rays came down at 5-17 degrees and met roofs 4-16 m taller 10-120 m in front of the anchor, while the billboard itself was drawn over those roofs. Skipping the test instead (the previous answer) drew a POI behind a taller building over that building. Web build, same camera, a composite with a contour slot: 74% of roof labels fully visible, 25% fully hidden, 1% in between; measured with it, the whole label occlusion pass measured 0.57 ms a frame, roof labels about half of its rays. mapbox applies the same depth test to a symbol-z-elevate label as to any other (symbol.vertex.glsl).

Not near top-down. Past tilt 80 (LABEL_OCCLUSION_MAX_TILT, 90 = straight down) no ray is cast: looking down, nothing stands between the eye and a label. A flattened (2D) extrusion has no height, so it is never an occluder either, and with none in view the test returns before any ray.

Not on a globe (the planar tile frame the rays are cast in does not hold there) and not for a translated extrusion layer or a bridge span, which the old pass skipped as well.

Buildings drawn by another group of the same composite count — roofs and occluders are gathered over the renderer's peers too (forEachLabelExtrusion); see the composite layer.

Asking for it​

TerrainOptions::setTextOcclusionOpacity(float) — the opacity an occluded label keeps. 1, the default, is no occlusion at all and no ray is cast; 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. Only such labels cast rays.

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 LabelRenderOrder is not LAST draws 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.