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:
- Bucket every visible render tile's layers by style layer index into
renderLayerMap. - 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
calculateTileBBoxsamples the elevation manager and transforms in double precision. - 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
-1fromglGetUniformLocation, butShader::getUniformLocreturns 0 — a valid location that then clobbers uniform 0. Always useglGetUniformLocationwith a>= 0guard (SkyRendereris 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..3cost 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-widthcomposes: the border pass shrinks the gap by the sameborderHalf, 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 ofline-occlusion-opacity, which has no mask, drawing the whole head). SorenderGeometry2Ddraws a style layer in two passes when it holds aline-arrow-onlyslot (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. Aline-arrow-onlyslot 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)/2the 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-onlydraws 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-pathreplaces the built-in triangle with a contour of your own — thedattribute of an SVG path:M/L/H/V/C/SandZ, absolute or relative, curves flattened. Any viewBox works, because the contour is fitted into the arrow box —line-arrow-lengthalong the line byline-arrow-widthacross 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-scalemultiplies the fitted contour andline-arrow-rotationturns 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.maneuverStyledoes 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::isConvexArrowPathlogs 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 (
vDistagainstvWidthinlineFsh) 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-miterlimitis 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² − 1is 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-miterlimitonly 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.
lineVshthen extrudes toclamp(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.cppruns 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. Aline-join: miterstill reachesline-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-opacityat 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 isbinormal × offset × sidein 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: roundbuilds tangram's 5-triangle fan (ROUND_JOIN_TRIANGLES, theirJoinTypes::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) andglStencilOp(KEEP, KEEP, GL_INVERT)on stencil bit0x80only, 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-opoverlay: the group draws as plain layers. - Drape bake (3D terrain,
DrapeLinesEnabled):bakeLayersDrawOnceattaches a lazily madeGL_STENCIL_INDEX8renderbuffer, 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, master | flat, draw-once | 3D terrain, master | 3D terrain, draw-once | |
|---|---|---|---|---|
| streets | 17.4 / 17.2 | 17.6 / 17.7 | 17.1 / 17.3 | 18.6 / 17.3 |
| hybrid | 20.8 / 20.2 | 16.5 / 16.7 | 21.7 / 21.5 | 20.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,
setupTerrainUniformsis 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 * uTileUnitScaleput the source's unit square in [0, 2^dz], so the> 1.0005test 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, notabs()) 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 (
blendwas 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 withMaxTileZoomCoarseningraised 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.
aVertexBinormalis PACKED — its raw length is not a number of line widths. Binormals ship asGL_SHORT, un-normalised (GL_FALSE), against a per-geometry scale (calculateScaleinTileLayerBuilder, a power of two ≈ 32768 for unit vectors), which is why the extrusion itself readsaVertexBinormal * (uBinormalScale * roundedWidth). Takinglength(aVertexBinormal)alone made the ceiling ~32768× too large, soedgeLen > nominalLennever 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 withuScreenScale) 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.xyand keepingcenterClip.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) andOptions::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. AscreenHalfWidth / roundedWidthvarying is in the tile's units, and the terrain LOD picks tiles up toMaxTileZoomCoarsening(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 bycenterClip.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. endFramesweeps every compiled-resource map (bitmaps, surfaces, geometries, label batches) looking for expired owners — cost tracked asendFrameNs/endFrameSwept.- Client-side vertex arrays and bound VBOs are a cross-renderer hazard: the terrain paint pass once
left
GL_ARRAY_BUFFERbound andSkyRenderer, 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, notonDrawFrame.MapRendererdraws the cross-layer terrain ground BEFORE any layer'sonDrawFrameruns, so anythingrenderDrapedSurfacereads has to be set earlier - that is whatTileRenderer::prepareFramealready does for the view state, the lighting and the ground AO.terrainMode, the regular grid and the elevation texture provider were missing from it, andrenderDrapedSurfacereturns-2without 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.pushTerrainStatesends them early;onDrawFramestill sends the authoritative values later in the same frame. - A VAO belongs to ONE program.
CompiledGeometrykeeps 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 theTERRAINand flat variants ofpolygon3DVsh. 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, thenkgsl gpu faultand 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.