Skip to content

Instantly share code, notes, and snippets.

@eddy-geek
Last active March 23, 2026 22:04
Show Gist options
  • Select an option

  • Save eddy-geek/f65e036656d54da4b8da1794f12aef4c to your computer and use it in GitHub Desktop.

Select an option

Save eddy-geek/f65e036656d54da4b8da1794f12aef4c to your computer and use it in GitHub Desktop.
terrain-analysis layer invisible in 3D when many layers intervene — @eslopemap/maplibre-gl@5.21.2 bug reproducer

Terrain Analysis 3D Bug — Root Cause & Fix

Summary

The terrain-analysis layer type (from @eslopemap/maplibre-gl@5.21.2) fails to render in 3D terrain mode when many non-DEM layers (GeoJSON tracks, circles, symbols, etc.) are positioned between the hillshade layer and the terrain-analysis layer in the MapLibre style layer stack.

Root cause: Layer position in the style stack. When terrain-analysis layers are appended to the end of the layer stack via map.addLayer() (the default behavior), and 20+ other layers exist above the hillshade, 3D terrain rendering silently drops the terrain-analysis output. This appears to be a bug in the fork's render-to-texture pipeline for 3D terrain.

Fix applied: Move terrain-analysis layers into the initial style definition, immediately after the hillshade layer, instead of adding them dynamically via addLayer() during map.on('load').

Reproducer: See debug-bisect-14.html — a minimal standalone page that demonstrates the bug.


Context

Architecture

The app (slopedothtml) uses @eslopemap/maplibre-gl@5.21.2, a fork of MapLibre GL JS that adds a built-in terrain-analysis layer type supporting slope, aspect, and elevation analysis directly from raster-dem tiles.

The app was refactored from a custom WebGL shader layer to use this built-in layer type. Two terrain-analysis layers are used:

  • analysis — for slope and aspect display
  • analysis-relief — for elevation/color-relief display

These coexist with a hillshade layer, contour lines (via mlcontour), multiple basemap sources, track/waypoint GeoJSON layers, and various UI overlays.

The bug

After the refactor, terrain-analysis rendered correctly in 2D (flat map) but was invisible in 3D terrain mode (map.setTerrain()). The 3D terrain mesh itself rendered fine — only the terrain-analysis color overlay was missing.


Investigation

Bisection methodology

A systematic bisection approach was used: starting from a minimal working demo (the published slope-builtin-published.html reference), incrementally adding app-specific features until the bug appeared.

Bisect results

Bisect What it tests Result
1 TileJSON source + 3D toggle PASS
2 Direct tiles, tileSize 512 PASS
3 Dynamic addLayer (no beforeId) PASS
4 Hillshade + terrain-analysis on same source PASS
5 Step ramp (exact app colors) PASS
6 Full combo: hillshade + addLayer + 3D toggle PASS
7 Zoom-interpolation expression for opacity PASS
8 App's exact init order: setTerrain → addLayer → setPaintProperty PASS
9 Bisect-8 + hide hillshade when 3D on PASS
10 Full replica: global-state, moveLayer, color-relief, contours PASS
11 Bisect-10 + mlcontour + antialias + maxTileCache + 30 dummy layers FAIL
12 Bisect-10 + mlcontour only PASS
13 Bisect-10 + antialias + maxTileCacheZoomLevels only PASS
14 Bisect-10 + 30 dummy GeoJSON layers between hillshade and terrain-analysis FAIL

Root cause isolation

Bisect 14 proves the bug:

  • Bisect 10 (full app replica without many extra layers) = PASS
  • Bisect 14 (same + 30 empty GeoJSON layers added before terrain-analysis) = FAIL

The terrain-analysis layers were added via map.addLayer() in the map.on('load') handler. Because the track system (initTracks) also registers a map.on('load') handler that adds ~30 track/waypoint/hover layers, these all end up between the hillshade and the terrain-analysis layers in the style stack.

In the app, the layer order was:

basemap(s) → dem-loader(hillshade) → [30+ track/waypoint/hover/debug layers] → analysis(terrain-analysis) → analysis-relief(terrain-analysis) → contours

The correct order (which works) is:

basemap(s) → dem-loader(hillshade) → analysis(terrain-analysis) → analysis-relief(terrain-analysis) → [track/waypoint layers] → contours

Fix Applied

Moved terrain-analysis layers from dynamic addLayer() calls in the load handler into the initial style definition, right after the hillshade layer:

// In the style.layers array:
{ id: 'dem-loader', type: 'hillshade', source: 'dem-hillshade', ... },
// Terrain analysis layers — must be right after dem-loader for 3D terrain compatibility
{ id: 'analysis', type: 'terrain-analysis', source: 'dem', ... },
{ id: 'analysis-relief', type: 'terrain-analysis', source: 'dem', ... }
// All other layers (tracks, contours, etc.) come after

The applyModeState() call in the load handler continues to set the correct visibility, opacity expressions, and blend mode after load.


Performance Considerations

Concern: DEM tiles loaded at startup

With the old approach (dynamic addLayer during load), the terrain-analysis layers were added lazily — the map had already started loading and the DEM source was already in use by the hillshade layer.

With the new approach (layers in the initial style), MapLibre will begin requesting DEM tiles immediately on map creation for the terrain-analysis layers, even before the load event fires. In practice, the DEM source (dem) was already being loaded for setTerrain() anyway, so the additional overhead is minimal — the tiles are shared via the source cache.

However, there are now two raster-dem sources pointing to the same tile URL:

  • dem — used by terrain-analysis and setTerrain()
  • dem-hillshade — used by the hillshade layer

This source duplication exists because the fork warns about sharing a DEM source between terrain and hillshade. It does mean two sets of tile requests for the same data, which doubles DEM tile bandwidth. This is a known trade-off from the source-splitting work and is not new with this fix.

Concern: initial paint properties in style

The style now references state.slopeOpacity, state.multiplyBlend, and ANALYSIS_COLOR at map construction time. These values are computed from persisted settings and URL hash before the map is created, so they are correct. The applyModeState() call in the load handler then adjusts them (e.g., sets zoom-interpolation expressions for slope+relief mode).


Alternative Fixes Considered

Alternative 1: addLayer() with beforeId

MapLibre's addLayer(layer, beforeId) accepts an optional second argument to insert a layer before a specific existing layer, rather than appending to the end.

The fix could have been:

map.addLayer({ id: 'analysis', ... }, 'dem-debug-grid-line');

This would place the terrain-analysis layer right after hillshade in the stack, even when added dynamically. However, this approach is fragile: it depends on knowing which layer comes after hillshade at the time of insertion, and that layer (dem-debug-grid-line) may not exist yet if the load handlers fire in a different order.

A more robust variant:

// Find the first layer after dem-loader and insert before it
const style = map.getStyle();
const demLoaderIdx = style.layers.findIndex(l => l.id === 'dem-loader');
const insertBefore = style.layers[demLoaderIdx + 1]?.id;
map.addLayer({ id: 'analysis', ... }, insertBefore);

This is viable but adds complexity and is still dependent on the load handler ordering being correct.

Verdict: Valid workaround, but placing layers in the initial style is simpler and guaranteed to produce the correct order.

Alternative 2: fix the upstream rendering bug

The real issue appears to be a bug in the fork's 3D terrain render-to-texture pipeline. In MapLibre's terrain rendering, layers are rendered to off-screen textures (one per tile) and then draped onto the 3D terrain mesh. The terrain-analysis layer type participates in this render-to-texture flow.

The bug is that when many non-terrain layers intervene in the layer stack between the DEM-sourced hillshade and the terrain-analysis layer, the render-to-texture pass appears to either:

  • Skip the terrain-analysis layer's contribution, or
  • Run out of texture slots / render passes and silently drop it, or
  • Incorrectly classify it as a non-draping layer due to its stack position

This could be investigated and fixed in the fork (@eslopemap/maplibre-gl). The relevant code paths are:

  • render_to_texture.ts — decides which layers to render to terrain textures
  • terrain.ts — manages the render-to-texture framebuffers
  • painter.ts — the main render loop that iterates through layers

A proper upstream fix would ensure that terrain-analysis layers are always included in the terrain render pass regardless of their position in the layer stack. This would remove the fragile ordering constraint.

