Skip to content

Terrain 3D - #4190

Draft
NathanMOlson wants to merge 95 commits into
mainfrom
feature/terrain-3d
Draft

Terrain 3D#4190
NathanMOlson wants to merge 95 commits into
mainfrom
feature/terrain-3d

Conversation

@NathanMOlson

Copy link
Copy Markdown
Collaborator

Working in mbgl-render!

image
  • Symbols, Circles, and other layers that are rendered on top are not displayed at the correct altitude. We need to get the elevation at the symbol position in the shader.
  • Line layers need different shader behavior when rendering to terrain vs not. GL JS achieves this using #ifdef TERRAIN3D.
  • 3D buildings: not started. Also need to get elevation in the shader.
  • Support for tiles rendering onto smaller terrain tiles. The current implementation supports multiple tiles being rendered to a single terrain tile, but not a single tile being rendered to multiple terrain tiles. Changing this will likely require significant redesign: getTileMatrix() will need more information from somewhere.
  • coveringTiles(): use terrain in calculation of covering tiles.
  • Use lower res terrain tiles? And figure out how to set the tile size for the render-to-texture buffers.
  • CustomLayer/PluginLayer: make render-to-terrain optional? Does this require a style spec change?
  • Object re-use. All testing so far has been done using mbgl-render, which uses Static map mode. Performance will likely be bad in Continuous mode, and there may be memory leaks.
  • Support transparency

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 13 commits September 5, 2026 21:53
Skirts hide the hairline stitches between neighbouring terrain tiles at
different zoom levels, but show as vertical curtains where the map has a
transparent background - so which one you want is a per-map tradeoff, not a
property of the style. TerrainSkirtLength::None builds the bare grid instead of
shortening the curtain; Auto (~1/5 of the tile's width at the current zoom)
stays the default and the previous behaviour.

The setting rides on UpdateParameters beside TerrainLoadMode, mirroring gl-js's
MapOptions.terrainSkirtLength rather than inventing a style property. Because
the skirts are baked into the shared mesh, changing it at runtime drops that
mesh and every tile drawable holding a buffer built from it.

Exposed on Android alongside setTerrainLoadMode, with a "Tile skirts" toggle and
skirts_auto|skirts_none|skirts_toggle adb commands in TerrainTestOptions - which
is currently the only way to actually see it work. The render tests
terrain/skirts-auto and terrain/skirts-none already carried a terrainSkirtLength
metadata field that nothing read, and the runner now applies it, but they do not
cover the option: their baselines hold no terrain, so both render identically
either way. TERRAIN.md records that, and it is why the option went unimplemented
without any test noticing. Map.TerrainSkirtLength covers the plumbing instead.

Port of maplibre-gl-js #7523.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A raster-dem tile was padded with a 1px border, so the bilinear fetch in
get_elevation() at the tile's far edge ran off the texture and was clamped to
that single ring, flattening the elevation along every tile seam. The hillshade
prepare pass had the same problem one level up: its Sobel target was exactly
tile-sized, so the hillshade draw's own bilinear filter clamped at the edge
rather than blending against a neighbour.

DEMData now pads by 2px (stride = dim + 4) and backfillBorder fills two deep, so
the edge fetch reaches a real backfilled neighbour pixel. The prepare target
grows to dim + 2 - one ring of derivatives computed from outside the tile - and
the hillshade draw insets past that ring, which is what actually fixes the
interpolation. get_elevation(), color-relief and the prepare tile size are
updated to the wider stride across GLSL, Metal, Vulkan and WebGPU; the GL
headers are regenerated from the .glsl sources.

terrain/fill-extrusion's baseline is refreshed for this. It moves 45 of 262144
pixels - buildings near a DEM tile seam sit ~1px lower - which is the fix
working, not a regression: collapsing the outer border ring onto the inner one
to emulate the old clamped fetch reproduces the old image exactly. The refreshed
expected.png differs from the device-captured one in those 45 pixels and nowhere
else. terrain/default, occlusion-debug and pitched-world fail identically before
and after.

Port of maplibre-gl-js #8302.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RenderColorReliefLayer::update() runs every frame with no change guard, and its
updateExisting path - which updateTile invokes for every existing drawable -
called context.createTexture2D() and setImage() unconditionally. So every
visible color-relief tile allocated a fresh texture and re-sent the whole DEM
image on every frame, for pixels that had not changed. Hillshade and terrain
both already guard their upload of this same image; color-relief was the only
per-frame caller.

The upload now sits behind a cache on HillshadeBucket, cleared in
RasterDEMTile::backfillBorder next to the setPrepared(false) /
renderTargetPrepared invalidations that are already there - the only other point
where the DEM pixels change is a tile (re)load, which brings a new bucket. It is
kept separate from the hillshade prepare texture because that one needs NEAREST
filtering where color-relief needs Linear.

Measured on a 64px DEM tile: a steady-state frame went from re-uploading the
whole padded image (68 * 68 * 4 = 18496 bytes per tile) to zero.
ColorRelief.UploadsDEMTextureOncePerTile covers it, and fails on the old code
with exactly that number. Note the render-test suite cannot catch this - still
renders build each tile's drawable once, so they never reach the per-frame path.

Port of maplibre-gl-js #8209.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings #4515 (Event::OpenGL renamed to Event::GraphicsBackend), #4554
(missing symbols/glyphs), #4553 (break a label before a left parenthesis),
#4561, #4538, #4548 and #4555.

No textual conflicts - none of the seven incoming commits touch a file this
branch has changed. #4515 does need a fix git could not flag, though: it renamed
the enum and updated the twelve files that used the old name *on main*, while
this branch had added four more uses in files main never touched - the
uniform-block binding warning in gl/drawable_gl.cpp and three texture-pool
accounting diagnostics in gl/resource_pool.cpp. Left alone the merge compiles
against an enumerator that no longer exists, so those four are renamed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_elevation() picked its four bilinear corners by building normalised
coordinates - (floor(coord) + 0.5) / (dem_dim + 4.0) - and reading them back
through a NEAREST sampler, so landing on the intended texel depended on that
arithmetic rounding the way it was assumed to and on the sampler really being
NEAREST. The corners are now addressed as integers with texelFetch, which is
what the code always meant. Same for the hillshade prepare pass's 3x3
neighbourhood and the color-relief elevation/color ramp lookups, which are index
lookups into a one-row texture that this shader interpolates between itself.

The edge case the sampler used to absorb is now explicit: texelFetch out of
range is undefined where clamp-to-edge was not, and a tile with no DEM of its
own is bound a 1x1 placeholder whose only valid texel is (0, 0). Every terrain
corner fetch is therefore clamped to textureSize - 1, as upstream does.

Two deliberate departures from gl-js, which has only WebGL to satisfy:

- the prepare pass derives its texel from the interpolated tile coordinate
  rather than from the fragment position builtin, whose y origin differs between
  GL and Metal/Vulkan/WebGPU;
- the clamp bound comes from the texture rather than dem_dim, so the placeholder
  DEM stays in range.

Mirrored into the Metal, Vulkan and WebGPU shaders, including the WebGPU
fill-extrusion copy of the elevation helper. Verified on GL only: 985 unit tests
and 58 render tests pass, including all 51 hillshade tests - which exercise the
prepare pass's texel derivation - and terrain/fill-extrusion, which is pixel
sensitive enough to have caught the 2px border shift. The other three backends
build but are not exercised here.

Port of maplibre-gl-js #8145.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RenderTerrain::getElevation mapped its tile-local coordinate into the DEM and
then clamped the result to the DEM's edge, so a query beyond the tile it started
in - geometry crossing a tile edge, or a map centre near a boundary - came back
with the edge elevation rather than the elevation at the point asked for. A
wrong answer that looks plausible is worse than an obvious one, and both of the
consumers this query is meant to grow (a terrain-anchored camera, and elevation
for CPU-projected line labels) sit right on top of it.

normalizeTileCoordinates now resolves such coordinates onto the tile that
actually contains them, wrapping across the antimeridian via the global tile x
and reporting failure past a pole, where the grid does not continue. The in-DEM
clamp stays as a bound on the ancestor sub-tile mapping rather than as the thing
quietly answering out-of-tile queries.

This is step 1 of the Phase 4 plan now written up in TERRAIN.md. The coverage
index that would replace the per-call linear scan is deliberately not here: the
obvious cache of DEMData pointers dangles when a tile is evicted between frames,
and getElevation has no hot callers yet, so it should be built with its first
real consumer and scoped to a frame.

Port of maplibre-gl-js #7040's OverscaledTileID.normalizeCoordinates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…moves

The map centre rides a plane at the centre altitude - updateCameraState orbits
the camera about (x, y, z) - but updateStateFromCamera re-derived the centre from
where the view ray meets **sea level**, so it both zeroed the altitude and moved
the centre. Measured on a static map at pitch 60 with a 1500 m centre altitude, a
single nudge through setFreeCameraOptions collapsed the altitude to 0 and jumped
the centre 2.6 km north. It now intersects the plane at the current centre
altitude and derives zoom from the height above that plane, which makes
get/setFreeCameraOptions an exact round trip.

This is the path an FPV or flight camera drives every frame
(TerrainFlightActivity), so it bites well before terrain is wired into the
camera at all.

Worth recording for Phase 4, since it narrows the problem considerably: the
decoupled centre elevation the terrain-anchored camera needs already exists and
works. CameraOptions::centerAltitude sets z, easeTo/flyTo interpolate it, and
probing it at pitch 60 held the centre lat/lng and zoom bit-stable over ten
frames and survived five pans and three zooms unchanged. The runaway pan
TERRAIN.md attributes to the sea-level-anchored model does not reproduce through
jumpTo or gestures - this free-camera path was the one place that broke the
invariant. What remains for Phase 4 is the feature itself: setting centerAltitude
from the terrain each update.

Map.FreeCameraPreservesCentreAltitude covers it and fails on the old code on
altitude, latitude and zoom. Full render suite 1316 passed / 3 failed (the same
pre-existing terrain/default, occlusion-debug and pitched-world), 992 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4's feature: with Map::setCenterClampedToGround on - the default, as in
maplibre-gl-js - the centre altitude follows the rendered terrain height under
the centre, so pitching over tall ground keeps the camera above the surface
instead of sinking into it and blacking out the near field.

Only the render side has the DEM, so the height travels back over a new
RendererObserver::onTerrainCenterElevationChanged: RenderTerrain samples the
rendered surface under the centre, Renderer::Impl reports it once per change
rather than once per frame, and Map::Impl applies it as a centerAltitude jump,
ignoring sub-metre wobble from the cover shifting under a camera that just moved.
Raising the centre moves the orbit plane and not the centre's lng/lat, which is
why this settles instead of feeding back - measured, not assumed.

No ordering trap here: the sample is taken after RenderTerrain::update within the
same frame, unlike placement, which runs long before it.

getElevationForLatLng samples at the depth of the finest DEM tile loaded.
getElevation only matches a DEM tile that is the sample tile or an ancestor of
it, so a fixed sample zoom reads 0 wherever the DEM is loaded deeper - the
existing above-ground debug log has that latent bug with its hardcoded z14.

Exposed on Android beside the skirt option, with a "Centre on ground" menu toggle
and clamp_on|clamp_off|clamp_toggle adb commands, since pitching over a ridge and
toggling is how this gets judged.

TerrainCamera.* drive a synthesised flat 1000 m DEM and check the centre reaches
it, settles without drifting, and stays at sea level when the option is off.
Removing the observer call fails the first two and leaves the third passing.
995 unit tests; render suite 1316 passed / 3 failed, the same pre-existing
terrain/default, occlusion-debug and pitched-world.

Two traps recorded in TERRAIN.md: RendererObserver is marshalled to the map
thread by a per-signal forwarding class on Android, so a callback missing from it
compiles and silently never arrives; and Projection::project is overloaded on the
zoom argument's type, where the int32_t overload returns tile units and the
double one returns pixels. Taking the wrong one samples tile (0, 0) and reads 0 m
with no error - which is exactly what happened here first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Phase 4 notes attributed terrain/default's blank render to
"camera/exaggeration". Measured today, that is wrong, and the terrain-anchored
camera work does not touch it: setCenterClampedToGround was live for these runs
and changed nothing, and it could not, since the failure is a fully white frame -
the style's white background and nothing else - where the Phase 4 symptom is a
partial blackout of the near field under a visible terrain silhouette.

Bisecting the camera zoom puts a sharp cliff between 12.4, which renders, and
12.6, which is blank. Everything upstream of the drape is identical either side
of it: the mesh cover is correctly clamped to the DEM maxzoom with the same four
tiles, the terrain drawables all find their render targets, the tile ids are
clean, and the drape targets take the RENDER path rather than any of the skip or
defer paths. So the drape has content and is drawn, and the surface still comes
out white.

Also ruled out: missing fixtures (the raster is cached for z0-14 and the run
reports no misses, warnings or errors - it fails silently), draped tiles being
deeper than the target (capping the raster so its tiles sit at or above the
targets stays blank), and the z14 tiles specifically.

Two unconfirmed leads recorded for whoever continues: at zoom 13 only three
terrain drawables are created where the cover holds four, and the single
configuration that renders is also the only one with raster tiles both shallower
and deeper than the target.

No code change - the instrumentation used to establish this was temporary and is
not part of this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r all

Yesterday's entry claimed terrain/default was not the camera. That was wrong. The
reasoning treated the white frame as disqualifying because the Phase 4 symptom is
described as a black near field - but the near rays clear to *the background*,
whatever colour it is, and this style's background is white. Andrew called it
from manual testing: from inside terrain you see the background plus the skirts,
and the skirts are much less prominent here.

Measured at the test's own camera (zoom 13, pitch 60, exaggeration 2): the
terrain under the centre is 5487 m - a ~2744 m peak, doubled by the exaggeration
- against a camera altitude of 1243 m. The camera is 4244 m inside the mountain.
The margin first goes negative around zoom 11 and deepens as you zoom, because
the camera holds a fixed pixel distance from the centre while metres-per-pixel
shrinks; by zoom 13 the whole view is under the surface, which is why the frame
is uniformly background-white rather than partly terrain.

The centre-clamp addresses this but cannot fix the test. The run logs
centreAlt=0 on the only frame it draws: the height is reported through
onTerrainCenterElevationChanged after the frame, so a single-shot still render
never applies it. That also means an interactive map's first frame after terrain
loads is uncorrected and settles on the next one - worth knowing independently of
this test. Making terrain/default pass needs either the runner to draw another
frame once the camera settles, or the clamp applied before the first frame rather
than reported after it.

The drape measurements from yesterday stay, reframed: they describe a healthy
pipeline whose geometry simply is not visible from where the camera is. The one
genuinely odd thread left is that at zoom 13 three terrain drawables are created
where the cover holds four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gl-js

Wrote and measured option A (hold a still image back for a bounded few frames
while the terrain-clamped centre settles). It does what it was meant to: in
Static mode the still image is delivered as soon as the renderer reports Full,
ignoring repaint requests, so the clamp reported after the frame never applies -
and with the settle loop terrain/default renders real terrain instead of white.

It is not landed, because the terrain it renders is not gl-js's. Native's
expected.png for that test is byte-identical to gl-js's own, gl-js defaults
centerClampedToGround to true and the render harness does not override it, so
that baseline is already a clamped render. Ours frames the scene differently, and
enabling the clamp in still renders moves all six terrain tests including the
three that currently pass.

The elevation is not at fault: we report 5487 m, and decoding the z12
terrain-shading fixture directly at the test centre gives raw 2743 m, doubled by
exaggeration 2 to 5486 m. getElevationForLatLng is correct. What differs is how
the height is applied - centerAltitude and gl-js's transform.elevation do not put
the camera in the same place.

That walks back the 2026-09-06 claim that the decoupled elevation "already exists
and works". It exists and is stable - the no-drift probes stand - but stable is
not correct. Resolving it is the real content of part B, and A should wait for
it rather than trade a white frame for a differently wrong one at the cost of
three passing tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stream

Chased the mismatch between our clamped render and gl-js's reference. It is not
the camera.

With the settle patch temporarily in place the clamp converges in a single frame
to centreAlt 5487.5 m and cameraAlt 6733.0 m - the terrain height plus
cameraToCenterDistance * cos(60) = 1245 m, exactly what the arithmetic predicts -
and holds there, with zoom and mesh cover unchanged. gl-js's model is equivalent:
its mercator transform translates the world by -elevation after the z-scale by
pixelPerMeter, leaving the camera cameraToCenterDistance from the elevated
centre, which is what updateCameraState already does here. Both consume the same
number, since gl-js's getElevation applies exaggeration too, and that number was
independently checked against the raw DEM fixture.

So the elevation is right, the camera it produces is right, and the white frame
is gone - neither image has any white left. What remains is 93% of pixels
differing, with inverted LOD as the visible symptom: our near field draws
low-zoom number tiles where the reference draws high-zoom ones. That is tile
selection or drape content for a raised camera, downstream of the camera.

This retires the previous reading of part B. It is not "fix the camera
semantics"; it is the ordering change plus whatever picks tiles once the camera
rises. A still must not land alone - it would trade a white frame for a
differently wrong one and cost three passing tests.

No code change; the settle patch and instrumentation used here were temporary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Andrew's hint that gl-js requests terrain tiles a zoom level lower checks out,
and reading the LOD history turned up a second, larger divergence.

1. gl-js covers terrain one zoom shallower on purpose. TerrainTileManager sets
   deltaZoom = 1 and tileSize = source.tileSize * 2^deltaZoom, and coveringTiles
   uses zoom + log2(transform.tileSize / options.tileSize), so doubling the tile
   size subtracts a level. Native's computeMeshCover passes the source tile size
   straight through, so it meshes one level deeper in every case - measured on
   terrain/default, native computes overscaled 14 where gl-js gets 13. Masked
   there by the DEM's maxzoom 12, but not masked wherever maxzoom is not binding,
   and the most likely mechanism behind both the "native over-requests ancestors"
   note and the zoom-0 relief intensity bug.

   My first reading of this was wrong: I took a hardcoded tileSize 512 in
   getElevationForLngLat's fallback for the render cover. Andrew corrected it;
   the real mechanism is deltaZoom, which is derived from the source size rather
   than fixed.

2. gl-js replaced its LOD algorithm in #5719 and native still runs the older one
   by default. gl-js now picks a per-tile zoom from camera FOV, the tile's pitch
   relative to the camera and a tile-count budget, integrating cos^p across the
   visible pitch range. Native's default is the classic quadtree heuristic
   measured from the map centre, with no pitch or FOV term. Native's pitch-aware
   Distance mode is neither the default nor gl-js's formula.

   Measured: both native modes pick the same tiles for terrain/default's own
   camera, so the LOD mode is not what makes that test differ. The spread appears
   only once the centre is clamped, fanning across z8-z14.

Reading list for whoever changes this recorded in TERRAIN.md: #5719, #4779,
#4988, #7932, and the globe-only #5865, #4937, #8187.

No code change; the mode-switch and instrumentation used were temporary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
acalcutt and others added 15 commits September 7, 2026 15:43
John Carmack picked this up on #4533 after Andrew pointed him at TERRAIN.md, and
his reading of the LOD divergence is sharper than what was written here.

Verified his claim against gl-js: the Mercator cover uses the variable-zoom
function whenever terrain is set, not only at high pitch -
mercator_covering_tiles_details_provider.ts returns
`!!options.terrain || transform.pitch > clamp(78.5 - zfov/2, 0, 60)`. So with
terrain on, gl-js picks a per-tile zoom from FOV, tile pitch and a tile-count
budget on every frame at any pitch, where native's terrain cover uses one
constant desired zoom. That is deeper than "native has no pitch term" and it
applies to every terrain scene rather than only steep ones.

The function is now reusable: globe commit e3e2043 lifts it into
src/mln/util/tile_lod.hpp as TileZoomFunction, with elevationForTileCulling
alongside it, unit-tested against gl-js's own function to 1e-9. It is still
called only inside namespace globe, so wiring computeMeshCover to it touches no
globe code. Notably elevationForTileCulling takes a centerElevation, which is
exactly what RenderTerrain::getElevationForLatLng already returns.

That supersedes any plan to port #5719 from TypeScript ourselves, and it is the
next concrete step here. deltaZoom and the centre-clamped camera remain
terrain-side.

Also recorded: his merge dry run (170 shared files, 93 conflicts, 66 of them the
one vertex position line where terrain wraps apply_drape_transform and globe
wraps projectTile), the projectTileWithElevation / projectTileFor3D seam the
globe prelude already provides, and that converging the two cover loops onto one
details-provider is being left as a follow-up. His numbers, not independently
checked here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plan agreed with Andrew: neither branch takes a dependency on the other while
both PRs are large and contentious. Splitting tile_lod into its own PR was
considered and dropped - with the globe PR under heavy review, carving a piece
out invites the reading that it is being slipped through, and vendoring it here
would add someone else's in-flight code to a branch facing its own review. So do
the terrain-only work first and consume TileZoomFunction from main once either PR
lands. The plan is on record on #4533.

Starting on deltaZoom turned up that it is not the one-line change I called it.
The existing comment above terrainCoverTileSize records the trap: meshing
shallower than the DEM leaves the DEM tiles as descendants of the mesh tiles,
getElevation only ever walks up to an ancestor, so every lookup misses and the
mesh renders flat off the placeholder. Doubling the cover tile size on its own
walks straight into that.

gl-js avoids it by shifting the source too - TerrainTileManager sets
tileManager.tileSize to source.tileSize * 2, so the source itself covers at the
doubled size and mesh and DEM stay compatible. Native has no usedForTerrain
equivalent but has the same single injection point, so the shape is a flag on
RenderRasterDEMSource set from RenderTerrain::prepareSource, with getTileSize()
returning the doubled size and updateInternal using it rather than re-deriving,
so the pyramid and computeMeshCover cannot disagree.

Recorded two things to watch: it moves terrain tile selection everywhere, and a
DEM source shared with hillshade or color-relief has its selection changed too -
which is why gl-js warns against sharing a source between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…revert

TERRAIN.md should read as project documentation rather than a conversation, so
the personal names and third-person references are gone.

Implemented deltaZoom as designed - a usedForTerrain flag on
RenderRasterDEMSource, getTileSize() returning the doubled size, updateInternal
reading it back so the pyramid and computeMeshCover cannot land on different
zooms, set from prepareSource and cleared in deactivate. It builds and the
coupling holds, but the measurements did not justify keeping it, so it is
reverted and only the finding is committed.

Terrain, hillshade and color-relief render tests went from 58 pass / 3 fail to
56 pass / 5 fail. terrain/default stayed blank at exactly the same size. All 51
hillshade and 8 color-relief tests still passed. The two regressions are
skirts-auto and skirts-none, which went from drawing the draped geojson to
drawing nothing at all - a real defect rather than the known fixture gap, since
the run reports no cache misses. Their DEM source declares tileSize 512 and no
maxzoom, so doubling moves the cover from z9 to z8 at the test's zoom 9.64.

Also recorded what this did not settle: no render test puts terrain and
hillshade on one source, so the shared-source concern was never exercised, and
the zoom-0 relief intensity bug that motivated the change has no repro in this
suite - so the change could not be judged on the thing it was meant to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The style and baseline now come from maplibre-gl-js, and the test finally tests
what it is named for.

As written it used exaggeration 0.45. The relief was gentle enough that nothing
stood between the camera and the labels, so all 25 rendered and the test would
have passed with occlusion disabled. At exaggeration 3 ridges hide seventeen and
eight survive. text-allow-overlap and text-ignore-placement were already set, so
collision cannot hide any of them. Rendering the same camera with the terrain
flattened brings all 25 back, which is what proves the missing ones are occluded
rather than off screen.

The baseline is gl-js's own render of this style, so this is now a cross-engine
reference rather than a picture of our own output. It still fails here - native
draws no labels at all at exaggeration 3, the camera being inside the terrain
again - but it fails against something meaningful, and the suite is no worse off:
occlusion-debug was already failing, and the count is unchanged at 58 pass / 3
fail.

TERRAIN.md gains the wider picture behind this: which terrain tests are oracles
and which were measuring native against itself (four of six), the recipe for
generating gl-js references including the traps, and the check that makes them
credible - the same run regenerated all 84 existing gl-js terrain baselines
byte-identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parity with gl-js is what these tests are for, so they should not be graded
against pictures of our own output. All six now use gl-js's baselines.

pitched-world and fill-extrusion needed only the baseline, their styles already
matching. skirts-auto and skirts-none needed the style as well: ours had been
pointed at a different DEM source - jaxa/ at tileSize 512 where gl-js uses
terrain/ at 256 - so they were not the same test.

The suite now reads 0 pass / 6 fail where it read 3 pass / 3 fail. That is the
honest number rather than a regression: of the three that were green, two passed
against baselines containing no terrain at all and the third sat 60% from gl-js.

Adopting the skirts style also produced the clearest repro of the tile-cover
divergence we have. Both engines ship the same 16 terrain/ fixtures. gl-js
renders the test from four z10 tiles; native asks for those plus four more z10
and an ancestor chain at z6, z7, z8 and z9, none of which exist in either
engine's fixtures because gl-js never asks for them. So it fails on cache misses,
and the misses are the finding - the long-noted "native over-requests ancestors",
reduced to a minimal case on an identical style. Better to fix the cover against
it than to add the eight tiles, which would hide the divergence rather than close
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `jaxa/` tiles were cut from a JAXA AW3D30 build that no longer matches
what the source publishes: every one of the 26 differs from the current
build by mean 2-6 m (max 439 m at z1). That is harmless while the set is
frozen, but any tile added later would come from the newer build, and a
pyramid holding two builds seams wherever terrain falls back to an ancestor.

Refetch the whole set from one build so it stays internally consistent.

Tiles are now stored as the WebP the source serves rather than re-encoded
PNG - byte-identical to it, and ~30% smaller (5.3 MB for 33 tiles against
5.8 MB for 26). Native already reads WebP raster-DEM fixtures
(`{z}-{x}-{y}.ocean.webp`). Render tests read from `cache-style.db` rather
than the flat files, so the cache rows are replaced to match.

Also adds the seven z10 tiles around the Grand Canyon that the terrain
suite asks for, and corrects `color-relief/low-zoom`: it declared
`maxzoom: 15` where the data stops at 12, and left `encoding` implicit.
All eight color-relief tests pass unchanged on the new build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adopts the maplibre-gl-js styles and baselines from the matching change
there: the `terrain/` fixtures they used mix terrarium and terrain-RGB in
one folder, and with `encoding` left implicit both engines decoded the four
terrarium tiles they use as terrain-RGB - 840 km of elevation. Neither
engine's baseline had terrain in it.

On the JAXA set the tests render, and they render the same scene in both
engines. Two things follow.

The cache misses are gone. They were not evidence about the DEM: the
ancestor chain native descends (6-11-25 up through 9-95-201) is the cover
DFS running `zoomRange{0, getMaxZoom()}` because the source declared no
`maxzoom`, and it is unchanged by declaring the encoding correctly. That
divergence is still open; it just no longer stops these tests running.

What is left is one number. Native's scene sits 13 px lower than gl-js's:
translating by dy=+13 removes 86% of the difference on `skirts-none`,
leaving 1.37 against the 1.02 the two engines differ by with terrain
flattened entirely. Flattened, the best alignment is dx=dy=0 - so the
camera, the projection and the drape agree, and the whole remaining gap is
a scalar elevation. The centre clamp ordering and the DEM sampling zoom are
the two candidates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section claimed the cache misses were the finding and that adding the
missing tiles would hide the divergence. Both were wrong. The misses are
unchanged by the DEM's contents, so they were never evidence about it, and
the fixture itself was broken in a way the section did not notice: mixed
encodings in one folder, decoding as 840 km of terrain.

Records what the fixed fixture leaves - a 13 px scalar elevation offset, on
engines that otherwise agree to within their flattened-terrain floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…over

Cherry-picked from the globe work (e3e2043, "globe phase 12i") with the
`tile_cover.cpp` hunk left out: that hunk removes the functions from the
globe cover's anonymous namespace, and this branch has no globe cover to
remove them from. The files themselves are taken unchanged.

`TileZoomFunction` and `elevationForTileCulling` are GL JS
`createCalculateTileZoomFunction` and `getElevationForTileCulling`, which
GL JS applies to both projections: its Mercator cover switches to them
whenever terrain is present or the pitch passes 78.5 - fov/2 degrees. The
terrain cover needs exactly that, so this lands them on their own branch
rather than waiting for the globe PR, and it can be dropped for the real
thing once either lands on main.

Oracle: tile_lod.test.cpp, values from the GL JS functions on the same
inputs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Temporary: brings in the GL JS tile zoom function so the terrain cover can
be wired to it now rather than after the globe PR. Drop this merge for the
upstream version once either PR lands on main.
Five things had gone stale or unrecorded:

- The extraction is no longer "called only inside namespace globe" as far as
  this branch is concerned - it is cherry-picked onto feature/tile-lod and
  merged here. Records that the merge is temporary, must not reach a
  submitted branch, and is one revert to undo.
- The 2026-09-07 plan is superseded. It assumed splitting tile_lod out would
  look like slipping it past review; the globe PR's author would in fact
  prefer that over vendoring, and is holding it back only so reviewers are
  not asked which PR to read first.
- `centerElevation` matching `getElevationForLatLng` was asserted here
  without checking the units. Both sides exaggerate, so they compose - noted
  with the exact call, because getting it wrong would have been visible only
  at pitch near the horizon.
- The tile zoom function does not decide whether it becomes a third LOD mode
  or replaces TileLodMode::Distance on Mercator. That is ours to answer
  before wiring the cover.
- The bounded-source trap behind #4533's zoom-6 flicker applies to us: our
  DEM sources declare a maxzoom, and the function asks for tiles finer than
  the nominal zoom just below an integer zoom.

Also records that deltaZoom was measured and does not cause the ancestor
requests - identical miss lists with and without - and corrects an earlier
"8 vs 11" note that was a truncated pipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…DEM"

This reverts a2006c2. The fixture change it made was sound in itself -
the `terrain/` folder does mix terrarium and terrain-RGB, and the four tiles
these tests use decode as 840 km of elevation - but fixing it here was the
wrong place, and it cost more than it bought.

For a parity oracle a broken fixture is still a valid oracle. Both engines
read the same tiles under the same declared encoding, so if native
reproduced gl-js's render the two agree, and the picture being absurd does
not matter. What mattered was that native could not render it at all: the
eleven cache misses were the result, saying native selects tiles gl-js does
not. Supplying those tiles removed the signal rather than explaining it.

Two goals were being conflated. Grading native against gl-js needs
upstream's fixtures untouched. Making `skirts-*` actually test skirts needs
the encoding fixed, and that is a change to maplibre-gl-js, on its own
merits, in its own repo - the corresponding commit there stands. When it
lands upstream the corrected baseline arrives here as upstream behaviour
rather than as something invented on this branch.

All four terrain tests that exist upstream (`default`, `pitched-world`,
`skirts-auto`, `skirts-none`) now run upstream's exact style against
upstream's exact baseline, verified byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seven z10 tiles around the Grand Canyon were fetched so the skirts tests
could render on the JAXA set. That change is reverted, so nothing asks for
them.

Leaves the folder at the same 26 tiles it held before, now all cut from one
build, which is all the refetch was actually for.

Co-Authored-By: Claude Opus 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 documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code iOS metal node OpenGL Issues related to the OpenGL renderer backend Qt Vulkan WebGPU windows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants