Skip to main content

3D models: GLB instances from a tile layer

Scope: the model symbolizer (issue #131) from file to pixel. What a style writes is on the feature page, 3D models.

Pipeline​

StageWhereWhat happens
loadvt/GLBParser.cpp, VTModelLoaderGLB → vt::Model: triangles of the default scene, node transforms flattened, glTF Y-up turned Z-up (x, -z, y), primitives merged per material, linear colours turned into display values. Cached per file in vt::ModelManager, failures too.
decodemapnikvt/ModelSymbolizer.cppone instance per point; properties reading [mapnik::seed] (random() without a seed) are evaluated per point with the seed set to the point's world position quantised to 2^-26 of the world
buildTileLayerBuilder::createModelProcessor, placeModelsevery point within MODEL_REACH (32 m) of the tile becomes a candidate (isModelPointInReach skips the rest before any expression is evaluated); a style whose symbolizers are all models reads the source tile UNCLIPPED (Symbolizer::needsUnclippedFeatures) so the candidates across the tile edge are there. buildTileLayer then keeps a candidate inside the half-open tile unless ModelFootprints finds a higher-ranked crown of another point crossing it (rank: splitmix64 of the point's world seed). The test is local, with no cascade, so every overzoomed tile decides the same; mapbox's density grid (model_bucket.ts lookupDim) thins after it. Instances are grouped by ModelStyle into TileModelInstances
drawGLTileRenderer::renderTileModelsone glDrawElementsInstanced per mesh, inside the 3D pass of renderGeometry3D
shadowrenderShadowCastersthe same vertex shader with shadowCasterFsh, walked by forEachVisibleRenderLayer like the extrusions; skipped for a rule with model-cast-shadows: false and for every model while Options.modelShadowsEnabled is off (a toggle redraws every shadow page, _shadowMapModelShadows)

The instance​

32 bytes, divisor 1: vertex-frame x, y (tile units, y up) and the instance index; heading, uniform scale, z offset (metres) and spin phase; tint RGB + tint amount as RGBA8. The model vertex is 36 bytes: position and normal (floats, metres), uv, RGBA8 colour.

modelVsh spins the vertex about uModelSpin.xyz (Rodrigues), turns it by the heading, scales it, then adds it to the anchor draped in the vertex shader with the shared applyTerrain(), scaled from metres by uHeightScale (the tile-centre latitude, as DefaultVertexTransformer::calculateHeight). mapbox bakes the DEM height of each instance on the CPU and re-bakes on an exaggeration change; draping on the GPU follows exaggeration and DEM arrival for free, which is why we diverge.

An overzoomed source tile is drawn once per target tile. The anchor is mapped into the target tile with uTileMatrix; an instance outside it collapses to a point outside the clip volume, so each instance is drawn exactly once without a per-fragment discard.

Alpha​

glTF's three modes travel in uAlphaCutoff: below 0 OPAQUE (alpha ignored), 0-1 MASK (discard below, solid above), above 1 BLEND. modelShadowCasterFsh applies the same MASK test, so a cut-out leaf casts its shape. A translucent draw turns blending on even in the overlay, where it is off and the model would otherwise replace the wall behind it.

Lighting, shadow, fog​

applyLighting3D is the extrusions' (TileRenderer::LIGHTING_SHADER_3D) with wallT = sideVertex = 1, so a model and the wall beside it agree under the same sun. u_emissive is forced to 0 (lit by the scene, mapbox's model-emissive-strength default). Receiving uses the extrusion receiver flags (SHADOW_RECEIVER_3D, normal offset, and SHADOW_SINGLE_TAP only at softness 0). Back faces flip their normal in the fragment stage and culling is off for the draw: generated foliage is single quads.

Spin and redraw​

_modelTime (double) advances in startFrame(dt) and is wrapped per draw to one turn of that style's spin, so the angle never jumps. A draw of a spinning style sets _modelsAnimating, which the next startFrame returns as a refresh — the map keeps drawing only while a spinning model was drawn in the last frame. setModelAnimationsEnabled(false) stops the clock and the refresh.

Picking and highlight​

findGeometryIntersections adds findTileModelIntersections to its 3D pass: per instance whose anchor the target tile owns (the same test as modelVsh), the anchor is lifted onto the terrain with the labels' elevation provider and the click ray is taken into model space by intersectModelInstance (TileModels.h, host-tested). The slab test is written out there: cglib::intersect_bbox lets a ray parallel to a slab through from outside it, which is every straight-down click on a flat map. A spinning style is tested against modelPickBounds, the cube around everything its spin sweeps.

The hit reports the tile-local id the decoder re-reads the feature by. The highlight is keyed the other way, by the SOURCE feature id and point index the app got from the click, which is why TileModelInstances keeps both; renderTileModels turns it into the instance index the vertex shader compares (uModelHighlight), so a highlight is a uniform, not a buffer rewrite.

Caches​

_compiledModelMap (per Model) and _compiledModelInstancesMap (per TileModelInstances, with one VAO per program and mesh — see CompiledGeometry for why programs never share a VAO) are keyed by weak_ptr and swept in endFrame with the other compiled resources. Textures go through buildCompiledBitmap.

A spinning model's shadow​

The shadow map is re-rastered only when the camera, the caster set or a fade moves it, so a turning rotor's shadow stepped about twice a second while the camera stood still. A page whose caster tiles hold a spinning model that casts is redrawn every frame (mapbox redraws its shadow map every frame), but only where the rotors cast: spinningShadowCasterBounds returns the light clip-space rectangle of every spinning caster's swept box (its shadow-map texels are its own light-space footprint), the page is cleared and drawn under a scissor on that rectangle (narrowCascadeScissor), and only the caster tiles whose light-space column crosses it are sent (filterShadowCasterTiles, the same column calculateShadowViewProj boxes a tile in). Off with modelAnimationsEnabled or modelShadowsEnabled false, or past model-shadow-max-distance.

Crosscall HLTE556N, the 3d-models example, camera still at the turbines (lon 5.7262, lat 45.1934, z17.6, tilt 40), 3 interleaved runs each:

Buildfpsshadow pass GPU / frame
no spin redraw20.88.8 ms
whole page every frame (#409)15.956.6 ms
scissor only17.650.7 ms
scissor + tile filter18.625.4 ms

The scissor alone barely helps: the pass is geometry-bound, every caster tile's terrain grid and buildings. What stays is the tiles in the rotors' light column (200 m building headroom, a low sun). A separate small shadow texture for spinning casters alone would remove it, at the price of a second lookup in every receiver while one is in range. A pan costs what it did: it redraws the pages anyway.

Distance cutoff​

model-max-distance / model-shadow-max-distance (metres; modelCutoffMetres in TileModels.h, a shadow never past its model) cut on two levels. The CPU skips a target tile whose ground distance to the camera (modelTileGroundDistance, a lower bound for every anchor in it) is past the cutoff, in the draw, the caster pass, the pick and hasSpinningShadowCasters. modelVsh gets the camera in the source tile's vertex frame and the cutoff in vertex units (uModelCutoff) and scales the instance, its z offset included, to the anchor over the last 15%, staggered per point by a hash of its position: a tower and its rotor share it. Mapbox's model-cutoff-fade-range is camera-relative and off on terrain, so it was not ported; the shrink is its building cutoff's (fill-extrusion-cutoff-fade-range).

Not done, not verified​

  • Globe mode skips models (renderTileModels returns on a spherical transformer).
  • No transparency sort; BLEND materials blend in draw order.
  • The density grid assumes a z14 source (OpenMapTiles): the builder does not see the source tile's zoom, so a z16 source is thinned to 256 cells at z15 where mapbox would keep 100.
  • A highlight scans the instance list of every drawn tile for its feature id, linearly.
  • Verified: host tests (loader, hostile files, random(), instance building, the pick math), NDK syntax gate, glslangValidator on every program variant, and on the Android emulator (2026-10-11, Grenoble, rhone-alpes.pmtiles, the demo's --es models): the Massif trees drawn and occluded by the buildings, a tap picking one tree of a packed MultiPoint and tinting it, a rotor turning between frames; with --es terrainLight true --es appSun true --es sunHour 13 --es shadow 2, the trees casting shadows on the ground, and the same trees kept from z18 to z19. Not yet seen: terrain draping on relief, a physical GPU, iOS, the web build, the spin's battery cost.