Skip to main content

Vector elements, billboards, and picking

Scope: everything drawn from app-supplied objects rather than from tiles — markers, popups, labels placed by the app, lines, polygons, 3D polygons, NML models — plus how a tap is turned into a hit.

The renderers​

VectorLayer holds a set of VectorElements and hands each to a renderer by kind (all/native/renderers/):

rendererdraws
PointRendererpoints
LineRendererlines
PolygonRendererfilled polygons
Polygon3DRendererextruded polygons
GeometryCollectionRenderermixed collections, delegating per geometry
BillboardRenderermarkers, popups, labels, balloons, NML models — anything screen-facing (NMLModel is a Billboard)

Elements are converted to draw data (renderers/drawdatas/) when they change, and the renderer consumes draw data, not the elements themselves — so an element edited from the app thread never races the render thread.

Billboards​

Billboards are screen-facing, so they need placement (where on screen, does it collide) rather than just a transform. BillboardPlacementWorker recomputes that off the render thread; the frame kicks it when MapRenderer::billboardsChanged has been set.

BillboardSorter orders them back-to-front for correct alpha blending — billboards are drawn after the layer's geometry, in a pass of their own (billboards in PROF).

Text on a billboard​

BalloonPopup, Text and the popup buttons rasterize their text into a bitmap with BitmapCanvas, which is the platform text API (Android Typeface, iOS CoreText, UWP DirectWrite) — not the FreeType/HarfBuzz path the tile labels use. Their font names go through the same list syntax and the same system-font matching as a style's text-face-name; see 06-labels.mdx.

Terrain interaction​

Two mechanisms, and they are different things:

1. Occlusion (billboards). A marker behind a ridge must fade out. The terrain depth is rendered into an FBO and read back (TerrainRenderer, sampleDepthW), and each billboard's draw data carries a terrainOcclusionOpacity that is animated toward the target (updateTerrainOcclusionOpacity) rather than switched, so a marker crossing a crest fades instead of blinking. The read-back is a pipeline stall, so it runs on TerrainDepthWorker at an interval and the occlusion is allowed to lag a gesture — the depth texture is a half-resolution approximation sampled with a mesh capped at DEPTH_TEXTURE_MESH_RESOLUTION = 32. See 04-terrain.md.

2. Depth (geometry elements). Lines and polygons drawn on the terrain are displaced like tile content and depth-tested against the ground. They get their own small forward bias, deliberately much smaller than tile content's: the eye tolerance of a constant-NDC bias grows as distance²/near, and a large one is exactly how a vector line shows through a ridge from the far side. Their clip-space slack is not scaled with MeshResolution the way tile content's is, because an element's chord error follows its own tesselation, not the terrain mesh's — scaling it over-clipped lines on shoulders when it was tried.

They are also lifted a fifth of a DEM texel (TerrainProjectionSurface's _heightLift, up to ~8 m on a z12 DEM) to stay clear of a concave mesh. The lift is scaled by the current exaggeration, so on flattened terrain - the app's "2D", terrain still enabled - it is 0, and the flatten ramp's version bumps carry it along. Billboards (markers, labels, popups) get no lift: they draw without a depth test, so it could only float them - a marker sat metres above a flattened map. Their occlusion already gives a terrain anchor MIN_OCCLUSION_TOLERANCE of slack. 3D models are depth-tested and keep it.

Billboards stand on the drawn ground. A draw data's height is baked on the fetch thread from ElevationManager's grid cache (TerrainProjectionSurface::calculatePosition), and rebuilt only by the elevation-version refresh, debounced to ELEVATION_REFRESH_DELAY (500 ms). That grid is not the one the ground is drawn from, and while DEM tiles land it walks through coarser ancestors (0 when nothing is cached), so a marker - the user location, a selection - stepped up and down and floated off or sank into the visible ground each time. BillboardRenderer::anchorOnDrawnTerrain now re-resolves every marker's height on the GL thread from what the frame draws, as tile labels do (ElevationTextureCache::getDrawnDisplayHeight, then the texture cache's own grid without a fetch), whenever the drawn version, the exaggeration or the integer zoom changes - the events tile labels re-anchor on, so the two agree. Off the drawn ground the baked height stands. NML models (placed by their frame matrix) and labels attached to another billboard (placed from it) are left alone. Lines and polygons still take the baked height. The placement worker reads the position on its own thread, so BillboardDrawData::getPos returns a copy taken under _posMutex.

If a vector line disappears into the terrain or shows through it, read 05-depth-model.md before touching any constant here.

3. The elevation warm-up. VectorLayer::FetchTask::loadElements loads the elevation under the elements before their draw data is built, on the fetch thread, so a line is not drawn half draped (vertices with and without heights give near-vertical segments). It samples the bounds of each loaded element. It used to sample a 4×4 grid over the cull envelope instead, and in terrain mode that envelope reaches the view distance: its corners land hundreds of km away, off the DEM coverage, so every fetch task blocked on elevation tiles that could only 404 — measured on a Crosscall, 15 failing HTTP round trips per startup, re-issued each time the failure markers expired (FAILED_TILE_TTL_MILLISECONDS, 30 s) while panning.

Picking​

MapRenderer::calculateRayIntersectedElements turns a screen position into a world ray and asks every layer:

  • Vector layers intersect the ray against their elements' geometry.
  • Tile layers (TileLayer::calculateRayIntersectedElements) intersect against tile content; for raster/bitmap hits GLTileRenderer::findTileBitmapIntersections walks the tile's CPU surface meshes to find where the ray meets the displaced ground.
  • TileLayer::processClick then decides what to report to the app.

Known gap: in regular-grid mode (which is every terrain configuration we ship) the per-tile CPU surfaces are never built, so _tileSurfaceMap is empty and the bitmap intersection path finds nothing. The fix is to build a tile's surface lazily on the pick — a pick is a user gesture, not a frame, so it can afford it — or to serve the query from the terrain depth read-back the way tangram does (ElevationManager::elevationLerp over the texture's own CPU buffer, with a one-entry memo).

UTF grids​

TileLayer::setUTFGridDataSource attaches a UTF-grid source for feature interactivity; hits are resolved from the grid rather than from geometry, and reported through UTFGridEventListener.

App content that is NOT a vector element​

Content the app owns can also be served as vector tiles from an in-memory source and styled by CartoCSS — which keeps it on the tile path (terrain tesselation, shared drape, style-driven z-order) instead of this one, and lets style properties do work an element would need new C++ for. Navigation maneuver arrows are built that way: see 15-maneuver-arrows.md.