Skip to content

Terrain 3d color relief#4389

Draft
acalcutt wants to merge 121 commits into
maplibre:mainfrom
WifiDB:terrain-3d-color-relief
Draft

Terrain 3d color relief#4389
acalcutt wants to merge 121 commits into
maplibre:mainfrom
WifiDB:terrain-3d-color-relief

Conversation

@acalcutt

@acalcutt acalcutt commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Merge current main into Terrain 3d and try to update it to support color-relief.

JesseCrocker and others added 30 commits October 15, 2025 17:22
…endered to texutre. Once they are, they won't be projected right, and once that's fixed, performance will likely be terrible.
…xture (one tile), which the whole viewport is mapped onto.
…me as the terrain tile. This should be calculated elsewhere and passed in the TileID (at least that's how GL JS does it)
…e, which is drawn using all tiles and a hardcoded matrix. Next steps: Only draw tiles that overlap the given terrain tile, and set the appropriate transformation matrix
…rrainRttPosMatrix. Remove terrainRttPosMatrix from overscaledTileID, which is where it is in GL JS but really doesn't belong.
acalcutt and others added 30 commits July 14, 2026 00:23
The terrain root property could previously only be configured through
style JSON. Expose it on the Android SDK following the Light pattern:

- org.maplibre.android.style.terrain.Terrain value class
  (sourceId, exaggeration)
- Style.setTerrain(terrain)/getTerrain(), passing null to disable
- JNI marshals the two values directly (nativeSetTerrain,
  nativeRemoveTerrain, nativeGetTerrainSourceId,
  nativeGetTerrainExaggeration); the core style API and the render
  orchestrator already handle runtime terrain changes

The raster-dem source's TileJSON may carry the encoding field
(e.g. Mapterhorn's terrarium), which the tileset parser picks up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… activity

Rework the planet vector map example to add the Mapterhorn DEM sources,
hillshade layer (inserted below the first symbol layer), and terrain via
the new Style.setTerrain API instead of fetching and patching style JSON.

Add a '3D Terrain (debug tiles)' activity loading the maplibre-gl-js
terrain debug style (demotiles.maplibre.org/debug-tiles), which drapes
numbered debug tiles over synthetic terrain - useful for comparing tile
zoom variation and draping against the gl-js reference rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The style spec and maplibre-gl-js call the property terrain.source, and
core mbgl::style::Terrain exposes getSource()/setSource(). Align the
Android Terrain class with both instead of introducing a third name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ats divergence

Last night's emulator crashes proved the memTextures assert still fires
on the instrumented build, but the forensic dump went to logcat, which
did not survive the emulator reboot. On Android, abort through
__android_log_assert so the dump becomes the abort message and persists
in the tombstone and dropbox entry.

Also compare memTextures against the pool's shadow counter on every
allocate/free and log the first divergence: the pool is the sole writer
of memTextures on the GL backend, so the first mismatch pinpoints
whether the corruption is external or in the pool's own bookkeeping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… block

The Android emulator logs 'ValidateProgramUniformBlockBindings: No bound
buffer' + 'DrawElements: ValidateState() failed' and rejects the draw,
silently dropping geometry (seen while testing terrain, where it can
look like tiles loading inconsistently). ShaderProgramGL now keeps its
uniform block list, and in debug builds DrawableGL::draw checks every
block's binding point after binding UBOs, logging the drawable and
block name once per pair so the offending layer can be identified.

Also applies clang-format to the previous pool forensics commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renderer::Impl::render binds the global UBOs after drawableTargetsPass,
so every draw issued inside a render target (the terrain draping path)
ran with nothing bound at the global binding points. Validating drivers
such as the Android emulator reject those draws ('DrawElements:
ValidateState() failed'), dropping draped lines and fill outlines, and
other drivers read undefined data. Metal/Vulkan/WebGPU bind globals per
render pass, so their render-target passes were equally affected.

Found via the debug uniform-block check: every draped translucent
drawable (fill-outline, line, lineSDF) reported GlobalPaintParamsUBO
unbound at binding 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drapes the openstreetmap.org raster basemap over Mapterhorn terrain
with a hillshade layer, following the maplibre-gl-js 3D Terrain example
style. Exercises raster layer draping, which the other terrain
activities do not cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…form design

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(GL)

Draped drawables were routed into exactly one terrain render target (the
deepest ancestor-or-descendant match), so a tile at a lower zoom than the
terrain cover - a parent raster/vector tile standing in for unloaded
children, or a 256px raster source whose pyramid runs one zoom deeper
than the DEM - was drawn into a single arbitrary target while every
other target it covers rendered the clear color, producing patchy,
per-frame-changing drapes.

Rework the drape pass to match gl-js render_to_texture: draped layer
groups stay in the orchestrator, and each drape RenderTarget renders
every draped drawable whose tile overlaps its target tile (enable-
filtered, both passes, style order). 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 a new
GlobalPaintParamsUBO::drape_tile field via a per-target copy of the
global paint params bound in the target's own render pass. One drawable
can now record into every target it covers with no cloning and no
per-frame allocations, and the old move-drawables-into-single-tile-
layer-groups routing is deleted.

Also stop giving terrain drape matrices to layers that are never draped
(fill-extrusion, collision, heatmap), which previously picked them up
via the default getTileMatrix argument whenever their tile happened to
have a render target.

GL only so far; Metal/Vulkan/WebGPU shaders keep compiling (a buffer
larger than the declared uniform block is valid everywhere) and follow
next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
-Wmissing-designated-field-initializers is an error on the Android build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…terrain logging

The debug uniform-block check called glGetIntegeri_v per block per draw;
on the Android emulator every glGet* is a synchronous round-trip to the
host GPU process, and with the drape pass recording draped drawables
into multiple render targets it dropped the frame rate to under 1 fps.
It already served its purpose (identifying the unbound
GlobalPaintParamsUBO in the render-target pass), so gate it behind
MLN_VALIDATE_UNIFORM_BLOCK_BINDINGS.

Also remove the per-frame Log::Info/Warning terrain debug logging
(render_terrain, terrain_layer_tweaker, orchestrator DEM marking), which
was on the TERRAIN.md cleanup list; each line is a binder transaction on
Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rawing all of them

The render tile set legitimately overlaps while tiles load (a parent
standing in for missing children next to already-loaded children). The
main passes resolve this with stencil clipping, but drape targets have
no stencil attachment, so the blurry fallback parent drew over its
sharp children and stayed there for as long as any child under it was
missing - panning the vector map left the drape permanently blurred.

Per (target, layer group), enable a single consistent coverage like the
gl-js per-terrain-tile tile stack: exact matches if present, else the
deepest covering ancestor, else descendants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s degraded

Panning briefly drops tiles from the render set (new edges load, LOD
boundaries move), and the drape target would re-render from an ancestor
fallback, visibly blurring areas whose full-detail rendering was on
screen a frame earlier - and hillshade could disappear entirely while
its two-phase prepare catches up.

Track the coverage quality baked into each drape target (groups with
content, zoom levels lost to ancestor fallbacks) and skip re-rendering
when the currently available coverage is strictly worse: the texture
keeps the sharper previous rendering until the tiles catch up. A change
in the style's draped layer set forces a re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arent fallbacks

When the DEM cover fell back to a parent tile during panning, a parent
mesh was created whose big low-resolution drape target drew over the
sharp meshes of its already-loaded children - a slight pan degraded the
whole scene, and where the parent's drape had only partial layer
coverage the rest showed as the clear color (the large dark region seen
over Hall in Tirol).

Like maplibre-gl-js, terrain mesh tiles now always stay at the ideal
cover: parent fallback render tiles are expanded into their ideal-zoom
children (RenderTerrain::expandToDeepestCover), each with its own drape
render target, and only the DEM textures fall back to the closest
cached ancestor (via the existing dem_coords machinery) while tiles
load. DEM texture decoding is done once per loaded render tile up
front; mesh drawables and drape targets are keyed by the expanded set
in both RenderTerrain::update and Renderer::Impl.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd performance target

The convergence items are explicitly marked ask-before-doing: the native
mechanisms work and may be the better fit for the drawable architecture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The persisted abort message from the emulator finally explained the
Texture2DPool 'memTextures >= 0' crashes: all pool-internal counters
agreed at 2,241,014,164 bytes (2204 live 514x514 DEM textures, 0
reuses, 73 freed) - the pool bookkeeping was always consistent, but
RenderingStats::memTextures is a 32-bit int and wrapped negative past
2GB, tripping the assert. The real defect is RenderTerrain::demTextures
growing without bound: ancestor/descendant retention keeps whole zoom
chains alive while browsing, eventually OOMing the process (the earlier
silent app deaths).

- Track lastUsed on each cached DEM texture and evict least-recently-
  used entries not referenced in the current frame beyond a 96-texture
  budget (~100MB)
- Widen RenderingStats::memTextures to int64_t so the stat cannot wrap
  (and the pool assert is a genuine corruption detector again)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t type

memTextures is int64_t now, so the deduced list type conflicted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-less mesh tiles

Tiles are confirmed loading at the HTTP level while large dark regions
persist in the drape; these temporary logs name the affected targets
(coverage per target) and the terrain state (render tile zoom histogram,
mesh/texture/drawable counts) once per ~120 frames. Remove before merging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The coverage metric counts layer groups with drawables overlapping the
target, which cannot distinguish 'layer has no features in this tile'
from 'tile not loaded yet'. A target baked during load/churn with
transiently inflated coverage could then be skipped indefinitely
(current honest coverage always compared worse), locking in stale or
near-empty content - seen as persistent dark tiles. Keep degraded-
coverage skipping for 2 seconds after a bake, then render whatever is
actually available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e by DEM tier

Mesh tiles with no DEM (own or ancestor) were skipped entirely, leaving
holes where the screen clear shows through (white areas at the near edge
while panning). Render them flat with the 1x1 placeholder DEM so the
draped map content still appears, and track a per-tile DEM quality tier
(placeholder < ancestor < own) so drawables upgrade as soon as any
better DEM becomes available (previously an ancestor arriving after a
placeholder did not replace it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Metal: drape_tile added to the GlobalPaintParamsUBO prelude struct with
the shared MSL helper; draped vertex functions that lacked the global
paint params argument gained it, and every draped position computation
goes through apply_drape_transform. Metal binds global buffers per
encoder, so the per-target GlobalPaintParams copy works as on GL.

Vulkan: the global descriptor set is allocated once per frame-in-flight
and guarded by a dirty flag, so swapping the global buffer per drape
target would be silently ignored (and rewriting a recorded set is
invalid). The target tile instead travels in the push constants next to
the consolidation ubo_index (range widened to 32 bytes), fed from the
new PaintParameters::currentDrapeTile at draw-record time. Also stop
casting offscreen renderables to SurfaceRenderableResource in
bindGlobalUniformBuffers (drape targets are not surfaces).

WebGPU is documented as pending: no push constants and cached bind
groups mean neither channel reaches per-target data; exact-match drapes
render correctly there in the meantime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same per-target GlobalPaintParams mechanism as GL and Metal: the WGSL
shaders gain the drape_tile field (and raster/hillshade/color-relief
gain the group(0) binding(0) paintParams declaration, which the
source-scanning bind group layout reflection picks up), the shared
helper lives in the WGSL prelude, and every draped position computation
goes through apply_drape_transform. This works because drawables
rebuild their bind groups on every draw, so the per-pass global buffer
swap is picked up naturally; if bind-group caching is introduced later,
drape targets will need per-target bind groups or dynamic offsets.

The drape routing rework is now implemented on all four backends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Following gl-js: the terrain meshes are rendered once per frame with a
new TerrainDepthShader into a viewport-sized render target, packing
gl_FragCoord.z into RGBA8 (terrain_depth.fragment.glsl, same packing as
the gl-js terrain_depth shader), cleared to the far plane. The symbol
vertex shaders unpack it in the new calculate_visibility() prelude
helper and zero their fade opacity when the symbol's clip position is
behind the terrain, so labels no longer show through mountains.

- RenderTerrain keeps a depth twin of each mesh drawable in a separate
  layer group (not in the orchestrator), rendered by renderDepth() after
  the drape target pass; the existing terrain tweaker fills both groups'
  UBOs
- getDepthTexture() hands symbol tweakers the packed depth texture, or
  a 1x1 far-plane placeholder (symbols visible) when the pass has not
  run - also the graceful path on backends without the depth shader yet
- Symbol drawables bind it at the new idSymbolDepthTexture slot; the
  visibility test reuses the dem_enabled flag (occlusion is exactly
  terrain-enabled)

GL only in this commit; Metal/Vulkan/WebGPU keep rendering symbols
unoccluded via the placeholder and follow next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The static member definitions in shader_info.cpp need the explicit
specialization declared in the header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng main

The merge from origin/main (07f8f33) recorded the pre-merge submodule
commit instead of main's updated one, which enables FastPFOR encodings
in MLT and is what the newly merged issue12432 test fixtures depend on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shader code generator emits MBGL_DEFINE_ENUM in a flat, unindented
form; the repo commits these generated files clang-formatted (main's
version is), but the Phase 2 commit that added TerrainDepthShader to the
enum did not run the post-generation clang-format pass. No content
change, only the BuiltIn enum block reindented to match clang-format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…main

Reverts the erroneous bump in 4c28990. origin/main records d457bf2b
for this submodule; I had mis-read a stale working-tree checkout and
advanced it to f493beb6, a newer mlt-cpp whose API diverges from the
code main ships (Decoder lost its bool ctor, Feature::getID returns a
plain id_t instead of std::optional), breaking the arm64 compile of
src/mbgl/tile/vector_mlt_tile_data.cpp. d457bf2b also vendors FastPFOR
as a plain static lib with no SIMDe FetchContent, so it does not hit the
duplicate-simde-target CMake error on aarch64 that f493beb6 does - no
FastPFOR workaround needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diagnostics showed drape targets re-rendering with 0 of N draped layers
(dark, over the clear color) and then recovering - the correct->dark->
correct flicker. Two flaws in the keep-baked-content heuristic let the
regression through: the skip required the draped-group count to be
unchanged, but that count wiggles frame to frame while updateLayers
recreates groups during loading (observed 80->82->83->80), bypassing the
skip; and a 2-second time bound let the dark re-render through whenever a
tile's content gap outlasted it.

Replace both with a single rule: keep the previously baked texture
whenever the currently available coverage is strictly worse (fewer
layers with content, or coarser ancestor fallbacks), re-rendering only
when coverage is equal or better. A target never regresses to less than
it already shows; its own lifetime bounds staleness (when the terrain
tile leaves the cover the target is destroyed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RenderingStats::memTextures is now int64_t; the render-test GfxProbe
tracks memory as int, so std::max(stats.memTextures, prev...peak) mixed
int64_t and int and failed to deduce. Cast to int at the call site
(render tests never approach 2GB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android build Related to build, configuration or CI/CD core Changes that affect the C++ core of MapLibre Native iOS metal OpenGL Issues related to the OpenGL renderer backend Vulkan WebGPU

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants