Skip to main content

The depth model

Scope: how the ground, the content on it, and the vector elements above it relate in depth. This is the single most failure-prone part of the renderer — rounds 45 to 56 of the terrain work were almost entirely about it — so it is documented as a model plus a catalogue of what breaks.

It is tangram's model, ported whole. Read res/scenes/terrain-3d.yaml in tangram-ng, not just their shaders: polygon.vs sets depth_shift = 0.0 "to allow blocks to modify", and the terrain scene is the block that modifies it.

The model

// tangram core/shaders/polygon.vs
gl_Position.z += (proxy - layer) * (TANGRAM_DEPTH_DELTA * gl_Position.w + depth_shift);

// tangram res/scenes/terrain-3d.yaml
depth_shift = -0.02 * u_proj[2][3]; // [2][3] is -1 with glm::perspective => a FLAT 0.02
#ifdef TANGRAM_RASTER_STYLE
proxy *= 48.0; // "prevent terrain poking through the level above"
#endif

with TANGRAM_DEPTH_DELTA = 2⁻¹⁹ on a 24-bit depth buffer, layer the style layer's order, and — core/src/style/style.cpp — opaque and translucent geometry drawing with depth test and depth write.

Ours, matching it:

pieceours
the grounddrawn at true depth, writes depth, pushed back 48 units per proxy level
content (fills, lines, buildings)writes depth, carries (proxy − layer) with the layer ordinal
depth_shiftflat, not scaled by the projection
ordinalsdense across the whole stack, one range per layer (see below)
near planecamera height / 50, as tangram (04-terrain.md)

Why the two terms differ

TANGRAM_DEPTH_DELTA · w is a constant-NDC bias: its eye-space tolerance grows as distance²/near, which is how a bias of a few units becomes hundreds of metres at range. That is the mechanism behind every see-through this project has had.

depth_shift is a constant clip-space offset: its NDC effect is 0.02/w — strong near the camera, where an un-subdivided fill chords furthest below the surface, and vanishing at range, where it would leak. That is why tangram can afford it and why it is the term that pays for not subdividing content.

Ordinals, and the budget

Tangram numbers its style layers in one global list (res/osm-bright.yaml uses 1..93) and spends 93 × 0.02 ≈ 1.86 clip units across the scene. Our stack is several renderers — a composite layer's children included — so the ordinals are handed out in draw order, one dense range per layer: MapRenderer::drawLayers gives each TileLayer an ordinal base and advances it by that layer's style layer count.

The quantity that matters is constant × span, i.e. the total depth budget the stack gets, not the constant. A style with nine style layers at their 0.02 would spend a tenth of their budget. So the shift is derived from the budget instead: TERRAIN_TANGRAM_DEPTH_BUDGET / span, which gives back exactly 0.02 for a 93-layer style.

Measured on device (45.244172/5.760595 z13.2 t26, 9 ordinals): 0.2 is the largest total with no see-through; 0.3 opens pale wedges through ridges, 0.5 sees straight through Saint-Eynard. Their budget is the leak threshold. Do not raise it.

Numbering starts at 1, not 0: the ground is a numbered draw at the bottom of the same list (tangram draws the terrain raster at order: global.earth_order), so content at ordinal 0 would share the ground's term exactly and have no clearance over ground it chords across.

Proxy depth

Tangram, core/src/tile/tileManager.cpp:

setProxyDepth(m_proxyCounter > 0 ? std::max(maxVisS - tileId.s, 1) : 0);

The m_proxyCounter > 0 is a guard, and it is the whole point: proxy depth applies only to a tile drawn in place of one that has no data. A legitimately coarse tile in a mixed-LOD cover is a live tile and takes zero, however far away it is. Giving it a depth pushes most of a tilted view's far field back by clip units and takes the hillshade paint (which carries the same push) behind the ground it shades — far hillshade missing, blinking as the cover's deepest level moves.

The measure is in levels (how many levels coarser than the deepest level on screen), not a flat 1, and the raster/ground multiplier is 48.

