Terrain 3D - #4190
Draft
NathanMOlson wants to merge 95 commits into
Draft
Conversation
…endered to texutre. Once they are, they won't be projected right, and once that's fixed, performance will likely be terrible.
…cpp seems like a dead end/bad idea
…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.
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>
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>
for more information, see https://pre-commit.ci
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Working in
mbgl-render!#ifdef TERRAIN3D.getTileMatrix()will need more information from somewhere.coveringTiles(): use terrain in calculation of covering tiles.mbgl-render, which usesStaticmap mode. Performance will likely be bad inContinuousmode, and there may be memory leaks.