This document describes the 3D terrain rendering implementation for MapLibre Native.
- The reference implementation is upstream maplibre-gl-js; when in doubt,
match its behaviour - it has been in production a long time. Key files:
src/render/terrain.ts,src/render/render_to_texture.ts(render-to-texture draping),src/geo/projection/covering_tiles.ts(elevation-aware tile cover),src/ui/camera.ts(_elevateCameraIfInsideTerrain), and the terrain vertex prelude. - Most development and testing has been on Android / OpenGL; the other
renderers are implemented but far less exercised. Build a specific renderer
flavour rather than
assembleDebug(which fans out to every flavour and currently fails on WebGPU's Gradle config):cd platform/android && ./gradlew :MapLibreAndroidTestApp:installOpenglDebug -Pmaplibre.abis=<abi>(arm64-v8afor a phone,x86_64for the emulator).assembleOpenglDebugbuilds without installing;:MapLibreAndroid:assembleVulkanDebug/assembleMetalDebugto check the other backends build.
- Android test activities (the five "3D Terrain" entries under
activity/style, in the app's Style section; all five share theTerrainTestOptionsoverflow menu - see Performance below):TerrainActivity- self-contained style with color-relief + hillshade over 3D terrain (Matterhorn); a bright hypsometric colour ramp to show color-relief.TerrainVectorMapActivity- a full-planet vector basemap (OpenFreeMap Liberty) draped over terrain, DEM/hillshade/terrain added at runtime.TerrainOsmRasterActivity- OSM raster draped over terrain (Innsbruck); exercises the raster-draping cases.TerrainDebugTilesActivity- the gl-js terrain debug-tiles style (numbered tiles over synthetic terrain), for comparing tile zoom / draping against gl-js.TerrainFlightActivity- an automated FPV "drone" camera flight starting on the ground at Innsbruck and touring the surrounding Alps, sweeping a range of zoom / altitude / pitch / rotation over terrain (baked, elevation-planned path that stays above ground); useful for eyeballing draping, occlusion, and frame pacing in motion, especially with the rendering-stats HUD on.
Terrain rendering enables draping the map over 3D elevation data from Digital Elevation Model (DEM) tiles. This feature provides a 3D visualization of geographic terrain with realistic elevation. The architecture follows maplibre-gl-js (src/render/terrain.ts, src/render/render_to_texture.ts).
- All Backends: OpenGL, Metal, Vulkan, and WebGPU
- DEM Support: raster-dem tiles in Mapbox Terrain-RGB or Terrarium encoding, decoded via the source's unpack vector like hillshade and color-relief
- Layer Draping: background, fill, line, raster, hillshade, and color-relief layers render into per-tile render targets draped over the terrain mesh
- Elevated Layers: symbol, circle, and fill-extrusion layers sample the DEM in their vertex shaders and are displaced by the terrain elevation
- Exaggeration: styled vertical exaggeration multiplier (1.0 = true scale)
- Shared Elevation Function: a
get_elevation()helper in every backend's shader prelude
{
"version": 8,
"sources": {
"maplibre": {
"type": "raster",
"tiles": ["https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.png"],
"tileSize": 256
},
"terrainSource": {
"type": "raster-dem",
"tiles": ["https://demotiles.maplibre.org/terrain-tiles/{z}/{x}/{y}.png"],
"tileSize": 256,
"encoding": "terrarium"
}
},
"terrain": {
"source": "terrainSource",
"exaggeration": 1.5
},
"layers": [
{
"id": "background",
"type": "raster",
"source": "maplibre"
}
]
}| Property | Type | Default | Description |
|---|---|---|---|
source |
string | (required) | ID of a raster-dem source to use for terrain |
exaggeration |
number | 1.0 | Vertical exaggeration multiplier for elevation |
maplibre-gl-js recommends giving terrain its own raster-dem source (even with the same tile URLs as a hillshade/color-relief source) because gl-js keeps a separate terrain source cache, and sharing one source between the terrain and a hillshade layer makes two caches with different retention fight over one tile set.
MapLibre Native does not have that constraint: there is exactly one tile
pyramid per style source, and terrain, hillshade, and color-relief consume the
same RasterDEMTiles and share the decoded DEM data, so sharing a source is
coherent and avoids downloading and decoding the tiles twice. A terrain-only
source (no layer attached) also works; the render orchestrator marks the
terrain's DEM source as needing rendering.
For styles that must also run on maplibre-gl-js, follow the gl-js recommendation and declare separate sources; the test app examples do this.
Terrain decodes elevations with the same per-source unpack vector used by the
hillshade and color-relief layers (DEMData::getUnpackVector()), so both
supported raster-dem encodings work:
- Mapbox Terrain-RGB (
"encoding": "mapbox", the default):elevation = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) - Terrarium (
"encoding": "terrarium"):elevation = (R * 256 + G + B / 256) - 32768
The terrain system consists of several key components:
-
Style Configuration (
style::Terrain)- Parses terrain from style JSON
- Stores source ID and exaggeration
- Notifies observers of changes
-
Render Manager (
RenderTerrain)- Generates terrain mesh geometry (128×128 grid)
- Provides elevation lookups
- Manages DEM source references
-
Terrain Shaders (
TerrainShader, all four backends)- Vertex shader samples the DEM texture and displaces vertices by elevation
- Fragment shader samples the draped render-to-texture map tile
- Applies the styled exaggeration multiplier
-
Render-to-Texture Draping (
TexturePool,RenderTarget)- Each terrain tile has a persistent render target; the orchestrator's draped layer groups render into every target their tiles overlap (they are not moved out of the orchestrator)
- Targets are cleared to the map background color and carry depth and
stencil attachments; every overlapping source tile is drawn and the layer
groups' tile clipping masks resolve parent/child overlap
(
PaintParameters::clipMatrixForTilebuilds the masks with the drape placement) - A content-hash render cache (
RenderTarget::DrapeCoverage) skips re-rendering a target whose draped content, zoom, and evaluated properties are unchanged, so panning does not re-render every drape every frame
-
Terrain tile cover (
RenderTerrain::expandToDeepestCover,util::frustumCull,DEMElevationProvider)- Mesh tiles come from the DEM source's render tiles expanded to the ideal cover, then frustum-culled with each tile's DEM min/max elevation so relief facing the camera is kept and off-screen low-zoom tiles are dropped
- The same elevation feeds
Frustum::intersectsElevatedinutil::tileCover, so every source requests the tiles the terrain mesh needs
-
Symbol occlusion (
TerrainDepthShader,RenderTerrain::renderDepth)- A depth pass of the terrain mesh is packed into an RGBA texture; symbol shaders fade labels that fall behind the terrain
-
Elevated (non-draped) Layers (
RenderTerrain::getTerrainData)- Symbol, circle, and fill-extrusion tweakers bind the covering DEM tile's texture per drawable, with ancestor-tile fallback and a 1x1 placeholder when no tile is loaded
- Their vertex shaders call the shared
get_elevation()prelude helper, which does manual bilinear interpolation on DEM pixel centers using the 1px backfilled tile border, matching maplibre-gl-js
-
CPU Elevation Queries (
RenderTerrain::getElevation)- DEM tile lookup with ancestor fallback and bilinear interpolation,
mirroring maplibre-gl-js
Terrain.getDEMElevation
- DEM tile lookup with ancestor fallback and bilinear interpolation,
mirroring maplibre-gl-js
Not exhaustive - terrain touches many layers/shaders across four backends, and the git history is the source of truth. The main pieces:
Style: style/terrain.{hpp,cpp}, style/terrain_impl.hpp,
style/terrain_observer.hpp, style/conversion/terrain.{hpp,cpp}.
Rendering:
renderer/render_terrain.{hpp,cpp}- mesh generation, DEM caching, mesh tile cover, depth pass, elevation lookupsrenderer/render_target.{hpp,cpp},renderer/texture_pool.{hpp,cpp}- drape render targets (depth+stencil), the content-hash render cacherenderer/dem_elevation_provider.{hpp,cpp},util/tile_cover.*,util/bounding_volumes.*- elevation-aware tile cover and frustum cullrenderer/paint_parameters.{hpp,cpp}- drape clip-mask matrix (clipMatrixForTile)renderer/layers/terrain_layer_tweaker.{hpp,cpp}and the circle/symbol/ fill-extrusion tweakers - per-drawable DEM binding and elevationrenderer/render_orchestrator.*,renderer/update_parameters.hpp- integration
Shaders: shaders/terrain*.glsl and shaders/terrain_depth*.glsl (GL
sources), shaders/{mtl,vulkan,webgpu}/terrain{,_depth}.*, the shared
get_elevation() / apply_drape_transform() helpers in each backend's prelude,
shaders/terrain_layer_ubo.hpp, shaders/manifest.json.
Build: bazel/core.bzl and the top-level CMakeLists.txt both list terrain
sources explicitly - new src//include/ files must be added to both.
Terrain uses a regular 128×128-quad grid mesh (129×129 = 16,641 vertices, 32,768 triangles):
- Vertices span from (0,0) to (EXTENT, EXTENT) where EXTENT = 8192
- Z coordinate (elevation) is 0 in mesh data
- Vertex shader displaces Z based on DEM sampling
- Mesh is shared across all tiles for efficiency
Every backend's shader prelude defines a shared elevation helper. Unlike maplibre-gl-js (which uses global terrain uniforms), MapLibre Native carries the DEM binding per drawable, so the values are passed as arguments:
float ele = get_elevation(pos, u_dem, u_dem_coords, u_dem_unpack,
u_dem_dim, u_dem_exaggeration, u_dem_enabled);Layers opt in by adding the dem_* fields to their drawable UBO, binding the
DEM texture in their layer tweaker via RenderTerrain::getTerrainData(), and
calling the helper in their vertex shader (see the circle, symbol, and
fill-extrusion layers for the pattern).
Implemented:
- Terrain shaders and registration for all four backends (OpenGL, Metal, Vulkan, WebGPU)
- DEM decoding via the source's unpack vector (Mapbox Terrain-RGB and Terrarium)
- Layer draping for background, fill, line, raster, hillshade, and color-relief,
with depth+stencil attachments on the drape render targets. Every overlapping
tile is drawn and the layer groups' tile clipping masks resolve parent/child
overlap (gl-js
coordsAscending), so a drape target is only ever empty when the source genuinely has no tile for it - no more black-while-panning. - Elevation-aware tile cover: covering tiles are tested against the frustum with
the height of their DEM, so relief leaning towards the camera is requested
(
DEMDatamin/max,util::TileElevationProvider/DEMElevationProvider,Frustum::intersectsElevated), and the terrain mesh is frustum-culled so a sparse DEM's low-zoom ancestor tiles are not meshed off-screen. - Elevation for the non-draped layers: symbol (icon/SDF/text-and-icon), circle,
and fill-extrusion on all four backends, via the shared
get_elevation()prelude helper and per-drawable DEM bindings - Style parsing of the
terrainroot property (source,exaggeration) per the style spec, with exaggeration applied as styled (1.0 = true scale) - CPU elevation queries (
RenderTerrain::getElevation) with DEM tile ancestor fallback and bilinear interpolation - Line antialiasing gamma handled per drawable (1.0 when draped into a terrain render target, perspective ratio otherwise)
- Mesh skirts on all four backends: each tile edge is extruded into a curtain
that hangs below the surface by
u_elevation_offset(gl-jsu_ele_delta), hiding the cracks/stitches between neighbouring tiles at different zoom levels. The skirt flag rides in the mesh vertex's z (native analog of gl-jsa_pos3d.z);TerrainSkirtLengthis styleable ("auto" or a length) and the terrain depth pass drops the skirts too so they occlude correctly. Render teststerrain/skirts-autoandterrain/skirts-nonecover it. - The terrain mesh vertex shader now samples the DEM through the shared
get_elevation()prelude helper (backfilled 1px border, NEAREST fetch + post-decode bilinear on pixel centres), the same path the elevated layers use, matching maplibre-gl-js - Hillshade prepare-target lifetime fixed (no more OOM / monotonic GPU-memory growth while panning terrain with a hillshade layer - see Performance below)
The reference for each phase is the maplibre-gl-js implementation
(src/render/terrain.ts, src/render/render_to_texture.ts, src/shaders/_prelude.vertex.glsl).
Implemented following gl-js render_to_texture.ts semantics. Draped layer
groups stay in the orchestrator; each drape RenderTarget renders every
draped drawable whose tile overlaps its target tile. The drape projection is
orthographic, so placement into a zoom-mismatched target is an affine
transform in NDC, applied in the vertex shader (apply_drape_transform in
the prelude): the drawable's tile rides in the unused third column of its
tile-local drape matrix, and the target tile is carried in
GlobalPaintParamsUBO::drape_tile via a per-target copy of the global paint
params bound in the target's own render pass. On top of that:
- Every overlapping tile is drawn into the target and the layer groups' own
tile clipping masks resolve parent-over-child overlap, exactly as gl-js does
(
coordsAscending+renderTileClippingMasks). This is what fixed drape targets rendering black when their exact tile was not loaded: a parent tile standing in for unloaded children now reaches every child target it covers. It required giving drape targets a stencil attachment (gl-js's RTT framebuffer has one) and building the masks with the drape placement rather than the camera matrix -PaintParameters::clipMatrixForTileevaluates theapply_drape_transformaffine CPU-side, and the mask cache is invalidated on entering/leaving a drape (it is keyed only on the tile set). This replaced an earlier "pick one consistent tile per layer group" heuristic that returned nothing, hence black, whenever a target's tile was outside the loaded set. - A drape target keeps its previously rendered content while the available
coverage is temporarily worse than what is already baked in, so panning
does not degrade already-seen detail (
RenderTarget::DrapeCoverage). - Terrain mesh tiles stay at the ideal cover (
RenderTerrain::expandToDeepestCover), then are frustum-culled (util::frustumCull) so a sparse DEM's large low-zoom ancestor tile is not meshed across its off-screen extent - those off-screen meshes had no on-screen source to drape and rendered near-black. Only DEM/drape textures fall back to ancestors while tiles load.
Backend status:
- OpenGL: complete (per-target GlobalPaintParams copy bound per pass).
- Metal: complete (same mechanism; globals bind per encoder, so the per-target buffer swap works).
- Vulkan: complete, but the target tile travels in the push constants
next to the consolidation
ubo_index(32-byte range in the general pipeline layout,PaintParameters::currentDrapeTileat draw-record time): the global descriptor set is one per frame-in-flight with a dirty flag, so a per-target buffer swap would be silently ignored. - WebGPU: complete (same per-target buffer mechanism as GL/Metal). This works because drawables currently rebuild their bind groups on every draw, picking up the per-pass buffer swap; if bind-group caching is introduced later, drape targets will need per-target bind groups or dynamic uniform offsets instead.
Places where the native implementation reaches gl-js behavior through different mechanisms. Each is working; converging would simplify review against the gl-js reference, but the native variant may actually be the better fit for the drawable architecture — discuss/decide before spending time on any of these:
- Terrain tile cover — done (
RenderTerrain::computeMeshCover). Native used to derive terrain mesh tiles from the DEM source'sTilePyramidrender set and reverse-engineer the ideal cover out of it (expandToDeepestCover, now removed). It now computes the mesh/drape cover directly from the transform viautil::tileCoverwith the DEM elevation provider and the frame's LOD params - an elevation-aware, LOD-based ideal cover, the way every other source is covered and the way gl-js'scoveringTiles({tileSize: 512, terrain})does. Both the drape-target allocation (Renderer::Impl) and the mesh build (RenderTerrain::update) call it with the samestate/updateParameters, so the two sets stay in lockstep. This fixed draped roads going blurry over a sparse DEM (e.g. mapterhorn): high-zoom DEM tiles 404 and native falls back to a lower-zoom DEM tile, and the old cover inherited that low zoom, so the fixed-size drape target spread over too much ground. The mesh cover now stays at the view's ideal zoom and only the DEM/drape textures fall back to ancestors per tile while exact tiles load. The LOD zoom selection insidetileCoveris still not itself elevation-aware (see the near/bottom frontier skirt under Phase 3). - Drawable tile id transport: the tile id rides in the unused third
column of the drape matrix to avoid a 12-struct UBO layout sweep across
four backends. Explicit
drape_tileUBO fields would be the boring, reviewable version. (gl-js needs neither: WebGL setsu_matrixper draw call; native's prebaked-UBO/deferred-recording architecture cannot.)
Downstream users include real-time applications (e.g. aviation/flight
planning on Android) where sustained frame rate matters more than fast
initial load. The content-hash render cache (above) already skips re-rendering
unchanged drape targets. Remaining levers: the per-frame CPU work in
RenderTarget::computeDrapeCoverage / renderDrapedLayerGroups (drawable
visits scale with targets × drawables, run even on a cache hit), the hardcoded
512x512 drape target size, and DEM texture upload scheduling. Real-device
profiling welcome.
Progressive loading budgets + TerrainLoadMode API. Zoom-in / tilt / pan
that reveals new coverage otherwise stalls a frame while a burst of new tiles
build their drawables and dirty drape targets all re-render at once (measured
on PowerVR GE8320: worst interaction frames 713ms/233ms). Two per-frame budgets
spread that work across frames (gfx::Context::allowNewTileBuild for new-tile
drawable builds; the drape re-render cap in Renderer::Impl::render via
RenderTarget::render's canRerender), each requesting a follow-up frame so
deferred work catches up. Because the trade (initial-load sharpness / a bit of
progressive fill-in vs smoother interaction) is hardware-dependent, it is a
per-map API knob, not a style property: Map::setTerrainLoadMode /
TerrainLoadMode { Quality, Balanced, Performance } (include/mbgl/map/mode.hpp,
terrainLoadBudget() maps each mode to a budget pair). Default Quality =
unlimited = the historical sharp snap-load, so nothing regresses; Balanced =
32 tiles / 16 drapes per frame, Performance = 8 / 4. Exposed on Android
(MapLibreMap.setTerrainLoadMode, TerrainLoadMode enum). All five "3D Terrain"
test activities share a TerrainTestOptions overflow menu that toggles terrain,
switches load mode, toggles the built-in rendering-stats HUD (FPS + frame
timings + counts), and toggles an above-ground-margin debug log (off by default,
gated so the sampling does not cost anything when unused); the same controls are
driveable over adb (am broadcast ... --es cmd terrain_toggle, plus launch
extras) for on-device testing. Not yet exposed on iOS/macOS.
Fixed - runtime terrain re-enable left the map blank until panned. Toggling
style.setTerrain(null) then back on over an otherwise static map left the map
blank until the view moved (re-entering the activity rebuilt correctly). Root
cause was not the draped renderToTerrain routing: on the first frame after
re-enable, RenderTerrain::update - which resolves the DEM source
computeMeshCover needs - has not run yet, so the drape cover is empty and no
drape targets are created; with nothing else loading, the map idles blank. Two
fixes in Renderer::Impl::render: (1) request a bounded number of follow-up
frames while an enabled terrain has an empty cover, so the next frame (DEM source
now resolved) covers normally; (2) force a drape re-bake on the frame the cover
reappears - the draped drawables still hold screen-space UBOs from rendering to
screen while terrain was off, so the tweakers must re-run before the fresh
targets are baked or the drape signature cache serves stale (blank) content.
Only observable on this branch (the drape signature cache is what serves the
stale content; upstream terrain-3d re-bakes every frame and never showed it).
Fixed - hillshade prepare-target memory leak / OOM (1efc0db). On a
terrain style with a hillshade layer, RenderHillshadeLayer appended every
per-tile "prepare" render target (a 512x512 Sobel target + its 514x514 DEM
input) to activatedRenderTargets, a list only cleared by the edge-triggered
markLayerRenderable(). While panning, the layer stays continuously
renderable, so the list was never cleared: live texture count climbed
monotonically (measured 175→634 in ~45s on PowerVR) until the process was
OOM-killed. This also degraded frame rate on lower-end GPUs as the working set
grew. Now activatedRenderTargets is reconciled against the tiles still in the
cover each frame (departed tiles' targets released, new ones registered, diffed
by identity so persistent tiles cause no churn), and
RenderOrchestrator::addRenderTargets drops any non-drape target it is the
sole owner of as a backstop. After the fix the same run plateaus and oscillates
within a band instead of growing.
Implemented following gl-js: a depth pass of the terrain mesh is rendered into a
packed-RGBA texture, and the symbol shaders fade symbols that are behind the
terrain (calculate_visibility), so labels no longer show through mountains.
TerrainDepthShaderre-renders the terrain meshes (same DEM displacement as the terrain shader, sharing its UBO layout) into a viewport-sized target, packing the fragment depth into RGBA8. It is driven byRenderTerrain::renderDepthafter the drape targets, from a depth twin of each mesh drawable held in a separate layer group; the existing terrain tweaker fills both groups' UBOs.RenderTerrain::getDepthTexturehands symbol tweakers the packed texture, or a 1x1 far-plane placeholder (everything visible) before the pass has run.- Symbol drawables bind it at
idSymbolDepthTexture; the test reuses thedem_enabledflag, since occlusion is exactly terrain-enabled.
Backend note - the depth lookup's V coordinate is not the same everywhere.
OpenGL samples the depth texture with the unflipped ndc * 0.5 + 0.5; Metal and
WebGPU have y-up NDC but a top-left texture origin, so they flip V; Vulkan needs
no flip because its y-down NDC and top-left origin agree, provided the position
is taken after applySurfaceTransform() (which is where its symbol shaders
already compute fade opacity). The convention was confirmed per backend against
the existing heatmap_texture shaders, which sample a render target the same
way: GL flips a_pos.y, Metal and WebGPU do not.
2026-07-19/20 update - Vulkan occlusion debugged and fixed on device; GL now suspected broken by the same change. Occlusion had never actually worked on Vulkan (the "verified... on OpenGL" note above was accurate, but Vulkan/Metal/ WebGPU following "the same structure" turned out not to be enough). On-device debugging (screenshots + targeted shader probes) found six independent Vulkan defects, all now fixed:
- The terrain depth layer group is never registered with the orchestrator (by
design - it's rendered only from
RenderTerrain::renderDepth), so the general upload pass skipped it: its drawables had no vertex buffers, and Vulkan'sbindAttributesfailed silently every frame. Fix: upload it explicitly inRenderer::Impl::render. GL builds attribute state at draw time, so it never hit this. RenderTerrain::renderDepthcreated the depth render target lazily, after the symbol tweaker had already boundgetDepthTexture()for the frame - symbols got the 1x1 far-plane placeholder for that frame, permanently so in single-frame renders (the render tests). Fix:prepareDepthTarget()runs before the upload phase now.- Vulkan's
unpack_depthreturned the stored[0,1]depth as-is, but symbol matrices carry no[-1,1]->[0,1]remap (only the terrain/drape matrices do- see Phase 2's original z-remap note above), so the compared z was still
GL-convention and the comparison almost never fired. Fixed to
*2-1, matching GL and Metal.
- see Phase 2's original z-remap note above), so the compared z was still
GL-convention and the comparison almost never fired. Fixed to
- The visibility test was a hard binary z compare; on-surface labels z-fought
it. Replaced with gl-js's actual
depthOpacity(): a small bias, a soft fade over ~0.002 NDC, and a second sample above the anchor (a label just behind a ridge still shows if its glyphs poke above it). Ported to all four backends. - Occlusion was gated on
dem_enabled(does this tile have DEM data), so labels from tiles outside the terrain cover - exactly the distant labels that pierce mountains near the horizon - skipped occlusion entirely. Added a globaldepth_enabledflag (terrain on) inSymbolDrawableUBO's former padding slot;dem_enabledstill gates only the elevation. - The position compared against the depth texture was
gl_Position- for screen-aligned text that's post label-plane/coord_matrix, a different clip space than the depth pass (and than gl-js, which compares its tile-projectedprojectedPoint). Switched all four backends to passprojectedPoint, with Vulkan additionally running it through a newsurface_transformed()helper (same transformapplySurfaceTransform()applies togl_Position).
Verified on Android/Vulkan on-device (TerrainVectorMapActivity, planet-vector
basemap over the Alps): distant city labels correctly hide behind the
Nordkette at both steep and shallow pitch, with gl-js's soft fade at ridge
crests. Committed as aaa145cc85df.
Known issue - GL regressed by the same fix (#6 above), unconfirmed cause.
After the Vulkan fix landed, GL symbol occlusion - previously verified working
on device - stopped hiding labels behind terrain (OpenGL flavour,
TerrainVectorMapActivity, reported on device 2026-07-20). Fixes #1-3 are
Vulkan/Metal-specific dead code on GL (its unpack_depth already had the
*2-1, and GL builds attribute state at draw time so #1 never applied), and
#4/#5 only broaden when occlusion applies, so they're unlikely culprits. The
prime suspect is #6: GL's occlusion was implemented and tuned against
gl_Position (the final label-plane-adjusted screen position), and switching
its calculate_visibility argument to projectedPoint (the raw tile-projected
anchor, matching gl-js and fixing Vulkan's shallow-pitch failure) may not be
correct for GL's particular label-plane/pitch/rotation pipeline, or may expose
a second, GL-specific bug that gl_Position happened to mask. Not yet
diagnosed on device - projectedPoint is provably what gl-js itself compares,
so the fix is more likely to find why it fails on GL than to revert it.
Next step: run the local terrain/occlusion-debug render test (committed in
c9fea76 at metrics/integration/render-tests/terrain/occlusion-debug/)
against a GL build to check whether the depth texture populates on GL the way
it was confirmed to on Vulkan (see the diagnostic pattern in the aaa145c commit
message / this session's history), before assuming the coordinate-space
hypothesis is right.
2026-07-23 - RESOLVED on GL, confirmed on device. Symbol occlusion works on
the Android OpenGL flavour (TerrainVectorMapActivity, mapterhorn vector map):
distant city labels fade out completely as the camera approaches a mountain
that comes between them and the viewer, and no labels bleed through foreground
ridges. The earlier "GL regressed" report did not reproduce on the current
build - it was most likely against a build that predated one of the six Vulkan/
GL occlusion fixes (the projectedPoint / depth_enabled changes in
aaa145c). The GL and Vulkan occlusion paths are now both verified on device.
This known issue is closed.
- Drape target size (
Renderer::Impl::texturePool). NowdrapeTileSize * drapeQualityFactor= 512 * 2 = 1024x1024, matching maplibre-gl-js, which renders its render-to-texture tiles attileSize * qualityFactor(a fixedqualityFactor = 2) specifically so draped thin lines are not pixelated/ aliased when the mesh magnifies them (src/render/terrain.ts,src/webgl/render_to_texture.tsrttSize). This was the fix for draped roads looking aliased/"not antialiased": the line AA band is sized to the screen's device pixel ratio, so at 512 (qualityFactor 1) it fell sub-texel in the lower-res target and produced no effective AA. Like gl-js, this is a fixed factor independent ofpixelRatio, so at very high DPR in the foreground the 1024 target can still be below screen resolution (softer, but no longer aliased);drapeQualityFactoris the single knob to raise it (each step is 4x GPU memory per target) or drop back to 1 on constrained devices. Was the long-standing// TODO: tile size. - Camera-terrain collision: a collision-only fix was prototyped and reverted (felt worse - it corrected only after the gesture); the real fix is the terrain-anchored camera in Phase 4
- Zoom-0 intensity bug (terrain and hillshade): at zoom 0 the relief is rendered far more intense than at zoom 1+ - terrain elevation looks over-exaggerated and the existing hillshade layer is over-shaded; zooming in past zoom 1 both look normal. Long-standing: the hillshade version predates this terrain work, so it is likely one shared cause - a zoom-dependent scale in the elevation/derivative math (e.g. a metres-per-pixel or DEM-derivative term that blows up when the whole world is a single tile at zoom 0), not something specific to terrain. Reproduces on device; gl-js does not show it. Worth fixing for both layers together. Not yet investigated.
- Coordinate picking against the terrain (gl-js coords/depth framebuffers)
- Elevation for CPU-projected along-line labels in viewport alignment (gl-js applies it in the CPU symbol projection)
- Fill-extrusion (buildings) are not occluded by terrain (reported on
device 2026-07-24): a building behind a hill renders through the hill
instead of being hidden by it. Unlike symbols - 2D billboards that needed the
explicit depth-texture
calculate_visibilitypath - fill-extrusions are real 3D geometry, so this is more likely a depth-test / draw-order gap against the terrain surface (the elevated building geometry not depth-testing against the nearer terrain mesh) than a need for the symbol depth-texture path. Not yet investigated. - The terrain mesh/drape cover is now a direct elevation-aware ideal cover
(
RenderTerrain::computeMeshCover, see the tile-cover item above), but only visibility is elevation-aware. Native's tile LOD system (tileLodMinRadius/tileLodScale/tileLodPitchThreshold) still drives the zoom selection and is not elevation-aware. - Frontier skirt at the near/bottom screen edge under steep pitch — partially
mitigated; the residual is Phase 4. (Diagnosed on device 2026-07-24,
mapterhorn vector map, 60° pitch.) At high pitch the near-bottom terrain rises
into view but the tile there is not in the cover, so the last meshed row's
skirts hang into the gap (with a sliver of background) until a pan pulls the
tile in. On-device logging established the mechanism precisely: the cover is
always fully meshed (
noDrawable=0) but too small — the tile is not in the cover at all.util::tileCoveris a top-down DFS that only descends into tiles whose flat (z=0) ancestor intersects the frustum, so a frontier tile whose terrain is in view but whose flat footprint is outside the sea-level frustum is never even visited.- Mitigation (done,
computeMeshCover): a one-ring dilation of the cover (all 8 neighbours), trimmed byutil::frustumCullwithDEMElevationProviderreporting a conservative range for not-yet-loaded tiles. This re-tests the neighbours the DFS skipped, keeping the ones whose elevation-extended bounds intersect the frustum; the elevation sign picks the side, so it is correct for terrain above or below sea level (bathymetry) without hardcoding a direction, and the trim keeps it from tripling the cover. Measured: at zoom 12 the skirt shrank from ~40% of the frame to a thin sliver at near-zero cost (+1–2 tiles). Drop thefrustumCullline for a pure uniform 1-ring (kills the sliver but ~2.7× the cover). - Residual (Phase 4): at higher zoom over tall relief the gap is several
tiles, not one, because the cover frustum is built from a camera anchored at
z=0 while the true view is from high on the terrain — the near tiles fall well
outside the sea-level frustum, and
frustumCullcorrectly trims the dilated neighbours there, so no ring count fixes it (a big skirt plus a white gap at the very bottom, the near-rays-miss-terrain signature). This is the terrain-anchored-camera work in Phase 4; the dilation only covers the moderate (roughly one-tile) case.
- Mitigation (done,
- Other transitional artifacts while panning/zooming: brief flat/empty far-field areas before a tile loads, and re-resolve flicker when the LOD migrates an area between zoom levels. The content-hash render cache and the ideal cover reduce these.
At high pitch over tall terrain the near (bottom) part of the view goes black: the camera sits at or below the terrain surface, so the near rays hit nothing and clear to the background. This is a camera-model problem, not a tile/drape one — confirmed on device with a clean tile diagnostic (all tiles covered, mesh == drawables) while the bottom of the frame was black below a terrain silhouette.
Research into why a quick fix is not enough (2026-07, Innsbruck, Android GL):
-
maplibre-gl-js decouples
center(a fixed lng/lat anchor) fromelevation(the terrain height at that anchor). "Centre clamped to ground" (getCenterClampedToGround, default on) setstransform.elevation = terrain.getElevationForLngLatZoom(center)each terrain update, so the whole view rides the terrain surface and the camera stays above it. A separate safety,_elevateCameraIfInsideTerrain, lifts the camera via a fully recomputedCameraOptions(calculateCameraOptionsFromTo) only when it dips below terrain. -
maplibre-native has no such decoupling.
TransformStatedefines the map centre as where the camera ray meets the z=0 sea-level plane (updateStateFromCamera, which also forces centre altitude back to 0), and the projection treatszas `cameraToSeaLevelDistance = cameraToCenterDistance- |z|/cos(pitch)
. Feeding a terrain height intosetCenterAltitude` therefore interacts with the sea-level-anchored model so that the resolved centre shifts, which re-samples a higher elevation, which shifts it again — a runaway pan (observed: the centre jumping ~40 km per frame, "the map pans as soon as you touch it").
- |z|/cos(pitch)
So a correct "camera rides the terrain" feature (the gl-js
getCenterClampedToGround behaviour) requires teaching native's camera model
that the centre sits on terrain rather than sea level — a real change to
TransformState's centre/plane math, not a wrapper around setCenterAltitude.
That is the Phase 4 work.
The collision-only subset (gl-js _elevateCameraIfInsideTerrain: recompute a
consistent CameraOptions and jumpTo only when the camera is below terrain,
which never touches the core plane math) was prototyped and then reverted - it
kept panning stable and did not run away, but only corrected after the gesture
(a brief black flash while zooming in, then a pop back out), which felt worse
than leaving the camera alone. A render→map elevation channel
(RendererObserver::onTerrainCameraCollision, forwarded per platform) plus
TransformState::getCameraLatLng / getCameraAltitude /
cameraCollisionCorrection and RenderTerrain::getElevationForLatLng were the
building blocks; see the reverted commits for the reference implementation. The
right Phase 4 fix is the terrain-anchored centre above, which prevents the camera
from entering the terrain in the first place rather than correcting afterwards.
- (done) Removed the TEMP terrain diagnostics: the throttled drape-coverage
warning and
DrapeCoverage::emptyGroupsin render_target.cpp, and the per-frame terrain-summary /skippedNoDEMlogs in render_terrain.cpp. - (done) Removed the terrain bring-up debug scaffolding: the
TERRAIN:texture/upload logs in mtl/drawable.cpp and thegetName() == "terrain"logs in mtl/layer_group.cpp (both back to their upstream bodies bar therenderToTerrainconstructor arg); the checkerboardcreateTestMapTexturefallback, the deadfindDEMSource, and the no-opupdate(UpdateParameters)in render_terrain.cpp; the opt-inMLN_VALIDATE_UNIFORM_BLOCK_BINDINGSblock in gl/drawable_gl.cpp; and the Metal terrain shader's elevation-gradient debug fallback (it now samples the drape texture like the GL/Vulkan/WebGPU shaders). The// TEMP: Disable depth testingcomment is gone — the drape pass not testing/writing the main depth buffer is intentional and unchanged. The duplicated DEM sub-tile mapping (dz/scale/dx/dy) in render_terrain.cpp is now a singledemSubTileOffsethelper. The remaining logs in render_terrain.cpp are one-time warning/error logs; terrain_layer_tweaker.cpp has no per-frame logging. - Extend the draped-flag gamma handling to the line gradient/pattern/SDF variants
- Decide whether heatmap should be draped (gl-js does not drape it)
- Runtime styling API for terrain (
setTerrain) on iOS/macOS (Android has it)
- Render tests:
metrics/integration/render-tests/terrain/containsdefault,pitched-world,skirts-auto,skirts-none(all ported from maplibre-gl-js) andocclusion-debug(the local symbol-occlusion diagnostic,c9fea76). They are un-ignored (no terrain entries left inmetrics/ignores/) and run as part of the normal render-test suite, with native-rendered baselines captured on-device (default/pitched-worldun-ignored in5b17c5a, skirts in5303d65;pitched-world's baseline refreshed in5d39f97).- Current status: all five FAIL on Linux CI. The style JSON is verified
byte-identical to the gl-js originals (only
defaultdiffers, in whitespace), so these are faithful ports. They fail because native's terrain tile-cover requests tiles the fixture DB does not have - the overview pyramid and the near/frontier edges (e.g.terrain-shading/12-2178-1433, theterrain/z6-z9 ancestors) - so terrain renders empty on CI and the pixel compare fails. Those exact tiles are absent from gl-js's own fixtures too: for the identical camera gl-js requests a different tile set, so there is nothing to copy in. This is a symptom of the Phase 3 tile-cover / Phase 4 terrain-anchored-camera work (native over-requests overview/near tiles vs gl-js), not a baseline or fixture-loading bug. Once the camera/tile-cover converges with gl-js the tests should request the fixture set that already exists. Render tests read tiles from the SQLitemetrics/cache-style.db(flat tiles undermetrics/integration/tiles/are loaded into it); there is no official populate tool yet. - The gl-js
terrain/symboltest is still worth porting now that symbols are elevated (needs its DEM fixtures loaded intometrics/cache-style.db).
- Current status: all five FAIL on Linux CI. The style JSON is verified
byte-identical to the gl-js originals (only
- Android: the test app has five "3D Terrain" activities in the Style
category (
TerrainActivity,TerrainVectorMapActivity,TerrainOsmRasterActivity,TerrainDebugTilesActivity,TerrainFlightActivity- see "Continuing this work" for what each covers). Developed and tested on the
openglflavour; the other flavours build but are far less exercised on device.
- see "Continuing this work" for what each covers). Developed and tested on the
- Load-mode benchmark:
metrics/benchmarks/terrain-load-mode-bench.shruns the FPV-flight activity once perTerrainLoadMode(quality / balanced / performance) over the same deterministic baked path with the stats HUD on, capturing a clean steady-state window of thePERF-HUD fps/worstFrameMs/jank/maxEncodeMslog into a per-mode summary (fps mean/p5/min, worstMs median/p95/max, jank-per-second, encode p95/max). It is built for cross-device comparison of the budgets: run it on a high-end and a low-end device at the same commit and diff the summaries. Baseline on an Adreno 750 (SM-S948U): Quality is the smoothest (~89 fps, ~10 jank/s) and the throttling modes add jank (~15 jank/s, worse frame-time spikes) - the budgets are meant to help weaker GPUs where the snap-load burst stalls a frame, not fast ones. Usage:metrics/benchmarks/terrain-load-mode-bench.sh <out-dir> [abi] [secs] [build]. - Or load any style with a
terrainroot property, e.g. the example above.
// Create terrain configuration
auto terrain = std::make_unique<mbgl::style::Terrain>("demSourceID", 1.5f);
// Apply to style
style->setTerrain(std::move(terrain));
// Query terrain
auto* currentTerrain = style->getTerrain();
if (currentTerrain) {
float exaggeration = currentTerrain->getExaggeration();
std::string sourceID = currentTerrain->getSource();
}
// Remove terrain
style->setTerrain(nullptr);// Requires a raster-dem source in the style (its TileJSON may carry the encoding)
style.addSource(RasterDemSource("terrain-source", "https://tiles.mapterhorn.com/tilejson.json"))
style.setTerrain(Terrain("terrain-source", exaggeration = 1.0f))
val terrain = style.getTerrain() // null when terrain is not enabled
style.setTerrain(null) // disable terrain{
"terrain": {
"source": "terrain-source-id",
"exaggeration": 2.0
}
}Implementation based on maplibre-gl-js terrain rendering architecture. Generated with assistance from Claude Code.