Verdict: This is the correct long-term fix. The current workaround (layers in initial style) is adequate for now but the ordering constraint should be documented and eventually removed via a fork fix.

Alternative 3: single DEM source (remove source splitting)

The source split (dem vs dem-hillshade) was introduced to suppress fork warnings about sharing a DEM source between terrain and hillshade. If those warnings are benign (the rendering works fine despite them), the split could be reverted to reduce tile bandwidth.

Verdict: To be evaluated. The warnings may indicate real rendering quality issues, or they may be overly cautious. Reverting would halve DEM tile requests.


Reproducer: debug-bisect-14.html

This file is a minimal standalone page that demonstrates the bug. It:

  1. Creates a map with a basemap, a hillshade layer, and a raster-dem source
  2. On load, enables 3D terrain
  3. Adds 30 empty GeoJSON layers (simulating the app's track/waypoint layers)
  4. Adds terrain-analysis layers after the 30 dummy layers
  5. Result: terrain-analysis is invisible in 3D mode

To verify: remove the loop that adds 30 dummy layers → terrain-analysis renders correctly.

The file uses @eslopemap/maplibre-gl@5.21.2 from unpkg and requires no build step.


Recommended next steps

  1. Evaluate DEM source deduplication — test whether reverting to a single dem source (shared between hillshade and terrain-analysis) causes real rendering issues or just warnings.
  2. File upstream bug — report the layer-ordering issue in the @eslopemap/maplibre-gl fork with debug-bisect-14.html as the reproducer. The 3D render-to-texture pipeline should handle terrain-analysis layers regardless of their position in the stack.
<!DOCTYPE html>
<html><head>
<meta charset="utf-8" />
<title>terrain-analysis invisible in 3D when many layers intervene — reproducer</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://unpkg.com/@eslopemap/maplibre-gl@5.21.2/dist/maplibre-gl.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@eslopemap/maplibre-gl@5.21.2/dist/maplibre-gl.css" />
<style>html, body, #map { margin: 0; width: 100%; height: 100%; }
#info { position: absolute; top: 10px; left: 10px; z-index: 10; background: #fff; padding: 8px 12px; border-radius: 6px; font: 13px/1.5 system-ui; box-shadow: 0 1px 4px rgba(0,0,0,.3); max-width: 520px; }
code { background: #f0f0f0; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="info">
<b>Bug reproducer: <code>terrain-analysis</code> invisible in 3D when many layers intervene</b>
<p>
<b>Library:</b> <code>@eslopemap/maplibre-gl@5.21.2</code> (fork with built-in <code>terrain-analysis</code> layer type)<br>
<b>Bug:</b> When 20+ non-DEM layers (GeoJSON line/circle) are added between the
<code>hillshade</code> layer and the <code>terrain-analysis</code> layer in the style stack,
the terrain-analysis overlay becomes invisible in 3D terrain mode.<br>
<b>Expected:</b> Layer stack position should not affect terrain-analysis rendering in 3D.<br>
<b>Workaround:</b> Place terrain-analysis layers immediately after the hillshade in the initial style.
</p>
<label><input type="checkbox" id="toggle3d" checked> 3D terrain (on = bug visible)</label><br>
<label><input type="checkbox" id="toggleLayers" checked> Add 30 dummy layers before terrain-analysis (on = bug trigger)</label>
<p id="status" style="margin:4px 0;font-size:12px;color:#666;"></p>
</div>
<script>
// ── Setup ──
// This reproducer creates:
// 1. A raster basemap + hillshade layer
// 2. Optionally 30 empty GeoJSON layers (simulating app track/marker layers)
// 3. Two terrain-analysis layers (slope + elevation) added AFTER the dummy layers
// 4. 3D terrain enabled via setTerrain()
//
// With the dummy layers present, terrain-analysis is invisible in 3D.
// Without them (uncheck the checkbox and reload), it renders correctly.
// In 2D mode it always renders regardless of layer count.
const DUMMY_LAYER_COUNT = 30;
let dummyLayersAdded = false;
const map = new maplibregl.Map({
container: 'map',
center: [6.8652, 45.8326], // Mont Blanc area
zoom: 12,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; OpenStreetMap contributors'
},
dem: {
type: 'raster-dem',
tiles: ['https://tiles.mapterhorn.com/{z}/{x}/{y}.webp'],
tileSize: 512,
maxzoom: 14,
encoding: 'terrarium'
}
},
layers: [
{
id: 'basemap',
type: 'raster',
source: 'osm'
},
{
id: 'dem-loader',
type: 'hillshade',
source: 'dem',
paint: { 'hillshade-exaggeration': 0.10 }
}
]
}
});
function updateStatus() {
const layers = map.getStyle().layers;
const analysisIdx = layers.findIndex(l => l.id === 'analysis');
const hillshadeIdx = layers.findIndex(l => l.id === 'dem-loader');
const gap = analysisIdx > 0 ? analysisIdx - hillshadeIdx - 1 : 0;
const terrain = map.getTerrain();
document.getElementById('status').textContent =
`Layers: ${layers.length} | Gap between hillshade and analysis: ${gap} | 3D: ${terrain ? 'ON' : 'OFF'}`;
}
map.on('load', () => {
const addDummy = document.getElementById('toggleLayers').checked;
// Step 1: enable 3D terrain
if (document.getElementById('toggle3d').checked) {
map.setLayoutProperty('dem-loader', 'visibility', 'none');
map.setTerrain({ source: 'dem', exaggeration: 1.4 });
}
// Step 2: optionally add dummy layers (this is the bug trigger)
if (addDummy) {
for (let i = 0; i < DUMMY_LAYER_COUNT; i++) {
const srcId = `dummy-src-${i}`;
const layerId = `dummy-layer-${i}`;
map.addSource(srcId, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: layerId,
type: i % 2 === 0 ? 'line' : 'circle',
source: srcId,
paint: i % 2 === 0
? { 'line-color': '#e040fb', 'line-width': 3 }
: { 'circle-radius': 4, 'circle-color': '#e040fb' }
});
}
dummyLayersAdded = true;
}
// Step 3: add terrain-analysis layers (appended at end of stack)
map.addLayer({
id: 'analysis',
type: 'terrain-analysis',
source: 'dem',
paint: {
'terrain-analysis-attribute': 'slope',
'terrain-analysis-opacity': 0.45,
'terrain-analysis-color': [
'step', ['slope'],
'#ffffff', 20, '#c0ffff', 24, '#57ffff', 28, '#00d3db',
31, '#fffa32', 34, '#ffc256', 37, '#fd7100', 40, '#ff0000',
43, '#e958ff', 47, '#a650ff', 52, '#5e1eff', 57, '#0000ff', 65, '#aaaaaa'
],
'blend-mode': 'multiply'
}
});
map.addLayer({
id: 'analysis-relief',
type: 'terrain-analysis',
source: 'dem',
paint: {
'terrain-analysis-attribute': 'elevation',
'terrain-analysis-opacity': 0.45,
'terrain-analysis-color': [
'interpolate', ['linear'], ['elevation'],
0, '#A9D4E8', 500, '#C8B75F', 1500, '#705B43', 3000, '#D9CCBF', 5000, '#F6F2EF'
],
'blend-mode': 'multiply'
}
});
// For slope+relief mode: crossfade via zoom expression
map.setPaintProperty('analysis', 'terrain-analysis-opacity',
['interpolate', ['linear'], ['zoom'], 10, 0, 11, 0.45]);
map.setPaintProperty('analysis-relief', 'terrain-analysis-opacity',
['interpolate', ['linear'], ['zoom'], 10, 0.45, 11, 0]);
updateStatus();
});
// ── Controls ──
document.getElementById('toggle3d').addEventListener('change', (e) => {
if (e.target.checked) {
map.setLayoutProperty('dem-loader', 'visibility', 'none');
map.setTerrain({ source: 'dem', exaggeration: 1.4 });
} else {
map.setTerrain(null);
map.setLayoutProperty('dem-loader', 'visibility', 'visible');
}
updateStatus();
});
document.getElementById('toggleLayers').addEventListener('change', () => {
// Layer count change requires reload to take effect
alert('Reload the page for this change to take effect.');
});
map.on('error', e => console.error('Map error:', e.error ? e.error.message : e));
</script>
</body></html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment