Skip to main content

The GL vector-tile renderer

Scope: libs-massif/vt/src/vt/GLTileRenderer.* and its bridge all/native/renderers/TileRenderer.*. Terrain specifics are in 04-terrain.md, depth in 05-depth-model.md, labels in 06-labels.mdx.

The bridge​

TileRenderer (all/native) owns a vt::GLTileRenderer and, once per frame, pushes the state the renderer cannot know by itself: view state, interaction mode, blending speeds, layer filters, and the whole terrain block — elevation texture provider, regular-grid mode, painter order, depth shift, slack scale, lighting, fog, shadow map, ground tiles and layer ordinals.

That "terrain state block" is also where an elevation version change is turned into work: invalidateTileSurfaces / invalidateLabelElevation for the changed tiles, or a full resetTileSurfaces (debounced by SURFACE_RESET_DELAY). Measured cost of the block during a pan: 0.04 ms — it is not the pan hang it was once believed to be.

Per-frame phases​

startFrame(dt) blend render tiles, re-anchor labels whose elevation changed, blend labels
renderGeometry() the 2D pass, then the 3D pass
renderLabels() glyph quads, built fresh every frame, uploaded as batches
endFrame() sweep compiled resources whose owners expired

renderGeometry2D (GLTileRenderer.cpp:3087) is the heart of it:

  1. Bucket every visible render tile's layers by style layer index into renderLayerMap.
  2. In terrain mode, sort each style layer's tiles near to far (content writes depth, so near tiles must occlude far ones). The distance is computed once per tile into a small map — doing it inside the comparator made the sort 21% of the render thread, because on the terrain transformer calculateTileBBox samples the elevation manager and transforms in double precision.
  3. For each style layer, in order: resolve the layer's opacity/comp-op, set the per-layer depth ordinal, and draw each tile's geometry (renderTileGeometry).

Render tiles​

A render tile is a target tile id plus the source tile actually available for it (possibly an ancestor), one RenderTileLayer per style layer, with active telling whether it is the live tile or one retained for the cross-fade. buildRenderTiles merges the new set with what was on screen so a tile can blend out rather than pop.

A retained layer holds while nothing has replaced it. updateRenderTile fades an inactive layer out only when an active layer covers the same ground, or when the tile has left the view (where the blend step is 1, so it goes in a single frame). Fading it on a timer instead is a hole: the tile that will take over is still being fetched, this layer is the only thing painting that ground, and it dies in the ten frames a fetch does not fit into. The render tile then survives as an empty shell — a good tile pointer, no render layers — and everything it was drawing is simply gone. Measured on the day-cycle-light example, pinching z17–19: sixteen pieces of ground at once left covered by a shell (geom 0 extr 0 | tile layers 19 extr 2), which is what "buildings disappearing all over the place while zooming" was. This cannot strand content: the moment the tile has an active layer of its own, the branch above erases the retained one as soon as the replacement is opaque.

It holds only while the replacement is still coming. "Nothing active covers this ground" also describes a render tile built from a tile of the current set that decoded without that layer at all — the answer, not a gap. Held, the layer never dies: zooming out from z13 to z10 kept drawing the z12 tiles' #contour[zoom>=12] lines inside the rectangle those tiles covered, under a camera the style stops contours well below, and no further zoom cleared it (the render tile carried four retained contour layers at blend 1.0, with nothing active in it at all). RenderTile::current separates the two: set by initializeRenderTile, cleared by mergeExistingRenderTile, which is exactly the ground that has no new tile yet.

A hidden layer is not drawn at all. setVisible(false) makes TileLayer::loadData hand the renderer an empty tile set, but by the rule above nothing replaces the tiles on screen, so they are held for good: visible() read false while the boundaries stayed drawn, and clearTileCaches did not reach them (they live in the renderer, not the layer's caches). VectorTileLayer::onDrawFrame, onDrawFrame3D and collectLabelLayers therefore return early when hidden, as CompositeVectorTileLayer::renderComposite already did; so do the raster, hillshade and custom-raster layers, which hold their tiles the same way. The held tiles stay, so showing the layer again redraws at once. Confirmed on device in alpimaps; the raster gates are syntax-checked only.

A retained layer is inherited once per target, not once per copy. initializeRenderTile hands a new render tile the retained layers of every existing render tile it intersects, and mergeExistingRenderTile splits a retained tile into four child copies that all carry the same layers. Inherited per copy, each rebuild multiplied them by four, and the cull worker rebuilds every frame while the camera moves - faster than the cross-fade can retire them. Measured on the web (3d-models example, browser pane, wheel zoom to z18.2 at tilt 32): the render layers of 60 render tiles went 855 → 1923 → 5007 → 17307 → 66471 → 263092 → 1 049 538 in seven rebuilds, almost all duplicates. The cull worker stalled the map for 2.4 s while the wasm heap went 396 MB → 2 GB, then mi_new failed and the module aborted (RuntimeError: unreachable). A native build has more heap to grow into; the same growth there is presumably the "map hangs at very high zoom in 3D" report, not measured. insertRetainedLayer (vt/RenderTileBlend.h) now keeps one entry per layer and target, at the higher blend; tests/vt/RenderTileBlendTest.cpp runs six rebuilds of it.

The culling box has to hold what stands on the tile, not just its ground. isTileVisible tests calculateTileBBox, which is the tile's ground, and an extrusion stands out of it — so a building is still on screen well after the ground it stands on has left the frustum, and culling there takes the building with it. maplibre grows the culling elevation for exactly this reason (covering_tiles.ts, getElevationForTileCulling): nothing while the camera looks down, rising to ASSUMED_MAX_FEATURE_HEIGHT_METERS (500 m) as the frustum's bottom edge comes within 15° of the horizon. Ported whole as tileCullingHeadroom. At tilt 45 with the old default fov of 70 the bottom edge was 9.25° up, which is 38 % of the way in — 191 m of headroom; at today's 36.87 it is 26.6° up, and the headroom only starts below tilt 33 (maplibre pitch 57). Before it, five of six measured building losses during a zoom were this test.

What a draw costs​

Measured on an Adreno 610: a 6-style-layer and a 21-style-layer style submit the same ~300k indices, but 59 draws against 500, and cost 3 ms against 20. The per-frame cost tracks the draw count, not the triangle count. RenderStats therefore counts draws, indices, style layers, render tiles and surface draws separately (geomDraws=… lines).

The counters split a draw into geomProgramNs (program selection + fog uniforms), geomTerrainNs (MVP, depth bias, terrain/shadow uniforms), geomStyleNs (style parameter uploads) and geomStyleEvalNs (the colour/width functions themselves), so a regression can be attributed without guessing.

What splits a tile's style layer into several draws​

TileLayerBuilder accumulates a style layer's features into one TileGeometry and calls appendGeometry() — one more draw — whenever the next feature cannot share the vertex format or the style table: a different geometry type, a different comp-op or translate, a full 16-slot parameter table, or more than 65535 indices.

A polygon pattern is not one of them any more. A style layer that alternates patterned and plain fills (polygon-pattern-file on some features) used to split at every alternation, and that was 48% of every geometry draw in a 2D city frame — 584 draws where 337 (tile, style layer) pairs actually drew. Each slot of the style table now carries a pattern flag (patternScales, uPatternTable, packed into vUV.z), so a plain fill sits in the same geometry and the same draw as a patterned one and simply keeps its flat colour. Only a second, different pattern still splits.

Measured on a Crosscall, 2D city pan: 584 → 363 draws a frame, 17.9 → 20.3 fps, the layers CPU section 21.9 → 15.1 ms (performance-log.md 16.6).

Lines never had the problem: they go through the shared StrokeMap atlas and select their dash by a per-slot stroke id, which is the same trick one level down. The remaining floor is one draw per (tile, style layer); going below it needs cross-tile batching, which nothing here does yet.

Shaders​

Programs are built on demand from source in GLTileRendererShaders.h and cached by a key made of the shader kind plus a bitmask of feature flags (terrain, VTF, lighting, shadow, derivatives, fog, paint surface, …). Consequences worth knowing:

  • Every new flag combination is a new program compile on first use, on the render thread.
  • A uniform the compiler drops has location -1 from glGetUniformLocation, but Shader::getUniformLoc returns 0 — a valid location that then clobbers uniform 0. Always use glGetUniformLocation with a >= 0 guard (SkyRenderer is the pattern to copy).

Several rasters in one draw​

A vt::TileLayer carrying several normal-map bitmaps draws them in one call (groupRasters in RasterGroups.h, renderTileBitmaps), up to MAX_RASTER_SLOTS = 4. The custom normal-map shader reads them with sampleRaster(i) / sampleRasterAtPixel(i, px), the names tangram-es uses (core/shaders/rasters.glsl); getRawColor() is sampleRaster(0). Colour maps and mixed types keep one draw each, so today's layers draw as before. The rasters may differ in size (a 1x1 texel can carry a per-tile number); uUVScale is the first one's.

  • No shader flag. The flag word is full, and uRaster1..3 cost nothing when unused, so the prelude always declares them. Sampler values persist in the program, so every draw rewrites them.
  • A computed sampler index is illegal in GLES2. sampleRasterAt(int, uv) branches on a literal instead of indexing an array; an ES 3.0 baseline would lift the cap of 4.
  • Texture units 4, 6 and 7 carry rasters 1..3: the normal-map draw owns 0, 1 and 5, and the shadow, gradient and drape-mask units belong to programs it does not use. Eight units is all GLES2 guarantees.
  • Known gap: the rasters share one uv matrix, so they must come from the same source tile. Tangram's per-raster offsets (currentRasterUV(i)) are not ported.

Line borders (line-border-width / line-border-color)​

maplibre's line border: a casing line-border-width px wide on each side of the line. One rule replaces the ::case / ::fill attachment pair a CartoCSS style used to need.

The line quad is extruded in the vertex shader from uWidthTable, so the same vertex buffer draws at any width. renderTileGeometry uses that: with a border it issues two glDrawElements over one buffer — border table first (widths[i] + borderHalf, border colour), then the fill table. Half the tesselation, half the vertex memory and half the decode CPU of a casing/fill pair, for the same two draws.

  • Border first, for the whole batch — that is what keeps a junction clean. mapbox orders a whole casing layer under a whole fill layer; this is that order inside one draw pair, so a crossing road's fill still covers this road's casing. Colouring border and fill in a single pass from the fragment's distance would be one draw, but the two features are in the same batch with no ordering between them and the casing would stripe across every intersection.
  • line-gap-width composes: the border pass shrinks the gap by the same borderHalf, so both edges of a casing strip keep their border.
  • Shared with the fill, by construction: joins, caps, line-offset, the end arrow and the dash pattern — one geometry. A solid casing under a dashed fill is still two rules, since the pattern covers the border too.
  • Ordering against other style layers is unchanged: the pair sits where the layer sits.

Line end arrows​

line-end-arrow: true puts an arrow head on a line's last vertex, sized by line-arrow-width and line-arrow-length in multiples of the line width. It is built in tesselateLineEndArrow out of the same screen-space extrusion the line itself uses — the offsets ride the binormal attribute, which the vertex shader multiplies by the line width — so the head keeps its screen size at every zoom and needs no bitmap, no marker and no label.

These details are what make it usable rather than a triangle stuck on the end:

  • An arrowed line is never a ring. A line whose last vertex equals its first is otherwise read as closed: no caps, and no end. A U-turn arrow cut the same length either side of the turn ends exactly where it starts, and lost its head. maplibre decides closure by feature type rather than coordinates; vt only sees vertices, so a line carrying an end arrow is simply always open.

  • Only the feature's own end gets a head. A source clips a line at its buffer, so a piece crossing a tile edge ends just outside the tile; that end gets neither head nor pull-back. The test is maplibre's for symbol anchors (symbol_layout.ts): the last vertex must lie in the half-open tile [0, 1), so an end on a shared edge is drawn by exactly one tile. Without it a maneuver arrow crossing z18 tile edges grew a head at every crossing.

  • A tile edge must not clip the head, nor a neighbour's shaft cover it. Under the shared ground the line shader discards by the CENTRE-line position (lineFsh, vTileUnit), and every vertex of a head sits on its last vertex, so a head owned by a tile is never cut. In the stencil-mask path (no terrain, or a terrain that is flattened) the owner's mask cut a head that overhangs the tile's edge, and the neighbour has none to complete it. A neighbour whose buffer holds the end draws the shaft without the pull-back and with a cap, and drawn after the owner it painted that stub over the part of the head lying in its tile (the head "under its shaft", the road casings of the base map showing where the head should be, and only the ghost of line-occlusion-opacity, which has no mask, drawing the whole head). So renderGeometry2D draws a style layer in two passes when it holds a line-arrow-only slot (ArrowHeadPass.h): every tile with the head slots collapsed to nothing, then every tile again with only the head slots, the tile mask off. The head is on top of every shaft of the layer, whichever tile owns which. A flat map draws a geometry once per target tile it overzooms into, so the head pass draws each geometry once; a stand-in tile under a live one skips it. A line-arrow-only slot never shares a slot with a plain line of the same colour and width (TileGeometry::StyleParameters::arrowOnly), or the pass could not tell the head from the shaft.

  • The head hangs on its incenter, and the line is pulled back to the head's base by the same amount. A homothety about the incenter moves every edge of a triangle by the same distance, so two heads about a common incenter stay a constant distance apart everywhere. Anchoring the tip instead pins them together at the point: the border is then zero at the tip and widest at the base, a wedge.

  • A casing rule must not simply repeat the numbers. They are multiples of its own width, so a 13-wide casing over an 8-wide fill draws a head 13/8 bigger — a border round the head ≈1.7× the (casing − fill)/2 the shaft has. Because the two heads are similar triangles about a common incenter, the casing's numbers can be worked back from the border you want; DemoStyles (maneuverCasingArrowScale) is the formula, and it is zoom-independent — it depends only on the ratio of the two widths.

  • line-arrow-only draws the head and nothing else, so a style can order the parts by hand: shaft casing, shaft fill, head casing, head fill. The head then paints OVER the line and keeps its silhouette where it lands on its own shaft — a U-turn seen from far enough away — instead of dissolving into it.

  • A head drawn on its own has a SLOT cut out of its base, one line width wide: the width of the very line the same rule draws elsewhere, so it needs to know nothing about the other rule. The slot leaves the shaft it docks on untouched, so no bar of head colour crosses the line, while the shoulders, barbs and tip still paint over it. That is what makes shaft and head read as ONE polygon while the head still sits on top. Both alternatives were tried and are worse: no slot with the head on top puts a casing bar across the line; casings-then-fills (a union order) loses the head's outline wherever it overlaps its own shaft.

  • line-arrow-path replaces the built-in triangle with a contour of your own — the d attribute of an SVG path: M/L/H/V/C/S and Z, absolute or relative, curves flattened. Any viewBox works, because the contour is fitted into the arrow box — line-arrow-length along the line by line-arrow-width across it — and SVG's downward y is flipped. So the two size properties still drive the size in path mode. The fitted contour is a SKELETON: what is drawn is it offset outward by half of each rule's own line width, triangulated by ear clipping. line-arrow-scale multiplies the fitted contour and line-arrow-rotation turns it about its centre, in degrees clockwise, relative to the direction of travel — an icon drawn pointing up needs 90. A custom head is CENTRED on the last vertex and carries no docking slot: a slot is a notch where the shaft enters an arrow head, and cutting one through the middle of an icon only gashes it. Centring also matters for the border: place the contour by its back edge instead and the casing, whose units are its own wider line, sits further back than the fill, so the border reads slid along the arrow rather than wrapping it.

  • One binormal unit is a QUARTER of the line width — the line's own edge sits two units out. Measured, not assumed: an offset of one unit made the head's border exactly half the shaft's, which is what the eye caught first.

  • A casing rule must shrink its box by the ratio of the widths (fill / casing). The box is in multiples of the line width, so repeating the fill's numbers draws a skeleton 13/8 bigger on top of its own larger offset — the border comes out about twice too thick. Scaled back, both rules describe the same skeleton and the offsets alone make the border, exactly (casing - fill) / 2. DemoStyles.maneuverStyle does it in one line; the built-in triangle needs the incenter formula instead, because its numbers describe the drawn shape rather than a skeleton.

  • Custom heads must be CONVEX. Offsetting a contour that turns back on itself folds the offset over and the border blows out into blobs where the shape is concave — a Homer Simpson silhouette renders, docks and scales correctly, and its hair is a mess. Removing those loops is a polygon offsetting algorithm of its own (Clipper-style); a maneuver arrow head has never needed one. LineSymbolizer::isConvexArrowPath logs a warning rather than silently redrawing the shape.

    This is the part to replace when a custom (SVG) head lands: treat the head's outline as a skeleton stroked by the line width, the way the shaft already is, and the border falls out for any shape with no arithmetic in the style. Scaling only works because triangles are special — growing an arbitrary polygon is a Minkowski offset, not a scaled copy.

  • The head is solid, with no antialias ramp. The distance field a line carries (vDist against vWidth in lineFsh) describes a band one width wide; stretched over a triangle it faded the silhouette near the barbs while leaving the tip hard. The head's corners carry a zero distance, which the fragment shader keeps opaque — so its edges are aliased, like polygon fills here.

A navigation maneuver arrow is one line with this property set: see 15-maneuver-arrows.md.

Line joins​

TileLayerBuilder::tesselateLine picks per vertex between a miter, a bevel, a round fan and a split, from the dot product of consecutive binormals. What is load-bearing in it:

  • line-miterlimit is a ratio, miter length over line width, and the stroke width must not enter it. It used to (asin(min(width/limit, 1)) as the half-angle), which cut in two wrong directions at once: a 0.8-wide contour kept mitering into a needle five half-widths long, while any line wider than the limit never mitered at all. dot = 2/limit² − 1 is the whole rule.

  • The INNER corner reaches the true miter only as far as its segments have room, and the cap is applied in lineVsh, not at build time. line-miterlimit only picks which branch runs; the bevel/round branch still has to put the inner corner somewhere. At the true miter point, 1/cos(turn/2) half-widths out, a 161° turn (one notch short of the split branch's −0.95) is 6.06 half-widths, and any segment shorter than that grows a needle past its own end: the spikes that roundabouts and slip roads grew at z11–12. Clamped to one half-width (INNER_MITER_LIMIT, the previous fix), the spikes went, but a right angle lost the last 0.41 of its corner: the line narrowed into every turn, and where a tile edge cut the line next to a join, the neighbouring tile drew the pieces without a join, at full width, so the inner edge stepped at the tile boundary. A maneuver arrow showed both, since nothing crosses its turns to hide them.

    The builder can't decide alone: the miter is in half-widths, a screen quantity, while the segment is in tile units, and the zoom that relates the two is a draw-time value. So the inner vertex carries the true miter in its binormal, plus a cap in the line's otherwise unused height slot. The cap is the bisector distance at which the corner still fits its segments: all of a segment that starts or ends the line, half of one shared with another join. lineVsh then extrudes to clamp(cap, 1 half-width, true miter). That is exact at every zoom where the segments allow it, and never thinner than the old clamp. tests/vt/LineJoinTest.cpp runs the same clamp: 161° on segments of 1, 2 and 4 half-widths still reaches no further than 1.05, and a right angle is now painted to its miter point. Offset lines, and transformers that rescale vectors (the globe), keep the one-half-width clamp: the cap is a tile-unit length, so it only holds where a binormal unit and a coordinate unit are the same length. A line-join: miter still reaches line-miterlimit. Cost: the height slot is 4 bytes after padding on a 16-byte line vertex, +25 % for every line geometry that has at least one such corner. Not measured on device.

  • A sharp join must not overlap itself. Two full-width quads meeting at a point overlap in a lens on the inside of the turn, and every pixel of that lens blends twice — which is what darkened a line with line-opacity at each hairpin. The inner corners are collapsed onto the centre line instead (mapbox's inner join) so the quads only touch; one triangle closes the outer gap. Offset lines keep the old overlapping split: their offset is binormal × offset × side in the shader, so a zero binormal would drop the offset entirely.

  • A sharp join is still a round join. Turns sharper than a right angle leave the bevel/fan branch for the split branch above, and closing that corner with the single triangle cuts it flat — the join reads as square. It only shows on geometry sparse enough for a turn to pass 90°, which is why simplifying a route surfaced it, and it comes and goes with zoom because which vertices survive simplification is decided per tile zoom. The same fan runs in both branches.

  • line-join: round builds tangram's 5-triangle fan (ROUND_JOIN_TRIANGLES, their JoinTypes::round). Getting it right took three device rounds, all invisible in a syntax check: the fan vertices must sit between the two cross-sections (appended after them, the next segment links to fan vertices and the line comes apart) — in the split branch as well as the bevel one, where it was the reason a segment following a turn past 161° lost its inner half; the hub must be on the centre line, not the miter point (that point is at zero alpha in the antialias ramp); and two extra triangles must close the sliver against each quad's end chord, which runs from its outer corner to the miter point and so does not pass through the hub. Winding mirrors with the turn direction — 2D geometry is drawn with back-face culling on, and a fan wound one way loses every join that turns the other way. Below ~10° the fan is skipped for a plain miter: tangram fans at every angle, but their line shader has no AA ramp and five near-degenerate slivers each carrying one is a visible seam.

Translucent layers: draw-once groups (opt-in)​

A translucent style layer blends a pixel again wherever its geometry overlaps itself: two ways meeting with round caps, a crossing, a cap inside the next way of the same road. It reads as a disc at every join (the Massif hybrid's roads over the imagery). MapLibre blends it twice too; Mapbox GL JS does not, for a constant line-opacity: draw_line.ts draws the layer twice through the stencil.

The automatic pass was removed once already, because "one blend per pixel" belongs to a symbolizer, not a pixel:

  • A second symbolizer in the same attachment is punched out. back/line-… is the same vt layer; the wide back line stamps its footprint first and the main line, inside it, is rejected everywhere ("I see the back line, I never see the line").
  • Every join grew a seam: the first fragment owned a pixel, so a partly covered antialias edge was never filled in by its neighbour.

So it came back opt-in, per group, as the CartoCSS style-level property draw-once: '<group>' (an expression: param:: may pick it, '' is off). TileReader evaluates it per tile into TileLayer::getDrawOnceGroup(). renderGeometry2D treats each run of CONSECUTIVE style layers with one group as one translucent layer (vt/DrawOnceOrder.h):

  • Core pass: every line of the run, top layer first, compiled with DRAW_ONCE_CORE (discard below 0.99 coverage) and glStencilOp(KEEP, KEEP, GL_INVERT) on stencil bit 0x80 only, above the tile mask values: a pixel gets the first, i.e. topmost, colour once. Non-line content waits.
  • Rim pass: the run again, plain shader (no discard, so the stencil rejects early), where the bit is still clear: the antialiased edges, and everything that is not a plain line. Then the bit alone is cleared (glStencilMask(0x80) + glClear); the tile masks under it are not redrawn.
  • Without tile masks (terrain, live lines) the stencil is cleared to 0 for the run. No stencil buffer, 128 tile masks or more, or a comp-op overlay: the group draws as plain layers.
  • Drape bake (3D terrain, DrapeLinesEnabled): bakeLayersDrawOnce attaches a lazily made GL_STENCIL_INDEX8 renderbuffer, the size of the bake viewport, to whichever drape FBO is bound, runs the same schedule per baked tile and detaches it. A tile with no group touches no stencil.

Running each pass over the WHOLE run, not per draw, is what removed the seams; batch by batch the first batch's rim was already down, unstamped, when the next batch's core landed on it. Mapbox's threshold is 0.8: measured in the style preview (hybrid, Grenoble Rue Thiers, z17.6), a cap's 0.8–1 coverage edge then claims pixels inside the next way and leaves a dotted ring where the disc was; 0.99 stamps only fully covered pixels and the ring is gone.

The ghost pass (line-occlusion-opacity, 01-frame.md) runs the same schedule with one built-in group, so a ghosted casing never tints its fill.

A casing (line-gap-width) shares its fill's group: its round end otherwise shows through the next way's translucent fill as a ring. A second line symbolizer in ONE grouped attachment is still punched out, as above.

Cost, measured on the Crosscall HLTE556N (Adreno 610, Release native, Massif from the bundled massif.zip over the Akylas tiles and Esri imagery, Grenoble 5.7245/45.1885 z16.5 tilt 45, scripted pan, PROF windows, APKs interleaved, two rounds, fps):

flat, masterflat, draw-once3D terrain, master3D terrain, draw-once
streets17.4 / 17.217.6 / 17.717.1 / 17.318.6 / 17.3
hybrid20.8 / 20.216.5 / 16.721.7 / 21.520.8 / 19.7

No group, no cost. Hybrid pays for drawing its roads twice: indices per frame 2.1M → 4.3M, draw calls 477 → 823 (~24 µs CPU each), GPU layers 7.9 → 14.2 ms. A first version also redrew the tile masks after every group and kept the discard in every line shader: 16.1 / 16.3 fps flat; moving the stamp to its own bit and the discard behind a define bought only 0.4 fps, so the second geometry pass is the bill. On terrain the lines bake into the drape, so only bakes pay.

The other route is still a layer's own opacity + comp-op, which draws the layer opaque into the overlay buffer and composites it once: a full-screen pass per layer, no depth in 3D terrain. Measured on an Adreno 610 while the automatic pass existed (demo route, casing + fill, translucent, scripted pan, two reps of 25 one-second samples): 37.7 fps with the pass against 26.5 fps through the overlay buffer, layers 2.43 vs 3.62 ms.

Lines over terrain​

A line is a chain of quads whose width is an offset along a per-vertex binormal, and three things about that offset are easy to get wrong. All three were, and the symptoms all looked like terrain or depth bugs rather than line bugs.

Clip every line fragment to its own tile. Lines are built with a clip buffer of an eighth of a tile (TileLayerBuilder.h, _clipBox = −0.125…1.125; polygons only 0.002), so every tile carries a long stretch of its neighbours' roads and draws it — displaced with its own target tile's elevation texture and lattice, which is a different DEM level than the tile that overflow actually lies on. The same road is then painted twice at two different heights: from straight down the copies coincide and it looks perfect, and the moment the camera tilts they separate. That tilt-only signature is the tell. The stencil tile masks were what used to clip this, but renderGeometry2D turns them off under the shared ground (maskStencilBits = _terrainSharedGround ? 0 : stencilBits), so they never run in terrain mode. lineFsh therefore discards outside the tile, using uTileUnitScale / uTileUnitOffset (vertex-frame units → TARGET tile units, set in setupTerrainUniforms; a 0 scale means no elevation, which disables the test) and a vTileUnit varying. No attachment, no extra draw.

The offset is what makes this survive a STAND-IN, and it was missing. For content, setupTerrainUniforms is called with the target tile (whose elevation texture the content stands on) and the source tile's vertex frame (the vertices are source-tile-local). Source and target are the same tile normally — but not while a tile loads, when an ancestor stands in for it. With a scale and no offset, vTileUnit = pos.xy * uTileUnitScale put the source's unit square in [0, 2^dz], so the > 1.0005 test discarded everything except the one quadrant that happened to land in [0, 1]. Since every visible tile becomes a stand-in for a second or two after an integer zoom step, all line content — contours, roads — vanished at every integer zoom in terrain mode, and only in terrain mode (the test is off without elevation). Measured at lat 45.210031 lon 5.730591 z14.99 tilt 26 over a scripted zoom, contour pixels per frame went 26k → 30 → 23 409; with the offset they decay smoothly and never collapse. Measuring the position from the target tile's own origin (offset = (frame(i,3) − target(i,3)) / target(i,i), signed, not abs()) gives each target its own share of the ancestor, so the four together still paint the whole thing, each with the elevation mapping of the surface it stands on.

The same scale also feeds the lattice edge-coarsening test in commonVsh (terrain), which is wrong for a stand-in by the same argument. It is deliberately not changed: adding the offset there moves settled contour positions (2.8 % of the frame) because it changes elevation interpolation, which needs judging on device rather than bundling into a clipping fix.

What this was not, each ruled out by measurement before the clip was found: missing draw data, deep or empty stand-ins, the renderer not being fed, elevation-texture misses, render-tile blend (blend was 1.0 throughout), and the terrain coarsening floor _terrainMinTileZoom — that one does re-cull the whole far field at every integer zoom, but the blank is identical with MaxTileZoomCoarsening raised so the far field does not churn, so it only lengthens recovery.

Width: tangram's model, capped at nominal. The offset is extruded in model space and displaced onto the terrain, exactly as tangram does (polyline.vs + res/scenes/terrain-3d.yaml), so a line is a world quad through the projection and tapers with distance. Left unbounded it also grows towards the camera until a near contour is a blob, so the projected offset is measured and shrunk back to the nominal width when it exceeds it. The factor is ≤ 1 by construction — it can never manufacture an oversized quad, which is what an unbounded screen-space fit does.

The ceiling is this vertex's own extrusion, roundedWidth * length(aVertexBinormal * uBinormalUnitScale), not one line width. Not every line vertex sits one width out: a round cap's corners are at √2, and an end arrow's barbs at several. Clamping those to one width squashes the shape they belong to back into the line's silhouette — which is what made line-end-arrow draw nothing at all in terrain mode while it drew fine on a flat map.

aVertexBinormal is PACKED — its raw length is not a number of line widths. Binormals ship as GL_SHORT, un-normalised (GL_FALSE), against a per-geometry scale (calculateScale in TileLayerBuilder, a power of two ≈ 32768 for unit vectors), which is why the extrusion itself reads aVertexBinormal * (uBinormalScale * roundedWidth). Taking length(aVertexBinormal) alone made the ceiling ~32768× too large, so edgeLen > nominalLen never fired and every terrain line fell back to tangram's unbounded world-space extrusion: near contours grew into fat, soft-edged blobs (their antialias band grows with them), roads read as draped again, and neighbouring vertices no longer agreed on a width so joins came apart. uBinormalUnitScale (1 / binormalScale, uploaded with uScreenScale) undoes the packing: 1 for a plain vertex, more for a miter, a cap corner or an arrow barb.

The capped vertex takes its DEPTH from the terrain, and its XY from the screen. Both halves are load-bearing. Applying the shrunk offset to centerClip.xy and keeping centerClip.z — the obvious way — gives the outer edge of a wide line the centreline's depth; on a cross-slope that is below the ground on the uphill side, so the depth test against the terrain surface eats the line in a ragged, stair-stepped band. The widest layer hits the cap first, which is why a route's white casing broke up while its blue fill survived, and why it only showed at a tilt. Shrinking the world position instead fixes the depth but makes the projected width only approximately nominal, and it drifts vertex to vertex — segments of visibly different width at z14.38. So: screen-space xy for the width, mix(centerPos, edgePos, shrink) for the depth.

Ruled out first, each by measurement, before the cap was suspected: line tesselation and joins, the route source's simplify tolerance (real but separate — it is applied per TILE ZOOM by MBVTTileBuilder's geojson-vt index, so a coarse tile collapses hairpins into chords), TerrainOptions::MeshResolution (32/64/128, no effect) and Options::TileLODFactor (no effect). The two A/Bs that settled it: with the content depth test disabled the casing is complete, and with the cap disabled the casing is complete but every line is visibly fatter.

Antialias in device pixels, from a per-frame constant. The ramp is one unit of the quad, and a unit is not a pixel: widths are unscaled-DPI units, so at 2.6× density one unit is ~1.8 device px and a 1 px contour is almost entirely ramp — that is what "blurry contours" was. uAntialiasScale (screen height ÷ normalized resolution, set by TileRenderer) makes the ramp one device pixel; measured on a contour at z17, the edge transition went 5 px → 2 px and the solid core 4 px → 8 px. GL_OES_standard_derivatives is not exposed on this context, so an fwidth-based ramp is dead code — check by forcing a = 0 in that branch and seeing whether the line still draws.

Never measure a line's screen width per vertex against roundedWidth. A screenHalfWidth / roundedWidth varying is in the tile's units, and the terrain LOD picks tiles up to MaxTileZoomCoarsening (default 3) levels below the camera zoom on a grazing slope. There the ratio is wrong by the zoom difference, the antialias ramp becomes a hard cut, and lines break into fat wedges and detached triangles. For the same reason, never divide the offset by centerClip.w + deltaClip.w: that is a second perspective divide by something that is not a position's w, and it explodes when the offset is large in world units.

Polygon patterns keep their phase across a tile border​

A polygon-pattern is a repeating texture sampled with GL_REPEAT, and the texcoord a vertex carries is tile-local: TileLayerBuilder::tesselatePolygon gives the tile a phase u0 and a step du_dx across it. The fragment stage samples uPattern at texCoord / (texCoordScale * widthScale), and texCoordScale is only the int16 packing scale, so it cancels — one pattern period is bitmap->width * widthScale in texcoord units.

u0 has to wrap at exactly that. It used to wrap at the bitmap's width, which is the same number of texels but not the same units, so a fraction of a period was dropped at every tile border and the pattern stepped across it. MapTiler's 25 px construction hatch spans 18.2 periods per tile — a fifth of a period lost each time. The accumulation is done in double: a z21 tile index reaches 2^21, and a float step loses the remainder long before that.

Wrapping at a period leaves the pattern's size untouched, which matters — the shipped eink style spans 0.9 (a 512 px scrub) to 14.2 (a 32 px vineyard) periods per tile, and rounding the step to a whole number of periods instead would have redrawn the big ones 11% smaller.

It only shows at high overzoom. At z17 a drawn tile is most of the polygon and the few borders fall outside it; at z21 a tile is a few hundred pixels and the borders are all over a single polygon, which is why a hatch looked fine zoomed out and broke up zoomed in.

Measuring it: a diagonal hatch is invariant under a 1 px diagonal translation, so |px(x, y) - px(x+1, y-1)| averaged down a column is ~2 everywhere and spikes at a discontinuity. Before, the two tile borders on screen scored 279 and 144 (140x and 72x the median); after, nothing exceeds 8 — the pattern's own stripe edges. Estimating the stripe period per window and comparing phases does NOT work: a fractional error in the period accumulates across the gap and reads as a jump that is not there.

Style evaluation​

CartoCSS values may be functions of view state (zoom, style parameters), so colours, widths and opacities are evaluated per frame per style layer through small caches keyed by the function plus view state (_colorFuncCache and friends, with styleFuncLookups/styleFuncMisses counters).

The cache is keyed by the function OBJECT, so it only helps when features share one. A function that reads a feature field as well as the view (Standard's linear([view::brightness], (0.3, ([class] = 'pedestrian') ? 9 : 6.5), ...)) used to be built per feature, carrying the whole expression, and was evaluated per label per frame - feature lookups and an interpolation curve rebuilt on every call. foldContextExpressions (mapnikvt/ExpressionUtils.h) now evaluates the parts that read neither the view nor a style parameter when the function is built, and GenericFunctionProperty::getFunction hands every feature that folds to the same expression one shared object. Crosscall, Grenoble z17.2 tilt 45: the labels' render-thread CPU 7.05 -> 5.8 ms a frame, 19.45 -> 20.2 fps (shadows on: no change measured).

Interaction with the rest of the frame​

  • The renderer holds one mutex covering its tile/label state. The label placement worker holds it for the whole of buildLabelMaps, which is the only place the GL thread has been observed waiting on it (mutexWaitNs); during pans it has measured 0.00 ms.
  • endFrame sweeps every compiled-resource map (bitmaps, surfaces, geometries, label batches) looking for expired owners — cost tracked as endFrameNs / endFrameSwept.
  • Client-side vertex arrays and bound VBOs are a cross-renderer hazard: the terrain paint pass once left GL_ARRAY_BUFFER bound and SkyRenderer, which draws from a client array, turned its quad into an offset into that buffer — the sky went black. Unbind after every draw loop.
  • State the shared ground needs is pushed in prepareFrame, not onDrawFrame. MapRenderer draws the cross-layer terrain ground BEFORE any layer's onDrawFrame runs, so anything renderDrapedSurface reads has to be set earlier - that is what TileRenderer::prepareFrame already does for the view state, the lighting and the ground AO. terrainMode, the regular grid and the elevation texture provider were missing from it, and renderDrapedSurface returns -2 without all three: on the first frame of a 2D->3D switch every surface bailed and the map drew with NO ground - sky through it, buildings still there, one frame. pushTerrainState sends them early; onDrawFrame still sends the authoritative values later in the same frame.
  • A VAO belongs to ONE program. CompiledGeometry keeps a VAO per program, built on first use. A VAO records pointers per attribute INDEX, and one geometry is drawn by several programs whose locations need not match — the main pass and the sun caster, or the TERRAIN and flat variants of polygon3DVsh. Re-specifying a single VAO for a second program is legal GL and the state reads back correct, but on the Crosscall's Adreno the next draw rasterises garbage: extrusions smeared into screen-long diagonal streaks, then kgsl gpu fault and SIGKILL. Auto-flatten is what used to reach it, by switching the 3D geometry between the two variants with the tiles kept (04-terrain.md). Deleting and re-creating the VAO on each program change also cures it but ran 4000+ times a minute, so the per-program VAO is the one to keep.