Skip to main content

3D terrain: elevation, surfaces, and the shared ground

Scope: how the ground is built and drawn. Depth relationships are in 05-depth-model.md; shading of the ground is in 07-hillshade-contours.md.

Elevation data

ElevationManager (all/native/terrain/) owns decoded ElevationTileGrids (one per DEM tile) in an LRU cache and answers height queries: getTileGrid, getDisplayHeight, getMinMaxDisplayHeight, with a LoadMode (CACHED_ONLY never blocks). Every consumer — the mesh, label anchoring, element placement, billboard occlusion — must go through it, or two parts of the frame disagree about where the ground is.

The grid cache is a tile count, not a byte budget. A grid is the source raster (768 KB for a 512×512 RGB DEM, 192 KB for a 256×256 one), so a fixed 64 MB meant 85 grids for one source and 340 for another. One terrain view needs 122–167 of them — the cover pyramid, the contour source's finer tiles, the border prefetch — and every grid past the limit evicted one still in use, which was then decoded again on the next pass: 1525 loads of 167 distinct tiles, 32 s of WEBP decode per startup on a Crosscall. The cache now grows on the first decoded grid to hold MIN_CACHED_GRIDS (192) of them, i.e. 144 MB for a 512² source and 36 MB for a 256² one; 192 was the first value where loads equalled distinct tiles (128 still re-decoded ~20%). TerrainOptions::setElevationCacheCapacity still wins over the rule, for an app that cannot spend the memory.

A decoded tile asks for a frame. Every consumer reads the elevation version from inside a frame (TileRenderer::onDrawFrame compares it and invalidates the surfaces it covers), so a tile that lands after the last drawn frame is never applied: the map goes idle on a half-displaced mesh — cracks at LOD rings, flat far ground — and only catches up on the next gesture, which is why "pan a pixel and it fixes itself" was the symptom. ElevationManager::setDataChangedListener closes that: MapRenderer hooks it on the terrain path and every decoded grid requests a redraw. Measured on the 3D-terrain example, cold: frames stopped at elevation version 43 while loading ran on to 159; with the listener the last frame follows the last decoded tile by 146 ms and has consumed it.

setSurfaceResolution no longer caps the elevation level (it did, at one texel per half cell, and the cap blurred the hillshade). It sizes the node field every decoded grid carries instead — the DEM box-filtered to one mesh cell, which is what the surface is displaced from and what every display-height query answers with. See The node texture.

Which DEM tile a render tile uses is tangram's rule verbatim (RasterSource::addRasterTask): subTileID = tileId.zoomBiasAdjusted(zoomDiff).withMaxSourceZoom(maxZoom) — the render tile's own z/x/y adjusted by the source's zoom bias (one level per doubling of tile size, since a 512-texel tile at z−1 has a 256-texel tile at z's density), capped by the source's max zoom. Nothing else: no cap against what the mesh can express, and no detail dial on top. Those were this fork's, and they are what made the hillshade blurry.

clampTileZoom is deliberately not idempotent — it drops the bias on every call. Applying it to an already-resolved elevation tile (getTileGrid on a getDataTile result, or on a neighbour of one) costs another level per hop, which is why the elevation-tile entry points call clampDataTileZoom instead.

The prefetch queue loads the nearest tile first

DEM tiles are fetched off the render thread by prefetchTileGrid / runPrefetchWorker (PREFETCH_THREADS = 3, queue capped at MAX_PREFETCH_QUEUE_SIZE = 64 per level, deduplicated by tile id). Requests carry a priority: 2 is a tile's own elevation level, 1 an edge neighbour (a texel of border), 0 a diagonal one (a single corner texel). 2 has a queue of its own; a tile displaced by a coarser ancestor tears against neighbours that have their own level, and no amount of nearness makes up for it.

Within a priority level the order is nearest the camera first. There used to be no distance term at all — the queue drained newest-first, on the reasoning that the newest request belongs to the current viewport — so near and far ground were fetched in whatever order the renderer happened to enqueue them, and with a tilted 3D view the ground under the viewer was routinely the last thing to appear. MapRenderer::drawLayers now calls setPrefetchFocus(viewState.getFocusPos()) once per frame and the worker picks the closest queued entry.

Three things about that metric, all of which have a host test in tests/api/PrefetchOrderTest.cpp:

  • The focus is read when a tile is DEQUEUED, not when it is queued. That is the whole reason it is one piece of state on the manager rather than a rank passed per call: a fast pan re-orders the 64 entries already waiting, instead of draining them against the camera of some earlier frame.
  • Distance is in tile widths at the tile's own zoom (prefetchTileDistance, split into all/native/terrain/PrefetchOrder.h so it can be tested without linking the manager). The queue mixes levels, and in raw mercator units a coarse ancestor covering the focus loses to a fine tile several tiles off to the side — its centre is up to eight tiles away. Both are the ground under the camera and must rank alike.
  • u wraps, v does not. A view on the antimeridian queues tiles either side of it; mercator y has no such seam, and wrapping it would make the arctic look near the antarctic.

The focus, not the ground point under the camera: at a tilt of 60° that point sits behind the bottom of the screen, and the horizon tiles this is meant to hold back are far from either.

The queue cap sheds the lowest priority entry, oldest first among equals, rather than simply the oldest — the corner texels are what a saturated queue should give up. Before, priority rode on the entry's position in the deque (push_front for 0, push_back otherwise, drained from the back); a nearest-first scan would have erased that distinction silently, so the priority now travels with the entry.

A neighbour is only asked for within NEIGHBOUR_PREFETCH_MAX_LEVELS_BELOW_VIEW (2) levels of the camera's zoom. A tilted view's far ground is covered by very coarse tiles — at Grenoble z14.5 tilt 65 the cover reaches z3 — and resolveEntry asked each of them for its 8 border neighbours. Measured on the Crosscall over a warm cache: 219 tile loads in 7.7 s, 129 of them those coarse neighbours, and the near ground the user is looking at waited behind them. Bounded, the same start is 121 loads in 5.9 s (turning the neighbour prefetch off entirely: 94 in 4.1 s, which is the floor). A border texel of a tile four levels coarser is far below a pixel; the tiles that matter for seams are the ones the camera is on.

CPU height queries

getDisplayHeight answers with the node field (the drawn surface), getElevationMeters with the DEM — see The node texture. Both are point queries, and their callers are dense: the label re-anchor walks every vertex of every label, the raycast marches a ray. Three things make one sample cheap, and each of them was a measured frame cost before it existed:

  • The last grid, kept. getGridForInternalPos remembers the last resolved grid per thread and reuses it while the point is inside its bounds. Without it every sample paid a projection transform, a tile id (IntPow alone was 21% of the render thread), a flip and the zoom clamp before reaching the cache — to find the tile the previous sample had just used. lookupTileGrid keeps a second memo for the callers that arrive with a tile id already.
  • The latitude scale, quantised. getDisplayScale is tanh-based and was 21% of the render thread on its own (tanh + expm1). It is now memoised over a ~40 m latitude quantum, which moves a height by under two millimetres and — being a function of the position alone — keeps the same vertex at the same height frame after frame.
  • Two versions, and what they mean. getVersion moves on any change; getDataVersion moves when the elevation DATA changes, including a tile load. A consumer tells an exaggeration ramp (heights scale on the GPU, surfaces stay valid) apart from new data by comparing the two. _dataVersion used to stand still for tile loads, so TileRenderer read every arriving DEM tile as scale-only and took the blanket invalidation path — a whole screen of label anchors resampled per tile, instead of the labels over that tile. Setting the same exaggeration twice is now a no-op for the same reason.

The elevation texture

Displacement happens in the vertex shader (vertex texture fetch), so each tile needs its DEM as a texture. ElevationTextureCache (all/native/renderers/utils/) turns a grid into one:

  • keyed by the grid's own tile, so overzoomed tiles and all layers share one texture per DEM tile, and neighbours sampling the same level sample one continuous texture;
  • the payload is a padded (W+2)×(H+2) RGBA re-encode with a 1-texel border taken from up to 8 neighbour grids (cross-level backfill and an edge box filter), so shared tile edges agree bit-exactly and the surface does not crack;
  • encoding and the Bitmap construction run on a worker thread; the render thread only uploads, under a per-frame budget (MAX_UPLOADS_PER_FRAME, MAX_UPLOAD_MS_PER_FRAME). A tile with no texture yet renders flat, which is the visible cost of a budget set too tight;
  • a neighbour landing patches only the 2-texel ring (encodeTextureBordersapplyBorderPatches, glTexSubImage2D into the live texture and into the bitmap behind it, which is what survives a context loss). Measured over a cold load: full re-encodes 353 → 24. It changes no frame rate — the encode was never on the render thread — so treat it as work removed, not speed gained. In a warm pan the whole pipeline is idle: zero encodes, zero patches;
  • _frameResolved memoises the per-frame tile → grid resolution, because the provider is called once per tile per render pass and each miss costs 9 locked cache lookups.

How tangram does it: the DEM raster is bound as the tile's own texture, uploaded once when the tile loads, ancestors addressed through uv sub-rects (u_raster_offsets), edges extrapolated in the shader (res/scenes/elevation.yaml). No re-encode, no border machinery — but also no cross-level edge filtering, which is a seam feature this fork wants. The port that keeps both is to upload the grid's own samples and patch borders as small glTexSubImage2D strips.

The node texture

The vertex stage does not displace from the texture above. It samples a second, small texture per DEM tile — the node texture — holding one height per mesh node: the DEM averaged over a box of ElevationNodeField::DEFAULT_BOX_CELLS (two) mesh cells centred on the node (ElevationNodeField, ElevationTileGrid::encodeNodeTexture). 129×129 texels for a 512-texel grid under a 64-cell mesh, ~50 KB, same encoding as the DEM so the same decode uniforms read it (uElevationNodeTexture, uElevationNodeUV, uElevationNodeTexelSize; the lattice clamp's uElevationLatticeCell is in node-uv units).

Why: a lattice that samples the DEM point by point aliases every relief finer than its cell. On a lidar-grade DEM (mapterhorn z16, 0.84 m texels) under a 6.7 m cell that is a road's cut and fill, a wall, a terrace — and every road edge at a grazing tilt came out as a regular sawtooth with the cell's period, plus a one-sided dark band (the cut face). Measured at Grenoble z16 33810/23522: the 64-lattice's residual against the DEM was p95 0.80 m, p99 1.53 m, max 13.7 m, and the >0.5 m residuals traced the switchbacks as double lines. demMaxZoom 13 made it vanish, 14 halved it; meshResolution 254 shrank the period 4× and kept the amplitude. The fragment stage (shading, contours, the paint) keeps the full texture, which is what PR #49 removed the old level cap for. Camera to judge it at: --es lat 45.201650 --es lon 5.727543 --es rotation -15.12 --es zoom 16.27 --es tilt 20, mapbox-standard, on emulator-5554.

Why two cells and not one. A node sits on a texel boundary, so the old bilinear at a node was already a 2-texel box; the runs above were therefore a half-cell box (default), a one-cell box (demMaxZoom 14) and a two-cell box (demMaxZoom 13) — and a one-cell node field rendered pixel-identical to the demMaxZoom 14 run (mean difference 0.01), still stepped. A one-cell box removes what the lattice cannot sample but leaves a road's cut as a full step within one cell, and a step of H over one cell is drawn as a staircase of H/2 at a grazing tilt. Roughness of the node field (p95 of the per-cell Laplacian, Grenoble z15 DEM, mesh 64):

boxhalf cell (old)1 cell2 cells4 cells
p955.0 m3.75 m2.36 m1.20 m

Two is where the staircase stops reading as one; wider trades real relief for it. The box scales with the cell, so a finer mesh gets its detail back. adb shell setprop debug.massif.nodebox <cells> overrides it for a measurement.

Why not mipmaps of the elevation texture: the texture is padded to 514, and from level 2 up the texel centres no longer sit on the tile edge, so two tiles sharing a DEM border would compute different edge-node heights — a crack at every DEM tile border. The node texture puts a texel ON every node, and the edge nodes are computed from both grids (a same-level neighbour texel-exactly, a coarser one sampled at the texel centre, and widened to the coarser neighbour's cell so our node meets what its lattice interpolates), so both tiles get the same number. A neighbour landing patches the four edge rows/columns along with the DEM ring.

At the nominal zoom a surface vertex is a node and reads one texel; an overzoomed tile's vertices interpolate the field bilinearly, so relief between nodes is not recovered there (the mesh could not carry it without aliasing anyway). The CPU side samples the same field (ElevationTileGrid::sampleNodeHeight, behind every getDisplayHeight), so label anchors, extrusion bases, the raycast and the occlusion depth mesh sit on the drawn ground; its edge nodes clamp where the texture reads the neighbour, a fraction of a texel step along DEM tile edges only. getElevation/getElevationMeters stay the DEM itself. The full-detail terrain paint cache (setDetailLevels) reuses the manager's grids, whose field is built for the nominal level, so in that off-by-default mode its node texture is twice as dense as its mesh and filters half a cell.

mapbox never meets this: GRID_DIM 128 over a maxzoom-14 512-texel DEM is one texel per node at z16 by construction of the data, not by a rule.

The decoder is per tile, not per source

ElevationManager::loadTileGrid resolves the decoder from the TILE's dem_encoding meta data (ElevationDecoder::Resolve, see 02-tiles.md), falling back to the source's and then to MapBox. Everything downstream already carried the coefficients per grid — ElevationTileGrid keeps _coeffs, hands them to sampleHeight and to the GPU through getDecode() — so a mixed OrderedTileDataSource of a MapBox DEM and a Terrarium one needs nothing else.

One place had to learn about it: the border backfill copies a same-level neighbour's texels bit-exactly, which is only valid when the neighbour encodes heights the same way. sameLevel compares _coeffs for that reason; a differently encoded neighbour falls to the resample path, which goes through metres and re-encodes. Without the check the seam between two coverages reads whole kilometres of bogus elevation, and only there — which is exactly the kind of bug that gets blamed on the tileset.

Surfaces

Two mechanisms exist; regular-grid mode is what runs, and it is no longer optional — the adaptive path is only reached on a GPU without vertex texture fetch (no elevation texture provider):

  • Shared regular grid (TileSurfaceBuilder::buildRegularGridSurface): ONE unit grid of TerrainOptions::MeshResolution cells, built once, drawn for every tile with the tile's own matrix and uniforms. surfIndices / surfDraws comes out at exactly 24576 = 64·64·2·3 — one grid per draw. This is tangram's RasterStyle arrangement (core/src/style/rasterStyle.cpp), and it is already implemented: there is nothing left to port here.
  • Per-tile adaptive surfaces (buildTileSurface, red-green edge-local refinement over corner fans) — used only by the non-grid draw path and by ray-cast picking (findTileBitmapIntersections). In grid mode _tileSurfaceMap stays empty, and both invalidateTileSurfaces and resetTileSurfaces iterate nothing. Verified with counters: surfBuilt=0 surfInval=0 for a whole pan.

Because the grid is regular, it also means picking through _tileSurfaceMap finds nothing in grid mode — the pick path is the one consumer left that would need a lazily built surface.

TerrainRenderer keeps its own mesh cache keyed by (tile id, mesh grid size) — the occlusion depth pass draws the same tiles at a coarser grid, and a tile-only key would make the two passes rebuild every mesh in turn. It evicts least-recently-used, sparing anything the current pass already drew. It used to clear() the whole cache on overflow, which rebuilt every mesh of every pass whenever the working set crossed the cap — i.e. exactly during a multi-level zoom, for the same reason ElevationTextureCache had already moved off a full flush.

Edge stitching

A coarser neighbour interpolates the DEM between its own (2^k wider) lattice nodes, so a fine tile must chord across the same nodes on that shared edge or the seam cracks open. buildTerrainEdgeCoarsening computes, per tile, how much coarser each of its four neighbours is, and the surface shader collapses the edge accordingly. Two things this got wrong once:

  • the map must be built from the cover that is actually drawn, not from a layer's own visible tiles (GLTileRenderer::terrainSurfaceTileIds: ground cover, else paint cover, else own tiles);
  • the lattices only line up when the resolution is a multiple of the level difference, which caps k.

Draped content takes the same coarsening, not just the surface: a road or a contour crossing the seam has to land on the same stitched edge as the ground it lies on, or its two halves meet at different heights — invisible from straight down, a step as soon as the camera tilts. The edge test is pos.x < 0.00001, which is only meaningful for surface vertices (they are the unit square), so content converts first with uTileUnitScale. Only the outermost cell is affected. Note the feature is off by default (TerrainOptions::TileEdgeStitching), and on its own it does not fix content mismatches at junctions — see the tile clipping in 03-vt-renderer.md, which is the dominant cause.

Open: this conversion uses the scale alone, so for a stand-in (an ancestor tile serving a finer target while it loads) it measures from the ancestor's origin rather than the drawn tile's, and picks the wrong edge. The tile clip had the same bug and was fixed with a uTileUnitOffset; the same offset applies here by the same argument, but adding it moves settled contour positions by changing the elevation interpolation (2.8 % of the frame at the camera above), so it is left for a deliberate on-device comparison rather than folded into the clipping fix.

Skirts: deliberately absent

Tile border skirts (walls dropped at tile edges to hide cracks) are disabled. Their walls, textured with stretched edge pixels, rasterize over neighbouring content wherever a displaced tile edge leans off-nadir — solid fill-coloured patches that grow with the tile size. Tangram has none either. Cross-LOD cracks are handled by stitching instead.

The shared ground

One cover for the whole layer stack, one ground pass, then every layer composites onto it in layer order. This replaced a per-layer depth pre-pass and a per-layer stencil mask (4209 mask draws and 24.7 ms per interval → 0), and it is what tangram does: one shared grid mesh, one draw per tile, no pre-pass, no masks anywhere in core/src.

The cover comes from MapRenderer::collectTerrainCover and is seeded by the terrain's own visible tiles — what the camera can see, not what the layers happen to have fetched. Two rules hold it together:

  1. The ground is drawn at its true depth and is never pushed back. Everything after it is GL_LEQUAL with no bias in either direction.
  2. Ground-shaped content is drawn on the cover tiles, not on the layer's own tiles. Two tesselations of one height field do not agree; on the cover they are coincident to the bit. This is why a hillshade at z12 is drawn on the z14 cover.

Stand-ins

A cover leaf whose DEM has not arrived walks up to the coarsest loaded ancestor and is drawn there once (duplicates collapsed). Drawing it flat instead makes every tile flash in the bare ground colour during a zoom. Tiles that stand in carry a proxy depth; live tiles carry zero, however coarse they are — see 05-depth-model.md.

A tile with no elevation anywhere in its ancestry draws nothing at all. A false ground at sea level next to displaced tiles is a slab of map hanging in space, and it writes depth, so it hides what is behind it. Zooming out is when that happens wholesale: the elevation cache holds the finer grids of the previous generation and its lookup only ever walks up, so every new coarse tile misses until its own DEM tile loads. When nothing has elevation (cold start, or ground the DEM does not cover) the scene is flat and internally consistent, so the flat draw stays.

Normalizing the cover to a quadtree partition

The collected set is a union across layers, and layers do not agree on a zoom level — a hillshade capped by its DEM max zoom yields coarser tiles than a vector tile layer. Drawing a surface for every tile in that union stacks a coarse surface and the finer ones covering the same ground, and they fight. collectTerrainCover normalizes to a single non-overlapping cover, keeping the finest tile for any ground area; coarser layers still reach it through the ancestor sub-rect bake.

Dropping a coarse tile outright is wrong — a single fine tile inside it covers 1/4ⁿ of its ground and the rest would have no surface at all, which reads as a hole. So a coarse tile that contains a finer one is replaced by its four children, recursively, giving a true quadtree partition.

Split only where a finer collected tile actually sits inside. Splitting every subtree down to one global level looks stable on paper, but a layer showing a coarse proxy — one z6 tile standing in for the whole view while its data loads — is then chopped into hundreds of leaves, most of them off-screen ground nobody asked for: measured, 16 collected tiles became 127 leaves. That is fatal rather than wasteful, because every leaf takes a drape cache entry: two such covers exceed the cache and the eviction pass drops the entire previous generation, which is what both the seed and the stand-in read from. The symptom is seeded 0, blank 16 and a screen of flat fills.

...but the camera seeds the levels the data does not reach. Built from the collected tiles alone the cover cannot split past the deepest tile a source gave, so once the camera zooms past a source's maxzoom the drape's metres-per-texel freeze: the ground goes soft and stays soft while the live geometry beside it keeps its full precision. A drape texture is a fixed size (TileRenderer::resolveDrapeResolution, one per frame), so the only thing that buys sharpness at depth is a finer leaf.

collectTerrainCover therefore also takes the terrain's own camera-driven cover as a seed. The shared ground takes it whole — it has no texture budget and needs the view covered, which is what stops it blinking white on a zoom out. The drape takes it with extendSeedsOnly: only the seeds that reach deeper than any collected tile, and none at all when the layers have nothing yet. Where the data already follows the camera the cover is byte-for-byte what it was; the seed pays for the extra depth and nothing else.

Measured, emulator, 44.0804/3.0037 z15.08 t19 rot130, mapbox-standard over mapbox streets:

source maxzoomsplit levelleaves
16 (data reaches the camera), before and after1511
14, before14 (the min(maxCollectedZoom, …) cap)
14, after1511

So the cover no longer depends on where the source stops: 0.86 m/texel at that camera either way, against 1.72 m/texel when it was pinned to z14.

This is mapbox-gl-js's model with our own cover standing in for their proxy source — theirs is a TerrainInternalSource('proxy', 'geojson', 512, 0, ceil(map.transform.maxZoom), reparseOverscaled) (src/terrain/terrain.ts), a source of its own so that no data source's maxzoom bounds it, drawn into a tileSize * 2 = 1024² buffer. Worth knowing before reaching for their model for anything else: their cover caps at floor(camera zoom) exactly as ours does (shouldSplit returns false at it.zoom === maxZoom), and 1024² over a 512 px tile is coarser on the ground than our 2 × tileDrawSize × dpiScale over a 256 px one. Past the source max was the only place they were ahead.

...except that the byte budget was silently taking it back. The ladder above is only the first half of resolveDrapeResolution; the second caps it at the largest power of two a working cover still fits in the drape cache's 96 MB (DrapeTuning::resolution). With DRAPE_WORKING_SET at 64 that cap was 512 on every device — 64 × 1024² × 4 B is 256 MB — so the screen's 1024 was asked for and never granted, and the comparison above was wrong in our favour: we were baking at half mapbox's linear resolution, a quarter of the texels, which is the blurry stretched drape at a grazing angle. A real cover measures 15–34 leaves, so 64 was headroom nothing used. At 24 the arithmetic lands on the budget exactly (24 × 4 MB = 96 MB) and 1024 gets through.

Still open: the oblique near ground. At a low tilt the ground at the bottom of the screen is magnified several times past what a cover at floor(camera zoom) resolves, and neither model splits deeper there — --es drapeResolution 2048 visibly sharpens it, which is what says it is texel-bound rather than cover-bound. The fix would be a per-tile resolution (near leaves large, far leaves small, same byte budget) or a split rule with a pitch term; mapbox's distToSplitScale is not it, that one makes grazing tiles coarser.

The drape cache: budget, seeding, and completeness

TerrainDrapeCache keeps a generation of tiles alive past the visible cover, because a zoom or a pan walks the cover back and forth over the same tiles and re-acquiring means re-baking every layer of every tile. Its cap is a byte budget, not a tile count: a drape texture is resolution² × RGBA, so the same 160 entries are 10 MB at 128 and 640 MB at 1024 — the count alone is how the cache came to ask for hundreds of megabytes on a high-DPI screen. The tile count is derived from the budget per resolution, with a floor so a large resolution still caches a usable cover.

Three things then keep the bakes off the critical path:

  • A per-frame bake budget. A bake re-renders every layer of a tile into a full-resolution texture (~16 ms at 1024), so an unbounded loop over a churning cover is a per-frame re-render of the whole map. Three classes, not two: a tile with nothing is a visible hole and is baked almost freely; a tile standing in on an ancestor shows the right ground at half the sharpness; a merely out-of-date tile already shows something plausible and can wait. An integer zoom step renames the whole cover at once, which is exactly the second class. Raising the budget to bake a renamed cover in one frame was measured on device: worst frame 128 ms → 300 ms, with no visible difference in the stand-ins it was meant to remove. Two rules on top of the classes (2026-09-03): within a class the tiles bake nearest the focus first, in tile lengths of their own zoom, so the point the user looks at fills before a corner; and the moving budget (16 ms) outlives the last camera move by a 300 ms settle window (DRAPE_BAKE_SETTLE_MS) before the at-rest budget (60 ms) opens. A fast zoom is a chain of gestures with rests of a few frames between them, and opening the at-rest budget in each rest made every one a 60 ms frame while the new zoom's cover baked — watched live at Paris, z15–18 fast: 100–144 bakes queued, 39 frames over 100 ms, the worst 375 ms, which read as the map hanging between the fingers. The deck's own drape (the span bake, per render tile) is under the same budget now, nearest first, one always through: unbudgeted, an integer zoom renamed every bridge tile in view and baked them all in one frame — 150–210 ms of drape in that frame at Paris, 43–71 ms after. A deck whose drape has never been baked draws its plain roof for those frames. Two more rules (2026-09-04), from a z20→19 step at the Louvre with 14 span tiles in view: the span bakes run right after the blank ground, not last — run last they queued behind the zoom's ground re-bakes at one per frame and the decks were bare for 14 frames; and a deck whose drape is baked but re-fingerprinted (its proxy gave way to the native tile, which happens once per deck per zoom step) is handed over as it is while its re-bake waits, an older road on the deck rather than a bare one. Only a texture that has never been baked stays out of the hand-over. Bare deck draws over that step: 27 → 11-16, and their span 2.7 s → 0.35-1.5 s. Then, 2026-09-05, DRAPE_BAKE_BUDGET_SPAN = 3 span bakes go through before the time budget is consulted (was one): emulator A/B, same build, debug.massif.spanbudget 1 vs 3 over two 0.1-step 20→19 zooms — decks waiting for a drape 30 vs 25 deck-frames, both over 3 frames (~0.18 s), and zero on the second zoom of each pair since the textures stay cached. Small on the emulator, where the reorder already drains the queue in 3 frames; the case it is for is a GPU where one ground bake fills the 16 ms budget and spans would otherwise dress one per frame. That case is not measured.
  • Seeding. A tile entering the cover has no texture and until its bake is budgeted it can only be a flat fill — the white sheet over the terrain on every zoom out. But the cache already holds this ground: the finer tiles it replaces, or a coarser one covering it. Copying those into the new texture is a few textured quads, and the tile shows the map from the frame it appears. Seeds are never sources (findBaked returns baked entries only), so nothing degrades through repeated copying.
  • Completeness, not just "has a texture". A zoom out reaches the new coarse tiles raster-first — the hillshade decodes in one step while the vector tiles still have a style pass to run — so the first bake holds the hillshade and the background and nothing else. By a "has a texture" rule that replaces the previous generation still on screen, and the map turns into bare relief for the half second the vector layers need. That is the flash. A tile counts as usable only when every layer that has something for it is in it.

What makes a tile stale is a fingerprint over its CONTENT — and the zoom is content. Each cover leaf mixes in every collected tile that bakes into it, plus whether the paint layer can reach it; a contributor left out is a tile that stays stale for as long as it is cached. The zoom was left out. But a style's functions are evaluated at the view zoom, so a road's width — and anything else that interpolates — is baked in at whatever zoom the tile was first baked at, and only changed when a new tile level happened to bring new textures. A road stepped once per integer level instead of growing with the zoom.

The zoom now goes into the fingerprint quantised (DrapeTuning::bakeZoomTerm), at the same quarter of a level the label re-placement uses: four bakes per zoom level, spread over frames by the bake budget above. The term follows the camera only once it settles — mapbox does the same, their drape does not re-render during a pinch — because re-baking mid-gesture spends a bake per tile per step on a picture that is about to change again. The frame the term moves in has to be asked for explicitly (requestRedraw); nothing else was going to draw it.

A bake pins the layer blend to 1 on purpose — a cached texture must not have a transient fade burnt into it — but it also used to pin the style's own layer opacity to 1, so a draped layer with opacity: 0.5 baked fully opaque. calculateDrapeOpacity now supplies it, matching what the on-screen path passes as element opacity. Comp-op layers keep 1: reproducing them needs the overlay buffer the bake has no equivalent of.

Stand-ins from the previous generation are pushed after the tile's own entry, not before: the surfaces coincide and the later draw wins, so pushed first they are buried under the fill they were meant to replace — the whole screen going white for a moment on every zoom out.

At any depth, and found in the cache rather than in the tile tree. Walking the tree down costs 4^depth lookups, so the search was capped at two levels — and a pinch out crossing three or more levels then found nothing, leaving the leaf painted in the flat clear colour. The cache holds at most MAX_ENTRIES tiles, so one pass over it answers the same question at any depth: TerrainDrapeCache::findBakedDescendants returns the coarsest baked tiles inside a leaf (a tile whose own ancestor is in the result is left out, or the ground is painted twice at two tesselations). The same call feeds both the seed and the stand-in draw. A descendant that cannot be drawn — no elevation under it, or a layer mask as incomplete as the tile it would stand in for — does not take its own descendants down with it; they are the finer generation and one of them may well be usable. The selection rule is DrapeStandIn::coarsestCover, free of GL so it is testable on the host (tests/api/DrapeStandInTest.cpp).

Measured on the day-cycle-light example, the same fast pinch out on emulator-5556, counting the surfaces actually drawn as a flat fill in a frame: worst frame 11 of 19 (58 %) → 9 of 25 (36 %). What is left is a cold cache, where there is genuinely nothing to stand in on — the remaining fills cluster in the first second after launch.

The cache has to hold two generations, not one

Searching the cache is worthless if the cache has already been emptied, and it had: the budget was sized for one cover. Two things did it, both measured on the same pinch at dusk (where a flat fill is obvious — it is painted in the style's background colour, unlit, so it reads as a light patch on lit ground):

  • DrapeResolution defaulted to 1024, so the automatic path never ran and the cache held 96 MB / 4 MB = 24 tiles against a cover of 17–28 leaves.
  • the entry-count cap counted coverage masks as whole tiles (28 colour + 28 masks = 56 against a cap of 24), which its own comment says it should not — a mask is a quarter of a drape in bytes.

Together they evicted 21–28 entries every frame of a zoom, so seeded 0, blank 16, and the stand-in had nothing to read. The resolution now defaults to automatic, the count cap counts colour entries only, and DRAPE_WORKING_SET is 64 — the live cover plus the generation behind it — which steps the bake to 512. Both ends of that trade are now the app's: TerrainOptions:: DrapeCacheSize (MB) and TerrainOptions::DrapeWorkingSet (tiles); an app that wants mapbox's 1024 raises the budget to pay for it.

Measured, four fast two-level zooms: frames drawing a flat fill 13 → 7, worst frame 18 of 23 (78 %) → 11 of 20 (55 %), and — the point — a second pass over the same ground fell from 14–18 fills to 1–2, because the previous generation now survives to stand in. The first traversal is still cold and that is honest: there is nothing cached to show yet.

A leaf already drawing its own bake does not get the finer generation stacked on top of it even when that bake is incomplete: it covers this ground and is merely missing a layer, with the re-bake already queued. Stacking a finer tesselation over it was a two-frame mesh pop at every integer zoom out (showsOwnBake in MapRenderer, same rule as showsAncestor above it).

The exception is a bake with nothing in it. A render tile whose native tile has not arrived holds only the finer proxies it retains, and the bake leaves those out (they do not cover the target), so the bake produces no layer at all and is still marked baked. Drawn as the tile's own picture that texture is the flat clear colour — measured at a z20→19 step as one z18 leaf blank for 4 s over cached z19/z20 drapes that had the map. An empty bake is therefore not a picture (DrapeStandIn::hasPicture): the leaf seeds from the cached generation like a fresh tile, or draws the descendants over itself, and it is never complete (DrapeStandIn::isComplete) until a bake with content lands. findBaked skips it too, so it never stands in for a neighbour.

The outgoing generation at an integer zoom out

Zooming out z12 → z11, the drape cover moves to z11 while the z12 render tiles are retained and faded out. GLTileRenderer::isTileDraped used to answer "draped" only for a tile that covers a drape tile, so those finer tiles counted as undraped and kept drawing themselves in the 3D pass. Two separate defects fell out of that, both fixed, both worth knowing about because the path is easy to reintroduce:

  • The direct raster draw was neither lit nor fogged. renderTileBitmap built its program with PATTERN_FLAG | terrainFlag | fogFlag() — no TERRAIN_LIGHT_FLAG — and it was the one draw path in vt that never called setupFogUniforms, so the fog code was compiled in with uniforms nobody ever wrote. colormapFsh had carried the full TERRAIN_LIGHT block all along as dead code. The visible result was the previous zoom's ground flashing unshaded over the lit drape beside it — measured as a uniform ×0.62 multiply on all three channels with the sky untouched, which is what a missing shading term looks like and what finally identified it. Passing the flag was not enough on its own: the block it enabled did not compile until its declarations were shared, see 08-lighting-sky-fog.md.
  • The old imagery was drawn at all. Once shaded identically the flash was gone, but the z12 raster was still painted over the z11 drape for the length of the fade — visible as the previous zoom's satellite imagery. isTileDraped now answers "draped" in both directions: a drape tile that contains the render tile covers that ground just as well as one contained by it.

The old comment argued the finer tile had to keep drawing because nothing else covered that ground. That holds only while the covering drape tile has no content; vt cannot currently tell, so the risk this trades for is a coarser stand-in (not a hole) for the frames a fresh cover tile needs. If a blank patch ever appears right after a crossing, that is this trade, and the fix is to plumb bake-completeness from MapRenderer rather than to restore the old imagery.

Adding the lit variant also moved the cost: it is only ever asked for at a crossing, so the lazy build put a full compile and link of the largest colormap variant (DEM taps, PCF, cascades) inside the gesture — a visible hang. warmTerrainRasterShader, called from startFrame, builds it on an ordinary frame and rebuilds only when the flag set changes.

Dead ends, in order, each killed by a measurement: the elevation texture cache; the terrain mesh cache; drape cover composition and its 257 ms lag (real, visually inert); fingerprint churn on finer tiles; the seed blit; ancestor sub-rect stand-ins; coarse-fed bakes; absent layers; the day cycle; raster blending speed; viewZoomCap; drape stack stability; the background overpainting the raster; two raster generations baked at full opacity; and TerrainRenderer's own background/surface/depth passes — which turned out not to run at all once a tile layer owns depth-write (background=0 keepDepth=0 prepass=0 depthWriteAssigned=1), and eliminating them is what pointed at vt's direct draw.

Extrusions on a slope

A building is a prism, not a cloth. Displacing every extrusion vertex by the terrain under it — which is what tangram does (position.z += getElevation() in terrain-3d.yaml, per vertex, for every style) — shears the roof down the hillside and the building reads as melted.

mapbox splits the two ends, and that is what is implemented here:

aligned toresult
base ringthe terrain under each vertexthe wall meets the slope everywhere, no gap, no float
everything above itONE elevation, resolved at the building's anchor on a smoothed fieldthe roof stays level; walls simply grow taller downhill

The centroid rides in the texcoord slot, which was free because for an extrusion _texCoords was a byte-for-byte duplicate of _coords. It is no longer read by the shader — the CPU pass below reads it back out of the vertex data to know where to ask for the ground, and polygon3DVsh keeps the position for the tile clip. packGeometry forces texCoordScale == coordScale for POLYGON3D, which is what lets one scale convert either of them.

The scale is fitted to the anchors as well as the coords (_polygon3DAnchorExtent). Under deep overzoom the centroid of a palace lies several tiles away from the z20 piece drawing it; at the coords' own scale it overflowed the int16 and wrapped, so every tile read its base at a garbage position — the same wing at two heights, a piece under the ground with no walls, only on 3D terrain and only zoomed in close (the Louvre, 2026-09-04). Host test ExtrusionAnchorTest.

One anchor per BUILDING, not per footprint

A footprint is not a building. The source splits a palace into parts and the tile grid cuts it, and each piece anchored on its own centroid stands at its own elevation — equal heights on stepped bases, which is read on screen as a sawtooth of separate slabs where one roof belongs. Measured on mapbox-streets z16 over the Louvre (2026-09-04):

parts of one building, base spread0.3–1.5 m typical, 5.6 m worst
the two halves of a building the z16 border cutscentroids 6–75 m apart, 5.5 m worst
building_id present on39 of 160 footprints — it alone leaves the palace ungrouped
footprints joined by a shared vertex738 of 1815 tile vertices are shared: 160 footprints → 33 buildings

vt::buildExtrusionAnchors (called from TileReader::processLayer, host test ExtrusionGroupAnchorTest) answers both, before anything is drawn:

  • parts that share a vertex or carry the same building_id are one building, and take the mean of every outer ring point they own. mapbox groups by building_id alone (_finalizeBuildingGroups, default group id = the feature id); the shared vertex is what makes it work on data that mostly does not carry the field.
  • sharing a FEATURE is not one of them, and the table holds one entry per footprint rather than one per id. An OpenMapTiles mbtiles packs a whole tile of unrelated buildings into one multi-polygon feature — Grenoble z15: 3513 footprints under 18 ids — so grouping by the id gave a tile ONE anchor, and every building on the Bastille slope stood on the valley floor 100 m below, its walls stretched up to its roof. The drawn polygon picks its entry by the bounds its centroid falls in (findExtrusionAnchor); the bounds come from the UNCLIPPED ring, so a clipped piece still lands in them. Parts of one feature that genuinely are one building still share, through the shared vertex or building_id.
  • a building crossing exactly one edge of the source box anchors on the MIDDLE of its crossing of that edge — the one point both tiles compute identically, because the server buffer means both hold the whole crossing. A corner cut anchors on the corner. Anything more tangled keeps the centroid: there is no local rule two tiles would agree on, and a step beats a wrong elevation.

The box is the source tile's (LayerFeatureDecoder::getSourceBox, the transform of the unit square), not the target's: under overzoom the target's edges cut nothing, the ancestor's are where the data was really cut.

The pass needs the features unclipped (createLayerFeatureIterator(..., clip = false)) — the parts of a building this tile does not draw still decide where the ones it draws read their ground, and a group built from what one target tile's clip box kept is a different group in every tile, which is the bug in another form. It runs on its own decoder caches, so it cannot leak an out-of-box geometry into the drawing pass, and it is skipped entirely unless the transformer is elevation-based and the layer actually extrudes; the table is built once per layer per tile.

Not grouped: nothing. Every attempt at a cross-TILE union was reverted before this — a global union in world coordinates gave the Louvre 36.2 m from one tile and 37.7 m from the next as tiles arrived, a crack that moved while panning. The rules above are all local to one tile's data, which is why two tiles agree without talking.

The base is resolved on the CPU, not sampled in the shader

The base is an ElevationManager::getDisplayHeight query at the anchor, run on the render thread by GLTileRenderer::resolveExtrusionBases and patched into a per-vertex slot (TileGeometry::setVertexBase, baseOffset in the vertex layout). The vertex shader only scales it: aVertexBase * uBaseScale + uElevationScale.w.

It has to be that way because the elevation texture bound is the one the tile being drawn carries. Under overzoom one source tile's geometry is drawn once per target tile, so a footprint spanning two of them was sampled through two different textures, got two different bases, and tore open along a straight line that crossed every building it met. A CPU query against the global elevation source is tile-independent by construction. Reproduced in Paris at lon 2.34466 lat 48.84847 zoom 19.04 rotation 66 tilt 45; --es exaggeration 0 made it vanish, which is what pinned it to the elevation path rather than the geometry.

getDisplayHeight returns internal z units with the exaggeration and the Mercator stretch already applied — not metres. uBaseScale is therefore only 1 / frameScaleZ, the same factor folded into uElevationScale.x for heights that do come from the texture. Feeding it through the metres conversion instead applies the Mercator stretch twice and drops the exaggeration.

mapbox-gl-js lands in the same place from the other end. Their a_centroid_pos carries a CPU-computed elevation (fill_extrusion.vertex.glsl), a building on a border is hidden until resolved (HIDDEN_CENTROID), and updateBorders reconciles the two halves — matching them by building_id, and giving up the flat roof entirely when the neighbours are at different zooms. We need no reconciliation PASS: their lookup is per tile and each half carries its own clipped footprint, while ours is one global query at an anchor both halves derive identically from their own data (the crossing rule above). What we do not have is their border stitching, and what we have instead of building_id alone is the shared vertex.

What we do copy is the timing. Nothing is guessed before elevation exists — an extrusion whose tile has no elevation texture yet is not drawn at all, because a base of 0 is sea level, not "unknown". invalidateExtrusionBases then re-resolves when the elevation version moves, including an exaggeration ramp, since the exaggeration is inside that height. It is a counter rather than the labels' per-tile list because setVertexBase is a no-op when the height has not moved, so a needless re-resolve costs the queries and uploads nothing.

"Costs the queries and uploads nothing" was the whole error: on a pan 3–12 DEM tiles land a second, each clearing ~30 geometries, and a re-resolve used to re-derive the footprints by walking every vertex again. The footprints depend on the vertex data alone, so they are found by one walk and kept on the geometry (TileGeometry::setBaseFootprints); an arrival re-samples nine points per building and rewrites the bases. mapbox never re-walks vertices for this either — its CPU path is restricted to parts split across a tile border and gated on the DEM's timestamp (draw_fill_extrusion.ts updateBorders).

The ground the anchor reads is a SMOOTHED field, not the lidar level the surface is drawn from. ElevationTextureCache::getDisplayHeight(..., smooth = true) samples the DEM at SMOOTH_BASE_POSTING (50 m), bilinear, from the grid LRU — an ancestor level answers too, and is asked for if none is there; the arrival bumps the elevation version and every base moves together.

The level is derived from the grid's own resolution, never fixed: the search starts at SMOOTH_BASE_ZOOM_HINT and walks coarser until the posting reaches 50 m. A hardcoded z12 was 50 m only for a source serving 256 px tiles at the equator — mapterhorn serves 512 px, so z12 is 12.6 m posting at Paris, and measured over the Louvre the "smoothed" sample agreed with the lidar one to 0.1–0.2 m while neighbouring parts of one palace differed by 0.3–1.5 m. At 50 m posting (z10 there) the same neighbours differ by 0.14 m, and buildings that touch nothing but stand within 80 m of each other by at most 1.7 m. This is the answer to a family of seams that only exist on lidar: a 0.84 m DEM steps by metres between a courtyard and its street, and the copies two zoom levels decode for a mixed-zoom cover stood at their own heights. Smoothing and the shared anchor answer different halves of it and neither replaces the other — the anchor makes the pieces of ONE building agree exactly, smoothing keeps the buildings AROUND it from stepping against it. The smoothing alone was tried first and measurably did not close the comb, which is what the numbers above are. Spans (bridge chords) keep the drawn surface (smooth = false); a deck must meet the road exactly. A chord holds ONE pair of heights for every piece on it (CachedChord), each portal read at the zoom of the visible tile holding it (spanSampleZoomAt) — the tile whose DEM level the approach road is draped with — and a portal in no visible tile at the asking piece's own zoom (the finest visible zoom asked for lidar tiles 8 km from the camera that nothing had loaded). Before that the pair was sampled per piece at the piece's own zoom, and the pieces of one deck read different DEM levels for the same portal: at Pont Neuf z21.2 one chord came back 1.345 on 22 pieces and 1.306 on 22 more, a step down every tile cut. Smoothing the portals instead was tried and closed the cut, but put the deck end off the draped approach road. The heights are refreshed on every cull, so a finer DEM moves the deck with the surface, and a chord whose portal cannot be read this cull keeps the pair it had.

The exact-level query (smooth = false) answers from the texture cache, which only a drawn tile fills — and a bridge's far portal is routinely in a tile that is not drawn. It falls back to the same grid in the manager's LRU (the same height field, sampleNodeHeight), and asks for the tile when neither has it; before that a portal one tile off screen at z17+ had no height, nothing ever fetched it, and the deck stayed hidden until the camera happened to draw that tile. Measured at Cité z16.2 before the fallback: every deck piece had both portals and no heights.

mapbox's floor (fill_extrusion.vertex.glsl: max(c_ele + height, ele + base + 2)): a building keeps at least 2 m above the drawn ground under it, so a part whose smoothed anchor sits under its own lidar street is still a building and not a hole, and nothing on a hillside is buried uphill. Done on the CPU through the provider, not in the shader: overzoomed geometry runs past the target tile, where applyTerrain clamps to the edge texel, and a shader floor read there tilted whole roofs into ramps.

Theirs is per vertex; ours is per building — the max drawn ground under it, against its tallest vertex. Per vertex on a 0.84 m lidar DEM, a low part's roof followed the ground down every bump it covers; the roof has to stay one plane, and one plane per building is the whole point of the anchor. It is accumulated per ANCHOR rather than per vertex run, since the pieces of one building need not be contiguous in the vertex order.

The max is taken over eight support points, not every rising vertex: the footprint vertices reaching furthest along ±x, ±y and the two diagonals (vt::ExtrusionFloor). Asking every vertex was 2.3 M elevation queries a second while panning and 407 ms of every 693 ms frame — see performance log 26. A support point is always a footprint vertex, which is the property that matters:

mapbox's flatElevation lift (the rise across the span, sampled at the span's corners) is still NOT ported, and a bounding box is not the cheap way to bound this either. Their corners lie off the footprint — beside the Seine one on the Tuileries terrace lifted a wing 5 m above its neighbour, and an L-shaped plan has a box corner the building never reaches. A support-point max is over a SUBSET of the vertices the old max used, so it can only land at or below the old answer: it can under-lift a building, never lift one it should not. That is the direction this has to fail in.

The diagonals are what a box would get wrong the other way: a building at 45° has its extremes there, and its axis-aligned corners on its neighbours.

The dead ends

maplibre's rigid prism buries buildings. Their fill_extrusion.vertex.glsl anchors the base at the centroid too and sinks it by a flat 10 m ("basement") so it cannot hang over a falling slope. On the Bastille hillside the ground rises further than a building is tall, so the whole prism disappears into the hill. 10 m is not a tunable that fixes it — the base has to follow the terrain.

The anchor must go through the transformer. DefaultVertexTransformer::calculatePoint returns (x, 1 - y, 0). The coords go through it; a centroid stored raw does not, so applyTerrain sampled a mirrored y — a different hill entirely, and every building on a slope sank out of sight. The tile clip then has to flip back, because uTileMatrix works in unflipped tile space.

Do not clip by the centroid. Making the overzoom clip test the centroid looks tidy — one tile owns each building, no cutting at borders — but a building can reach into a tile while its centroid sits in a neighbour, and then every tile holding it discards it. Walls vanish until a zoom out retiles them. The clip stays per vertex.

Clamping the finished top to the ground is not the fix. It was the first answer to a buried building, and it takes the max after adding the height: the wall then has zero height wherever the hill reaches the roof, so whole faces are simply gone. Taking the ground per vertex instead keeps the walls but bends the roof down the slope — tangram's melted look, by another route. The max has to be over the FOOTPRINT and applied to the BASE.

Sampling the footprint in the shader cannot be made tile-independent. The previous model took the max of 5 texture samples — the centroid plus a stored reach in four directions — and it was wrong twice over. The reach was stored as a fraction of the tile in a signed byte, so its cap of 127 became a zoom threshold: a 30 m footprint hit it at z19 and a 50 m one at z18, and past that every large building suddenly stood a little shorter. Widening the unit fixed that and left the tile-seam crack untouched, because the samples still went through whichever elevation texture the drawing tile had bound. The units were the smaller of the two bugs.

What is still wrong

Pieces of one building each anchor at their own centroid; they agree only as well as the smoothed field is flat between those centroids — centimetres across a block in Paris, up to a metre across a 400 m palace on a real slope. mapbox groups a tile's parts by building_id and reconciles borders (updateBorders); tried here in three forms on 2026-09-03 (per-tile groups, source-tile groups, a global union) and all three made the pieces disagree by more than the field does, because an anchor shared across a large building is far from most of its parts. On a smooth field the ungrouped anchor is the smaller error.

Bridges and tunnels: spans

Moved to its own page: 3D bridges - chords, portals, the deck extrusion, its drape, the opt-in switch, and what is still wrong.

Near and far planes

Terrain mode floors the near plane at camera height / 50, which is tangram's core/src/view/view.cpp:452. The old behaviour — near taken from the nearest visible ground point, floored at 1/16 of an internal unit — gave centimetre near planes next to a slope, a far/near ratio of 10⁴–10⁶, and NDC depth so non-linear that a constant-NDC bias was worth hundreds of metres at range. That is the mechanism behind every see-through this project has had.

"Camera height" is the smaller of the distance to the focus and the height above the terrain under the camera (ViewState::setTerrainCameraReference, published every frame by the renderer next to the clearance). Tangram's m_pos.z is the distance to what the camera looks at and their camera is held a distance away from the terrain itself (the depth at the screen centre against minCameraDist); ours is held a clearance above the ground under it, so at a low tilt the focus is kilometres away while the ground is a couple of hundred metres below — and a fiftieth of the focus distance then parks the near plane in front of the ground at the bottom of the screen and cuts it away. Over flat ground with the focus close the two distances are the same; the cost of the smaller one is bounded by 1/sin(tilt) (2× at tilt 30), so the depth budget is only spent in the close-to-terrain case that needs it.

The floor is a floor and the ground walk is a ceiling too, but only when the view is pitched away from the camera geometry — free roam looking up, or a first person camera (13-celestial.md). The walk takes the near plane from where the sampled rays MEET THE GROUND; as the view pitches up those hits move off into the distance, the near plane follows them out, and everything close to the camera is clipped away — worse the higher the view goes. What is near the camera does not move when the view turns, so in that case near is capped by the same camera-height rule, which does not depend on the view direction at all.

Their far plane (2·height/cos(pitch + fovy/2)) is available as TerrainOptions::ViewDistanceFactor but changes nothing at the cameras tested: the ground-derived far is already inside the bound it gives.

An absolute view distance only extends the rule

TerrainOptions::ViewDistance pins the distance in metres instead, and StyleEnvironment::terrainMaxVisibleDistance (terrain-max-visible-distance) does the same from a style. Both used to take over ViewState::calculateViewDistance outright, and that is wrong in one direction: tangram's rule is scale-invariantcameraDistance ∝ 2⁻ᶻᵒᵒᵐ, so the drawn ground keeps the same size on screen at every zoom — while metres do not. Zoom out and the fixed distance becomes the binding one, and the ground ends in a disc well inside the screen.

Derived, 1080×2400, fovy 60, tilt 90, z8: camera height ≈ 1270 km, the factor rule ≈ 2500 km, against a 170 km pin — 15× short. Both are now a max(): the absolute distance is a minimum, which is what "keep the panorama as the camera descends into it" actually asks for, and it never shortens the zoomed-out view (#156). The far plane still follows the absolute distance only when the absolute one won; where the rule is longer this is the plain factor case and the depth budget is untouched.

Auto-flattening: when 3D stops earning its cost

Zoomed far out the displacement is sub-pixel, and straight down it shows nothing — but the drape RTT, the terrain passes and the elevation fetches are all still paid. TerrainOptions flattens the map itself in those two cases, without touching Enabled: the rule writes Flattened, and what the switch then does with it is the section below.

The criterion is parallax in screen pixels, not a zoom threshold — a fixed zoom is wrong for flat country or for a high exaggeration:

parallax = halfScreenDiagonal · heightRange · exaggeration / cameraDistance

Derived over the Alps (4 km range, 1300 px half-diagonal): z8 → ~4 px, z13 → ~130 px. AutoFlattenParallax is the threshold, AutoFlattenTilt the separate top-down one (at z13 the parallax is still large and 3D still buys nothing). They default to 2 px and 88° — device-checked on the Crosscall, where below 2 px the displacement is under the antialias ramp — and 0 disables either half. The rule, its hysteresis and the ramp are all/native/terrain/AutoFlatten.h, kept free of the renderer so tests/api/AutoFlattenTest.cpp can check them on the host.

Hysteresis is not optional: 3D returns at 1.5× the parallax threshold and 2° below the tilt one. Without it a camera parked on a threshold flips modes every frame.

The 2D/3D switch

Flattened is the state — writable, so an app can drive its own 2D/3D switch, and the auto rule above writes the same field. FlattenMode is how far the switch goes, and it is the whole design:

FlattenModeFlat costsSwitching costs
RENDER (default)the terrain passes, the drape and the elevation fetches are gone, but the tiles keep the terrain subdivision and the terrain tile setnothing — one ramp, no re-cull, no re-decode
FULLnothing: the map decodes, culls and draws as if no TerrainOptions were attacheda re-decode of the visible tiles, each way

Three pieces of state, and which question each answers:

QuestionRead by
isEnabled()is terrain configured at allthe app; gates the other two
isActive() (FlattenRatio < 1)is 3D being renderedevery renderer, the touch handler, the sky, the hillshade paint
isDecodeActive()are tiles being prepared for 3DTileLayer::loadData's cache compare, resetTileTransformer, calculateVisibleTiles

The one fact everything rests on, and it is asymmetric:

  • terrain-decoded tiles render correctly flat — the displacement is GPU-side and the only decode-time difference is subdivision density, so it is extra triangles and nothing else;
  • flat-decoded tiles do not render correctly in 3D — with no subdivision a road chords straight between its endpoints, which over a valley rides well above the ground.

So the decode is only ever moved while the map is flat, where the two densities draw the same picture, and 3D is never entered before the tiles for it exist. all/native/terrain/FlattenSwitch.h is that rule alone — four phases, free of the renderer, so tests/api/FlattenSwitchTest.cpp checks it on the host:

FLAT ──ask 3D──▶ WARMING ──tiles ready──▶ RAMPING ──▶ TERRAIN
▲ (renders 2D) │
└──────────────── RAMPING ◀────────── ask flat ────────┘
(drops the decode one frame AFTER it settles)

WARMING is deliberately not isActive(): it renders as plain 2D, so the wait costs 2D and shows no half-built terrain. It ends on TileLayer::isTerrainDecodeSettled() for every tile layer, or on MapRenderer::TERRAIN_SWITCH_WARM_TIMEOUT (2.5 s — late 3D beats a map pinned flat by one tile that never loads). Going the other way there is nothing to wait for, so it ramps at once.

Who drives the ratio

Three ways, in increasing order of control:

What the app writesThe clock
automaticFlattenedAutoFlattenDuration, and AutoFlattenRiseDuration for the way up (negative = the same)
by gestureAutoFlattenTilt / AutoFlattenParallaxthe same
by handFlattenRatiothe app's own

The two durations are split because the directions are not alike: the rise is the one an app matches to a camera flight, and the one that waited for its tiles first.

FlattenRatio is writable, and that is the only way two animations can be made to match exactly — a duration is a second timer, and two timers of the same length still drift when a frame is dropped. Writing it puts the switch in MANUAL, which suspends both its own ramp and auto-flattening, and keeps them suspended until Flattened is written. An app driving an animation therefore writes Flattened once when it ends; forget it and a later tilt gesture silently does nothing. MANUAL is deliberately not auto-released at a settled ratio: a rise starts by writing exactly 1.0, so releasing on the endpoints would hand control back on the animation's first frame. MANUAL keeps the tile gate: a ratio below 1 asks for 3D's tiles and the ground is held flat until they arrive, with isSwitching() as the observable so an app can start its flight when the hold ends rather than watch its animation jump.

A tilt threshold is asymmetric by construction, and it shows: the rule fires at 88°, so a flight from a landscape view to top-down flattens at the very END of it, while the reverse fires almost immediately. That is what a threshold means, not a bug — an app that wants the switch to lead the camera drives Flattened or FlattenRatio itself and leaves the thresholds at 0.

Two mechanics that make the switch invisible:

  • The tile set follows isDecodeActive(), not isActive(). The terrain LOD asks for tiles flat rendering never wanted (overzoom targets, the coarsening floor), so culling on the render state re-culls at the instant the terrain appears — which is the tile set arriving after the map is already 3D. That was the flash.
  • The decode change invalidates, it does not clear. TileLayer::loadData calls invalidateTiles(false) rather than clearTileCaches(true): the old tiles stay on screen and are re-fetched one by one. Clearing them blanks the map for a whole decode.
  • Shadows stand down for the ramp. A cascade is only re-cast when its light box or its caster list changes, and neither does while the ratio moves — but the ground receiving the shadow is displaced every frame, so the map wears the shadow of a terrain it no longer has. applyTerrainShadows drops the pass while FlattenRatio > 0 rather than re-casting each frame, which would be a full caster pass on the frames least able to afford one. The !shadowsWanted path already invalidates the atlas, so coming back out of the ramp re-casts every page.

Two consequences worth knowing:

  • The ramp bumps the elevation version every frame, so TileRenderer's scaleOnly path re-anchors labels on each of them, and VectorLayer's terrain projection surface is rebuilt (debounced by ELEVATION_REFRESH_DELAY). Unmeasured; if a ramp ever stutters, this is where to look.
  • HillshadeRasterTileLayer::isTerrainPaintActive reads isActive(), so flattening drops the layer back to its own DEM tile set rather than the terrain's elevation texture. That is a tile-set swap on the flip — the alternative is the hillshade vanishing, so it is the right trade, not an oversight.

Not measured: what FULL actually buys in 2D frame time against RENDER, and how long the WARMING wait really is on a device. Both need the -PprofileRender bench of 10-performance.md at a mountain camera and a city one. See #177, which asked the opposite question — always decode for terrain so setEnabled never invalidates — and which this answers the other way, without its permanent cost.

The camera against the terrain

The focus rides on the ground. The map's projection surface is planar (TerrainProjectionSurface only places vector elements), so every camera event puts the focus at sea level, and the zoom — the camera's distance to the focus — was a distance to sea level. As elevation arrives the ground under the focus rises towards a fixed camera: at the Bastille (470 m) a z16.27 / tilt 20 camera sat at 362 m ASL, inside the hill it looked at, which is what drew the near roads eight times too wide and blurred (the drape is magnified by however much closer the ground is), and at Aiguille du Midi (3842 m) the clearance below zoomed out until the camera cleared the peaks: z12.73 for a z16.27 request. mapbox defines zoom as the distance to the terrain at the centre (transform._centerAltitude, _updateCameraOnTerrain) and lifts the camera with it. MapRenderer now does the same every frame in terrain mode: when a decoded grid answers under the focus, ViewState::liftFocus moves focus and camera together onto it (zoom, tilt and rotation kept). Cached-only and only when a grid answers — an evicted grid is not a valley. A pan or zoom event still places its focus at sea level; the next frame lifts it, and the camera-to-focus vector the event built is preserved, so the camera follows the ground's height difference as mapbox's does. Both spots read z16.27 after it.

The auto-flatten rule hands back what it set. The SDK's defaults (2 px, 88°) run on the first frame, at the default tilt of 90, before an app has set its own thresholds — and an app that then sets them to 0 turned the rule off with its last answer ON, so nothing ever asked for 3D again: the map came up flat, at random, whenever that first frame beat the app's setters. A rule disabled while ON now releases the flat state it set (never an app's own setFlattened(true)), and logs it.

The rule does not judge a camera the app has not placed. The same first frame, drawn at the SDK's default view (top-down, world zoom) before the app's moveTo landed, flattened a map that was about to tilt — and the release then dragged it through the whole switch while its tiles were still arriving: flat unlit 2D in WARMING, three slow ramp frames over a still-blank drape, and the first shadow frame, six distinct looks in the first two seconds (day-cycle-light, 10 fps recordings, 2026-09-05). MapRenderer::_cameraPlaced is set by every camera event and cleared once after the constructor's defaults; until it is set, AutoFlatten::Trigger answers nothing, so the first judged frame is the app's camera. A camera the app places top-down is judged at once. Host test: testRuleWaitsForTheCamera.

The gate alone left 1 launch in 12 flattening: moveTo sets the zoom, rotation, tilt and focus as four camera events, and a frame drawn between the first and the third saw a placed camera at z17 still straight down. BaseMapView::moveTo now holds the renderer's view lock (MapRenderer::holdView) across the four, so they land in one frame. The listeners still hear four moves; only the render thread waits.

The clearance is mapbox's, a fraction of the height, not a fixed 60 m. The camera is kept a height above the ground under it (terrain/CameraClearance.h, the port of transform._minimumHeightOverTerrain / _constrainCamera): a sixteenth of its distance to sea level — mercatorZ(min(seaLevelZoom, maxZoom) + MAX_DRAPE_OVERZOOM), with their constant 4 — so it shrinks with every zoom in and never blocks one by itself. The fixed 60 m it replaces was the real cap on the zoom, not the sea-level reference: the camera-to-focus distance at z21 is about 60 m on a phone, so flat ground straight down stopped at ~z21, tilt 45 at ~z20.5, and a slope under the camera on a 600 m hill ate the rest down to z18. TerrainOptions::CameraClearance is now an optional floor in metres under that rule, default 0. What the rule inherits from mapbox: on very high ground the sea-level share is a cap of its own — 3842 m at Aiguille du Midi needs a 256 m orbit straight down, about z19.

One divergence from mapbox, deliberate: the shell is a fraction of the camera's OWN altitude. mapbox builds its sea-level distance from _centerAltitude + cameraToCenterDistance (_updateSeaLevelZoom) — the ORBIT, which is the camera's altitude only at pitch 0. Read at a low tilt that shell grows as 1/sin(tilt): at tilt 20 it is 2.9× the camera's real altitude, and a moveTo to tilt 15 at Chamonix (lon 6.87, lat 45.92, z14) landed at 19.6 with the camera clear of the ground — the map rode up and down with the terrain on every pan. We pass cameraPos(2) instead, so the shell is tilt-honest and only tightens where mapbox calibrated it, straight down. Because the shell then MOVES with the camera, the lift is a fixed point (CameraClearance::targetHeight, camera height above the focus = max(terrainZ / (1 - 1/16), terrainZ + floor) - focusZ) and not terrainZ + minHeight: rising raises the clearance it has to clear, and a lift that ignores that under-shoots every frame. Both the lift and the zoom bound read the same shell, so they cannot disagree.

It is a bound on the zoom (ViewState::getTerrainMaxZoom, clamped in CameraZoomEvent::calculate), solved on the camera-to-focus vector so it lands exactly on the shell, plus a per-frame correction in MapRenderer for the paths that lower the camera without zooming — panning into a hillside, a DEM tile arriving. Three rules keep a gesture against that bound from throwing the map somewhere else:

  • The bound stops a zoom in; it never drives a zoom out. A zoom event scales the map about its pivot, and with the pivot under the fingers, clamping a zoom-in request to below the current zoom scales the map the other way about that point — the map jumps sideways, once per pinch tick. ViewState::clampZoom honours the same rule. Getting back onto the shell is the renderer's correction, and it is mapbox's: the camera is lifted at a constant distance to the focus, so the zoom is kept and the tilt gives (_constrainCamera keeps cameraToCenter's length). Only past the tilt range's top does the rest come from a zoom out about the focus. And it lifts only a camera under the ground, or after a pan (their adaptCameraAltitude = dragging): after a zoom the ground under the moved camera differs by a little, and lifting for that turned every pinch tick on a slope into a tilt.
  • A zoom is never cancelled for want of a ground hit. TouchHandler::calculatePivotPos falls back to the focus when the ray under the fingers misses the anchor plane or lands past the far plane. Close to the terrain the far plane is short and half the screen is sky, so requiring a hit (which the pinch, the wheel and the double tap all did) left the map unable to zoom out at all — the "I have to pan somewhere else before I can move" symptom.
  • The scale and the angle come from the SCREEN, not from the ground. A pinch and a two-finger turn are what the fingers did (tangram: InputHandler::handlePinchGesture / handleRotateGesture, fed by the platform gesture detector). Taking them from where the two rays meet the ground makes a grazing ray — a low camera, a finger near the horizon — into most of the answer. The pan is still world-anchored (that is the point of a map pan) and goes through one path for both gestures, TouchHandler::panBetween, which honours PanningSpeedMode and, below tilt 15, caps the travel at what the finger's pixels are worth at the map scale — tangram's getTranslation guard for a near-horizontal view.

isValidScreenPosition tests the plane the gesture is actually anchored to (the terrain height under the touch, _gestureAnchorHeight), not sea level: in the mountains the two are hundreds of metres, and at a low tilt kilometres of ray, apart.

The zoom pivot sank the focus, and everything was drawn at the wrong scale (fixed 2026-08-13)

Symptom. In 3D, zoom very close to the terrain, pan, then pinch back out: the map sticks in a state where everything is blurry and oversized, and stays that way while zooming out. Enough movement clears it. In 2D the same state shows labels, shields, peak icons and line widths several times too large for the zoom on screen, the VectorLayer route line with them. Reported as "blurry", but it is a SCALE fault, not a resolution one. Only ever reproduced with a real style (the packaged one) — an inline style whose widths and sizes are constants shows almost nothing, because the fault is in what the zoom-dependent style functions are evaluated at.

Cause. CameraZoomEvent::calculate shifted the map about the pivot with the full 3D offset pivot − focus (ProjectionSurface::calculateTranslateMatrix), and the pinch pivot carries the terrain height under the finger (TouchHandler::calculatePivotPos_gestureAnchorHeight). Every zoom-out about a pivot above the focus therefore pushed the focus DOWN by (pivotZ − focusZ)·(scale − 1). Close to a slope that is a few hundred metres per gesture, and it accumulates.

The focus height is not cosmetic: dist(camera, focus) is the distance the whole zoom scale is calibrated on (_zoom0Distance / 2^zoom, Near and far planes above). With the focus below the ground, that distance stops describing the distance to what is on screen — so the tile walk asks for a zoom several levels too coarse (the blur) while every zoom-dependent width and label size is evaluated for that same far-out zoom (the oversizing), against terrain that is actually a tenth as far away.

The fix is tangram's model verbatim: the pivot moves the map along the surface only. Their pinch correction is a ground translate in x/y (View::translate, core/src/view/view.cpp:258) and their view height is derived from the zoom, so a pivot on a mountain cannot move the view point up or down. CameraZoomEvent now forces the pivot to the focus's own height before building the shift, which means it can no longer change the focus height in any mode — including the lifted viewpoints of free roam and the peak finder, which set that height deliberately (and which the old code could silently drag back down to the ground).

The visible trade is theirs too: pinching with a finger on a summit holds the point at the focus height under the finger, so a high point drifts slightly on screen during the pinch.

How it was found, in numbers. A probe on dist(camera, focus) against zoom0Distance / 2^zoom, printed once a second next to the focus and camera heights, during the gesture on the device:

zoom=12.15 dist=589 ratio=1.0000 focusZ=-218 camZ=76.5
zoom=11.06 dist=1259 ratio=1.0000 focusZ=-501 camZ=128.1 <- label depth to the terrain: 115

ratio staying at 1.0000 is what makes this readable: the invariant the SDK maintains was intact the whole time — the camera distance did match the zoom. What was broken is the unwritten second invariant, that the focus is on the ground you are looking at. The 1259 against a terrain depth of 115 is the entire bug.

Two things this rules out, both of which cost a round: the camera-clearance clamp (it was active and correct — maxTerrainZoom tracked the zoom throughout), and the sag tesselation above (both arms measured identical through a scripted zoom sequence — edge energy 17.4/24.7/17.2/25.0 against 18.0/24.8/17.8/24.8 — and the report predates it). A scripted setZoom sequence never reproduces it either: it zooms about the focus, so there is no pivot to sink anything. The demo's --es anim approach (dive, pan, pull out) is that sequence, and its clean run is what pointed at the pivot.

Labels partly hid it. Label::calculateTerrainScaleFactor rescales a label by depth / focusDistance to cancel the perspective divide, and that ratio cancels exactly this error too (it read 0.09 while the fault was worst). Geometry, fills and vector elements have no such cancel, which is why lines looked worse than text at first and why the 2D screenshot — where the cancel is near 1 — was the clearer evidence.

Residual, not fixed here. Even with the focus where the app put it, on a z=0 plane under a 1000 m ridge the focus still sits below the ground, so dist still overstates the distance to what is on screen — the same error, milder and always on. Tangram's answer is to derive the render zoom from the terrain depth at the screen centre (m_zoom from m_elevationManager->getDepth(centre), clamped to [m_baseZoom, m_maxZoom], core/src/view/view.cpp:403-415). Porting that redefines what getZoom() means for tiles, styles and labels alike, so it is its own change — see 11-tangram-diff.md.

The surface shader

TerrainOptions::setSurfaceShaderSource lets the application paint the terrain surface itself. It replaces the background bitmap and the background colour as the base fill (precedence: shader > bitmap > colour) and is drawn by TerrainRenderer::renderSurface where those are — globally, before any tile layer, with the same keepDepth semantics. So a map with no tile layer at all still shows shaded relief; that is the relief (peak-finder) case, and it is what 14-post-process.md draws its lines over.

The shader defines vec4 surfaceColor() and gets v_normal (world space), v_worldPos, v_elevation (metres, before exaggeration), v_dist (metres from the camera), the resolved sun (u_sunDir, u_sunColor, u_sunIntensity, u_ambientIntensity), the resolved fog (u_fogColor, u_fogRange, plus a fogAmount(dist) helper), u_time, u_zoom, u_resolution and every parameter set with setSurfaceParameter / setSurfaceColorParameter. Sun and fog come from resolveLighting / resolveFog (08-lighting-sky-fog.md), so a shaded surface, the tile content and the sky agree on the light. Redeclaring a provided name is a compile error and the shader is dropped (logged, falls back to the bitmap/colour fill) — the same trap as SkyOptions::setShaderSource.

Two implementation notes:

  • Normals are per-vertex and lazy. TerrainRenderer::ensureSurfaceAttribs fills a normal + elevation array from the mesh's own height field the first time a mesh is used by the surface pass — central differences in tile-local space, which is a world direction because the tile matrix scales x, y and z alike. The depth passes never allocate it (at grid 96 it would be 150 kB per tile).
  • Nothing else asks for elevation when there is no tile layer. The tile layers are what normally drive DEM loads, so the surface pass prefetches the DEM for its own visible tiles (ElevationManager::prefetchTileGrid) and keeps requesting frames until they arrive — the same argument, and the same code, as the terrain paint cover. Without it the surface shades a flat height field and the map goes idle on it.

Occlusion depth

Billboards and vector elements need to know whether a point is behind a ridge. The terrain is rendered into an FBO and read back — glReadPixels is a full pipeline stall (55–62 ms measured), so it runs on TerrainDepthWorker: its own thread, its own EGL context, deliberately not shared (the pass draws CPU meshes from client memory with its own program and FBO, so a job just holds shared_ptrs and nothing crosses contexts). The render thread only collects meshes (~0.8 ms).

Two GL contexts still share one GPU, so the submit interval matters more than the work: every frame 13.2 fps, 250 ms 14.3, 500 ms 14.9 (13.7 synchronous). Tangram does the same thing with a shared context and never waits on it.

Query with the buffer's camera, not the frame's

Symptom: labels that should be visible fade out and come back while zooming, in 3D only, at any camera (reported at 45.188/5.719 z13.18 t30 r-15). 2D never shows it because TileRenderer::updateLabelOcclusionTest returns early when terrain is off — 2D runs no occlusion test at all.

The buffer lags the camera by design (DEPTH_SUBMIT_MOVING_INTERVAL = 500 ms while moving, plus worker latency), and the test used to project the label with the current frame's MVP and compare that distance against it. Zooming out moves the camera farther than the 1 % tolerance floor (MIN_OCCLUSION_TOLERANCE) inside those 500 ms, so every anchor reads as behind the terrain, updateLabel fades it to 0, and the next read-back brings it back. Zooming in inverts the same mismatch through screen drift: the anchor samples a pixel the old camera had something else at — and getDepthW clamped out-of-range coordinates to the border pixel, so a label leaving the old frustum read a border ridge instead of failing open.

TerrainDepthBuffer now carries the mvpMatrix it was rendered with, and TerrainRenderer::isOccludedByTerrain projects with that matrix, samples in buffer pixels, and fails open (not occluded) for a position behind that camera or outside its viewport. Staleness then only makes the answer late, never inverted, which is what the throttle assumed all along. The query also takes the snapshot once instead of per sample, so the five taps cannot straddle a read-back.

Not affected: billboards and vector elements decide occlusion by ray-marching the elevation grids from the current camera (BillboardPlacementWorker), which is self-consistent already.

Draped lines sagging into the terrain (historical)

Symptom: lines do not sit on the surface — a route reads as sunk into a ridge or floating over it, worst at low zoom, straightening as you zoom in, at any tilt.

TerrainTileTransformer used to have two line-subdivision paths, and only one of them was exact. The lattice one — cut each segment exactly where it leaves a surface triangle (tesselateSegmentOnLattice), so every sub-segment lies in one triangle — is now the only one. The other halved segments until shorter than a threshold, and a sub-segment one mesh cell long still chords across the cell's diagonal fold and sags below it.

The bug there was lineDivideThreshold = divideThreshold: lines shared the fill threshold including its DEM-texel floor (max(tileMeters / meshResolution, demTexelMeters)). That floor answers "how much elevation detail exists", which is the right bound for a fill but the wrong one for the sag — the sag is against the surface mesh, not the DEM. Since the threshold is proportional to the tile, the error scaled with tile size, hence better on every zoom in.

Kept as a record because the same reasoning applies to anything else measured against the surface: bound it by the mesh cell, not by the data resolution.

Not fixed here: without the regular grid the sag is only reduced, never zero. Turning on regular-grid mode is what removes it, and that is a larger change (05-depth-model.md).

What that subdivision costs over a city

Line subdivision is the single reason panning over a city is slow. Crosscall, the app's own style, a 25 s scripted pan at 45.188/5.724 z15 t45, interleaved:

fpsGPU layersgeometry indices / frame
shipped6.651.3 ms2.90M
3D buildings off6.650.7 ms
area subdivision off entirely6.750.6 ms2.83M
lines at source density13.520.9 ms0.74M
terrain off altogether21.711.8 ms0.72M

Fills are innocent: turning area subdivision off changes nothing, because fills are draped and baked once. Lines are never draped — they are drawn as terrain geometry every frame — and a city is mostly lines. In regular-grid mode the lattice split does the cutting, at every cell edge and diagonal: about 64 cuts per tile crossing at z15 with meshResolution 64, per road.

Two things this reveals:

  • The split runs whatever the relief. The only flatness gate is FLAT_HEIGHT_RANGE_EPSILON (0.001 m), so a valley tile is cut exactly like a cliff to protect against a fold it cannot have. debug.massif.latticerelief <metres> skips the split under a given relief: the city goes 6.61 → 7.57 fps (layers 51.3 → 36.9 ms) at 200 m, and adding debug.massif.linethreshold 8 on those tiles reaches 8.43 fps / 32.5 ms. The mountain camera does not move (11.4–12.0 fps) — the gate never fires there, which is the point.
  • debug.massif.linethreshold alone does nothing in regular-grid mode: the lattice split is tried first and returns, so the threshold is only a fallback for segments spanning very many cells. Any measurement of line cost has to go through the lattice, not the threshold.

The remaining gap to source density (8.4 against 13.5) is the tiles that legitimately have relief — the mountains standing in the far half of a tilted city view. They are cut as finely as if they were under the camera, because subdivision cost is per tile and independent of the tile's size on screen.

Where this should go: pay in depth, not in vertices

Tangram does not subdivide at all. res/scenes/terrain-3d.yaml displaces every vertex in the vertex shader and pays for the chord with depth instead — depth_shift = -0.02*u_proj[2][3], larger near the camera where the chord error is. We already ported that shift, and we already have the better tool for a line: uDepthClearance, a clearance worth the same number of METRES at any range, which is exactly what a chord over relief needs.

What blocks using it is that setTerrainLineClearance is one global value, so it has to cover the worst tile on screen — which is why the code notes that un-subdivided lines need a lift so large it "shines everything through".

Cutting a line by its sag instead of by the tile's cell count

tesselateSegmentBySag splits a segment only where the terrain under it actually leaves the chord, recursively, until the residual sag is under a tolerance — expressed in METRES so it is the same currency as the depth clearance that lifts these lines. It replaces both the lattice split and the fixed threshold, and it is the shipped path since 2026-08-13: DEFAULT_LINE_SAG_METERS = 2, with debug.massif.linesag <metres> as the override and debug.massif.linesag 0 going back to the old lattice split for an A/B.

The insight is that sag measures curvature, not slope. A road running along a constant slope chords perfectly: its sag is zero and it needs no cut at all. Only a break in slope needs one. The lattice, which cuts at every cell edge and diagonal, was therefore paying about 4x the geometry the terrain's shape actually asks for.

Crosscall, the app's packaged style, 25 s scripted --es anim pan, three interleaved pairs. Per-frame counts, not per interval — RenderStats sums over the log interval, so the faster arm prints bigger totals (10-performance.md):

fps (3 runs)geometry indices / framedraws / frame
city z15 t45, lattice7.4 / 7.5 / 7.62.37M210
city z15 t45, sag 2 m13.4 / 14.1 / 13.80.70M213
mountain z13.6 t45, lattice10.8–13.71.31M140
mountain z13.6 t45, sag 2 m17.0–21.30.37M140

Same draw count, 3.4x less geometry: the win is in what gets tesselated, not in what gets submitted. The mountain gains as much as the city, which is the point — relief does not imply curvature.

The tolerance is not what binds. 0.5, 1, 2 and 4 m measure the same at both cameras (all within the run-to-run spread, 0.37–0.70M indices/frame), so the value is chosen for margin: a draped line is already lifted DEFAULT_LINE_CLEARANCE_METERS = 25 m off the surface, and 2 m is an order of magnitude under that as well as well inside the surface mesh's own chord error. At a far tighter setting the splitter does keep tracking (0.01 m against 0.5 m differs, 3.43M against 3.39M indices), so it is live, not saturated.

Checked on screen at 45.244172/5.760595 z13.6 t45, z11 t60, and — the check that was missing before it became the default — a slow 30 s pan across the ridge at z11.5 t60 with vector elements on: the two arms are indistinguishable, no line sinking into a crest. The GeoJSON route line is broken at z11 in BOTH arms — that is the open route-following issue, not this.

Draping the lines, and keeping contours out of it

Cutting a line better does not change what a line costs to shade. With the sag split in place the city is still fragment-bound, and the whole of it is the lines: draping them (TerrainOptions::DrapeLines, --es drapeLines true) bakes them into the per-tile drape texture once instead of drawing them as terrain geometry every frame, and the frame collapses.

Crosscall, packaged style, 25 s pan at the city camera (5.724/45.188 z15 t45):

fpsCPU frameGPU totalGPU layers
lines as geometry (default)13.4–15.245 ms32.4–34.920.8–24.1
drape false (nothing draped)12.0–13.351–59 ms37.3–37.628.2–28.4
drapeLines true26.8–27.731 ms11.9–12.10.3
drapeLines true, drape resolution 204824.3–26.413.4–14.30.3
base map layer off (the floor)439.40.0

Draped lines land within 2.5 ms of the no-basemap floor. The cost is resolution: the bake resolves at the drape texture's size and a slope then magnifies it. Fills and road casings survive that; contours do not — they are hairline, and they smear.

Hence GLTileRenderer::setNoDrapeLayerFilter: style layers matching it stay OUT of the bake and are drawn live in the 3D pass at screen resolution, exactly once (the same predicate gates the bake loop, hasDrapeableContent and the 3D-pass skip). The application sets it through TerrainOptions::NoDrapeLayerFilter, a regex over vt layer names, defaulting to ^contour.*; an empty string drapes everything the geometry type allows, and adb shell setprop debug.massif.nodrapelayers <regex> (or none) overrides it for an A/B without rebuilding.

Both defaults changed on 2026-08-13: DrapeLinesEnabled is now true, with contours exempt. Verified on device with no props and no intent extras — city pan 26.0–27.2 fps, GPU total 11.8–12.6 ms, layers 0.8 ms. An application that wants the old behaviour sets DrapeLinesEnabled false; one that wants everything flattened sets NoDrapeLayerFilter to "".

What it costs, same runs: the city does not notice (26.8–27.7 → 22.8–26.8 fps, GPU layers 0.3 → 0.7 ms — there are barely any contour lines on a valley floor), the mountain pays for what it draws (32.9–42.1 → 24.2–31.3 fps at z13.6 t45), and is still far above the 17–21 it had with nothing draped.

Note the filter matches the vt layer name, which comes from the style's own rule names — a style that calls its contour rules something else needs its own pattern.

Putting the live layer back in its style position

Keeping a layer out of the bake also takes it out of the order. The drape composite for every visible tile is baked and drawn before any live geometry, and the per-layer pass then skips whatever went into the bake (GLTileRenderer.cpp, the drapedTile && isDrapeableGeometry && isLayerDraped skip), so a no-drape layer can only land on top of everything draped. With the defaults — filter ^contour|maneuver.*, DrapeLinesEnabled true — roads are baked and contours are drawn live over them, while 2D draws them the other way round. That is #175.

Three fixes do not work. Draping the contours too: the drape is parameterised by the tile's XY, so its ground resolution degrades as 1/cos(slope) and contours run along the slope, where the stretch is worst — no DrapeResolution fixes a parameterisation. Cutting the drape at the topmost no-drape layer: correct order, but roads stop being draped, and that is the 13.4 → 27 fps win in the table above. Splitting the drape into two RGBA textures: exact, but +4 MB per tile at 1024 against a 96 MB cache.

So: do not reorder the passes, occlude. The whole stack flattens into ordered units, one per style layer of each drape layer, each draped (D) or live (L). What the frame produces today is all D then all L, which is wrong at exactly one kind of position — a D after an L. Each L is therefore drawn through a coverage mask: the accumulated alpha of every D after it, sampled in drape-tile uv, alpha *= 1.0 - mask.

wherewhat
terrain/DrapeStackCuts.hthe ordering rule — flatten, walk backwards, one mask per L→D transition. Header-only, covered by tests/api/DrapeStackCutsTest.cpp
MapRenderer::onDrawFrameasks each drape layer for its D/L runs, stitches them into one sequence, bakes the masks inside bakeTile and hands them back before the layers draw
GLTileRenderer::bakeDrapeCoveragethe bake — the same covering tiles and transforms as bakeDrapeTile, restricted to the style layers at or after the cut, with the fragment stage writing alpha (COVERAGE) instead of colour
GLTileRenderer::resolveDrapeCoverageMaskwhich mask a live layer takes over a given tile, and the target-tile → mask-tile uv sub-rect
TerrainDrapeCachestack 0 is the RGBA drape, stacks 1..K the R8 masks — baked off the same fingerprint, so a mask can never describe a different generation of the map than the drape beside it

Two properties make it cheap. Coverage accumulation is order-independent (1-(1-a1)(1-a2) commutes), so the extra pass need not preserve style order internally; and consecutive L units with no D between them share a mask, so K is the number of L→D transitions — 0 or 1 for every ordinary style. The rule is derived per frame from isLayerDraped plus the existing drape-layer order, so a style that puts its contours above the roads still renders that way, at zero cost.

style orderKresult
landcover, hillshade, contours, roads1contours under roads, over hillshade
roads, hillshade, contours0contours on top — already correct, and free
landcover, contours, roads, maneuver1both correct; the maneuver layer has nothing draped after it

Costs and known limits:

  • K × 1 MB per drape tile at resolution 1024 (R8 — ES3 is a hard requirement on both platforms), against the 4 MB of the colour drape. TerrainDrapeCache budgets in bytes rather than entries for this reason: a count would let the masks eat a quarter of the cache's tiles for nothing.
  • One extra rasterisation of the above-cut units per mask, inside the existing bake budget.
  • Exact for opaque above-layers, approximate for translucent ones — a 50 %-alpha road gets the contour tinting it rather than the other way round. The exact fix is the 2×RGBA split.
  • A terrain paint contributes no coverage. It shades the ground the other layers put in the drape; treating it as an occluder would hide every live layer under it outright.
  • A drape tile finer than the geometry's render tile would need several masks in one draw. That draw keeps the pre-#175 behaviour and is drawn on top.
  • Capped at 2 masks (MAX_DRAPE_COVERAGE_MASKS); a deeper cut is dropped, logged once, and its layer draws on top as before.
  • A style with an opaque layer above the contours hides them completely. Correct, and what 2D already does, but it reads as a bug the first time.

A/B: adb shell setprop debug.massif.drapemask 0 and relaunch puts the live layers back on top of the whole drape.

The shader half of this never ran until now. DRAPE_MASK_FLAG was declared on SPAN_FLAG's bit, and flagDefineMap is keyed by the value, so DRAPE_MASK was never defined: the masks were baked and paid for every frame, and applyDrapeMask was the no-op stub. Worse, asking for the flag defined SPAN instead, which flattens a contour or maneuver line to uElevationScale.w — sea level in the tile frame — since its unbound aVertexBase reads as a resolved chord of 0. The flag now owns bit 2^30. The masked ordering above is therefore unverified on a device.