Four ways to get this wrong, all of them tried

attemptwhat it produced
masks dropped, content not writing depththe previous zoom's roads painting through every gap in the new tile's content, blinking as the blend ran
content writing depth, no per-style-layer ordinalfills shredded into stripes, washed road casings
no subdivision and no depth_shiftall content sunk into the terrain — contours and roads swallowed
ground stand-in walking to an ancestor, content notroads snapping to straight lines over ground that IS displaced

Two more from choosing constants instead of copying them: depth_shift scaled by |m22| (did nothing), then by |m23| (dragged a landcover fill in front of the mountain); and an ordinal stride of 32 per renderer, which reached the leak range with five layers.

Extrusions are in the numbering too

renderGeometry3D sets _terrainDrawLayerOffset from the same dense numbering renderGeometry2D uses. Left out of it — which it was — an extrusion draws at ordinal 0, the ground's own level, while every 2D style layer is pulled forward by its own ordinal, so any decal with a real ordinal wins the depth test against a building of any height. Two symptoms, one cause:

  • roads over buildings, but only with the drape off — with the drape on they are baked into the ground and never drawn live, so nothing is left to win;
  • billboard-line labels over buildings, drape or not — clipped text is POINT geometry and is not draped, so it is always drawn live.

Both were chased first to the decals' own forward pull, and neither is it: setting DEFAULT_LINE_CLEARANCE_METERS to 0 changes nothing, and so does removing the decal glPolygonOffset(-2, -8). The ordinal is the whole of it. Tangram has no such gap because a building is a style layer with an order like any other.

The ordinal applies only under the shared ground, as in the 2D path; the other paths take their clearance from pushing the surface back instead.

A flat quad over a curved surface is a DECAL

Lines and points are both chains of flat quads laid over a height field, so neither is coincident with it: between its corners a quad chords over relief the ground follows, and under a depth-writing ground it is cut away wherever it sags. Both get the same treatment in renderGeometry2D — a slope-scaled glPolygonOffset(-2, -8), which tracks the sag because the sag scales with the primitive's own depth slope, plus _terrainLineClearance, a clearance in metres (the sag does not care how far away the quad is, which neither the clip-constant ordinal pull nor an NDC-constant depth bias can express without leaking through ridges at range).

POINT was left out of that for a while and only lines got it, which is the wrong way round: a glyph quad of text-clip text is tens of metres wide against a line's few. Whole letters disappeared over 3D terrain — and only where the ground is draped, because a draped tile draws its surface at TRUE depth and writes it, while an undraped one pushes its pre-pass surface back and leaves clearance by accident. Same drop at drape resolution 512 and 2048, absent in 2D: not the raster, the depth.

The vertex shaders match: both sample the terrain at the extruded corner (applyTerrain(pos + delta)), never at the anchor. A quad placed at the anchor's height and then offset sideways is a flat plate whose uphill half is under the ground.

Rules to keep

  1. Never push the reference surface back. Slack belongs on the content, forward and test-only. A backward push only works for opaque depth-writing content; translucent content double-blends and non-writing geometry leaks in the band.
  2. Content that writes depth must discard transparent fragments (alpha < 0.004), or the transparent corners of sprite and dash quads pollute the depth buffer — white boxes and dark roads.
  3. Keep the ordinal out of the clip-space slack. Ordinal-scaled clip slack was itself a leak; ordinal-scaled NDC pull washed out road casings and fills.
  4. Sort near to far inside a style layer (03-vt-renderer.md), because content writes depth.
  5. An emulator pass is not a device pass for anything in this file. Every regression here was found on a device after the emulator was clean.

Known open artifact

Landcover fills break into triangular shards over the hillshade while the LOD is changing, and heal when tiles settle. Measured not to be the cause: fill subdivision (1 vs 2 cells identical) and the depth-shift magnitude (0.02 / budget / 0.5 identical at a settled camera). The remaining suspect is the proxy path — the ground stand-in and the content of the outgoing zoom disagreeing during the swap. Not yet reproduced on an emulator.