3d-buildings-and-meshes · git:20261026.79a684e · 2026-10-26 · sha256 8236d6916d54955e

3d-buildings-and-meshes git:20261026.79a684eA

Immutable. This exact content never changes and is served at /api/v1/blob/8236d6916d54955e.

---
name: 3d-buildings-and-meshes
description: How 20 map engines build, store, order, light and cull extruded buildings and imported 3D meshes (OSM/PLATEAU buildings, glTF models, TINs), and what they reverted. Load it when a map-from-scratch request mentions 3D buildings, extrusion, height, tilt/pitch, oblique or 2.5D views, shadows, glTF/OBJ models or mesh export, before writing the bake step's wall/mesh layout or the renderer's depth and lighting.
---

# 3D buildings and meshes: bake the prisms, keep the depth, light in the local frame

Extruding a footprint looks like one loop over ring edges, but every engine
that added it then had to deal with these problems, roughly in this order:
precision of heights and coordinates far from the origin, wall winding,
triangulation that fails on dirty rings, draw order against other layers and
labels, a tile coverage that drops tall buildings whose base is off-screen,
memory for millions of wall vertices, and lighting that changes with longitude
or camera shear. OsmAnd's "simple 3D buildings" alone took follow-ups for
transparency, premultiplied alpha, symbol order, polygon colour and a zoom
cut [65][66][67][64]. The engines solved most of these at run time. A
renderer that serves one request can solve most of them in `bake.ts`.

Numbers in brackets refer to the Sources list. Per-engine detail, with the
same numbering, is in [references/engines.md](references/engines.md).
Sibling skills go deeper where this one only touches: depth and layer order
in [../draw-order-and-depth](../draw-order-and-depth/SKILL.md), local origins
and quantisation in [../float-precision](../float-precision/SKILL.md), dirty
rings and earcut in [../polygon-triangulation](../polygon-triangulation/SKILL.md),
PLATEAU-style 3D Tiles in [../3d-tiles-and-hlod](../3d-tiles-and-hlod/SKILL.md).

## Decisions the engines made

### Mesh layout and memory

- **One buffer per tile or per material, not per building.** OsmAnd merged a
  tile's buildings into one vertex and uint16 index buffer, struct of arrays,
  "draw call optimization", with no numbers [68]. osgEarth built one geometry
  per state set directly instead of merging per-feature drawables, and tile
  build went from 8 s to under 1 s; picking moved to index-range tags [60].
  Cost: uint16 indices cap a tile at 65,535 vertices, and changing one building
  means rebuilding the buffer [68].
- **Instanced walls (maplibre-native, Metal and Vulkan only).** One instance per
  wall edge: int16 position, a fraction field with a packed discard bit, and
  edgeDistance. The non-instanced path stores a 2D normal scaled by 2^14 and a
  top/bottom bit on 4 vertices per edge; the instanced path drops the normal
  and derives it in the shader [3][4][5]. Cost: a backend-specific path and
  separate instance segments; OpenGL still uses per-vertex walls [5].
- **Collapse invalid vertices in the vertex stage.** deck.gl's side walls set
  `gl_Position = vec4(0.)` for invalid side vertices instead of passing an
  `isValid` varying to a fragment `discard`; the commit is titled only as a
  side-polygon shader performance improvement [12].
- **A GPU-ready quantised format, baked offline (ortho-earth, PLATEAU).**
  Positions u16 over the batch bounding box, delta zigzag varints, normals
  i8x3, implicit or delta-coded indices. Minato's 32 tiles: 15.25 MB of typed
  arrays to 2.95 MB gzip, 1.4 cm position error, 28 ms unpack; raw b3dm was
  341 MB per ward, 60% of it batch-table JSON that is never drawn [16]. Welding
  vertices whose quantised position and normal are equal (Int32Array
  open-address hash; a JS `Map` of millions of entries costs hundreds of MB of
  heap; skipped when it saves under 15%) took Komae batch 0 from 3.12M to
  1.62M vertices and Minato to 1.94 MB gzip; flat normals keep corners as
  separate vertices, so the image is unchanged [17].
- **int8 normals.** ortho-earth went from float32x3 (12 B) to int8 xyz + pad
  (4 B) as a normalised attribute, about 2/3 less storage, transfer and upload,
  with no shader change because the fragment shader normalises [18]. iTowns
  stopped recomputing normals that glTF already carries, because recomputing
  overwrote KHR_mesh_quantization int8 normals [52].
- **A hard budget on generated geometry.** OsmAnd gives up on a mesh that would
  exceed 4,194,304 vertices (32 B each, about 128 MB) [62].

### Heights and coordinate precision

- **Heights as integers in a chosen unit.** Tangram multiplies heights by 16
  (1/16 m) before packing and divides in the vertex shader [36]. maptalks
  moved from decimetres to centimetres when its tile unit changed [41]. The
  unit trades resolution against the attribute's range [36][41].
- **Precision qualifiers fail on some GPUs only.** MapLibre GL JS moved
  extrusion base and height from lowp to highp in the vertex shader after
  heights over 65,536 m failed on some devices [2].
- **Vertices relative to a local origin.** iTowns stores feature coordinates in
  a local East-North-Up frame at parse time: no jitter, one fewer transform
  [48]. openglobus moved from a high/low split for everything to per-segment
  relative-to-centre vertices, so every consumer (bounds, hit tests) must add
  the centre back [42][43][44][45].

### Triangulation and winding

- **Normalise winding as an explicit step.** Marble's wall culling assumes
  clockwise rings; OSM guarantees no order, and the old clipper had normalised
  it as a side effect. When the clipper switched to Clipper2's rectangular mode
  to keep vertex order stable, walls rendered inside out [35][34].
- **Dirty rings break the triangulator.** QGIS's poly2tri rejects repeated
  points within epsilon (often unprojected lat/lon), so QGIS simplifies, then
  skips the polygon; that path later lost vertical walls and needed a fix
  [23][24][25].
- **earcut gave correct shapes; frame rate was a feature-count problem.** In
  iTowns' abandoned WFS PR, CVML left holes; with earcut more buildings were
  shown and it "works fine", but the frame rate was very low because too many buildings were loaded even when
  the camera was far away [49].
- **Keep triangles that already exist.** QGIS added PolyhedralSurface and TIN
  types (GDAL RFC 64) so they no longer pass through multipolygons [21][22].

### Walls, tiles and zoom

- **Drop walls on tile seams.** harp.gl turned tile-boundary walls off by
  default because they caused artefacts with shadows [40].
- **Tile coverage must include tall geometry.** tangram-es rasterises an extra
  triangle from the point under the view centre to the two near corners, so
  tiles whose base is off-screen but whose buildings are visible stay loaded
  [37].
- **Gate buildings to a zoom band.** iTowns requested WFS buildings at one
  suitable zoom and the drag lag disappeared [51]. OsmAnd returns empty
  building tiles below z16 instead of special-casing the layer in its loader
  [64].
- **Height-ordered indices give LOD for free.** ortho-earth sorts each batch's
  triangles by building height (descending) and stores the draw count for each
  height threshold; the renderer picks the threshold from metres per pixel
  and draws a prefix, dropping sub-pixel buildings with no CPU or shader work
  [18].

### Depth and draw order

- **Use a depth buffer for extrusions.** RustGIS first draped CPU-drawn bands
  (35 fps mid-drag vs about 3), then moved to a GPU view-projection (26 ms vs
  316 ms per frame), then added depth for extrusions because far-to-near
  painter's order fails where features interlock; with 4x MSAA and
  Depth24PlusStencil8 the whole frame of the extruded World Atlas sample at
  60deg pitch takes about 20 ms [7][8][9].
- **Group offscreen passes and keep depth across extrusion layers.** MapLibre
  GL JS renders all extrusion textures before other layers and no longer
  clears depth between extrusion layers [1].
- **Symbols over buildings.** OsmAnd enables stencil every frame because some
  far volumetric symbols must appear in front of buildings [63]; it also fixed
  symbol order after adding buildings [67].
- **2.5D without depth.** Marble culls back-facing walls (about 20% faster)
  [31], draws buildings flat at level 17 to cut draw calls [32], and switches to
  the cheaper fake-3D mode earlier on high-DPI mobiles [33]. Mapnik projects
  first, offsets the roof by the height in screen pixels, and dropped its
  per-wall y-sort, with no visual comparison recorded [61].

### Lighting, shadows, AO

- **Light in the local tangent frame.** With an Earth-fixed light, ortho-earth's
  lit wall depended on longitude; a test with identical boxes in Tokyo and
  London now requires equal wall luminance (Tokyo S=176/N=218, London
  identical) [13].
- **Keep camera shear out of the normal matrix.** tangram-es builds the normal
  matrix from the unsheared view for its isometric camera; before, diffuse
  light changed as the camera moved [38][39].
- **Plausible PBR without assets.** ortho-earth integrates 96 sky directions
  of its own scattering model into SH9 diffuse sky and caches it by key, uses
  an analytic specular, screen-derivative tangents, both faces drawn, and a
  0.35 fixed fill so walls stay readable; WebGPU only [13].
- **Shadows: one window, cull casters, reuse the pass.** ortho-earth keeps one
  sun-orthographic 2048^2 map (16 MB; 1024^2 in low memory) up to about
  12 km, culls casters by the window rectangle and reuses the depth pass when
  static (339 of 375 frames). At Tokyo Station, z15/60deg, about 7.4M casting
  triangles cost 1.9 to 2.3 ms after culling. Three cascades (about 48 MB) were
  rejected because each level casts over a wider area; about 19% of the
  visible ground has no shadow [15]. iTowns made shadows opt-in, separate
  from atmospheric lighting; its first version spent most of the frame in
  picking, which the author's fast machine had hidden [50].
- **AO by blending, not by another target.** Half-resolution horizon AO, 4x4
  depth-weighted blur, then a multiply blend onto the existing target before
  line overlays; only two half-res r8 targets stay resident, against about
  12 MB for a full-res RGBA8 target at DPR 2 [13]. Its look was then tuned:
  strength 0.5 to 0.6 and radius 10% of the view distance; 0.7 looked dirty
  and a 15% radius barely differed [14].

### Imported models and export

- **Bake imported meshes into the renderer's own format.** In a GL3 core
  profile osgEarth has no default pipeline, so external models need generated
  shaders, and some textured tiles still render black [55][56]. osgEarth's
  glTF loader covers only what some 3D Tiles need; an instanced truck lost its
  wheels while the single model was fine [57]. Projected-texture draping
  flattens any 3D model; osgEarth recommends reading the source data instead
  [58]. iTowns lets each layer choose: vectors rasterised into the terrain
  tile texture (follows terrain, resolution bounded by the tile texture), or
  3D meshes placed with base altitude and extrusion height [46][47]. A GDAL
  3/PROJ 6 axis-order change tilted terrain-clamped models [54]. osgEarth's
  model textures moved from texture arrays to bindless textures, which is
  desktop GL only [59].
- **Parse off the render thread, upload on it.** Organic Maps parses an OBJ
  arrow on the backend thread and creates GPU objects on the frontend thread;
  the default arrow costs no frame rate [69].
- **Update in place.** TerriaJS keeps stable glTF DataSource/Entity instances
  instead of recreating them on each trait change ("vastly improves the
  performance", no numbers) [53]. Cesium removed CPU instancing in 1.97 and
  kept GPU instancing only [10][11].
- **Export and tests.** QGIS kept exact golden OBJ/STL comparisons (triangles
  sorted first) but dropped the largest terrain STL; reviewers objected that
  40k-line files are impossible to debug and break on tessellator or tiling
  changes [19][20]. Its STL export ships open terrain and floating buildings,
  which need manual repair before printing [19].

## Tried and reverted

- Marble skipped walls under 1.4 px offset and simplified them under 5 px,
  claiming about 50% faster rendering at level 17; it was reverted the next day
  as "reinventing the wheel", without naming what it duplicated [29][30].
- QGIS reverted a special triangulation path for classified point clouds and
  used the plain colour-ramp settings for all modes [26][27][28].
- maplibre-rs's fixed-height extrusion behind a style flag (no OSM heights)
  went stale unmerged [6].
- openglobus wrote re-centring of segments as the camera moves, then left it
  commented out [42][45].
- ortho-earth rejected cascaded shadows (more area per level, not cheaper)
  [15], and replaced hemisphere-sample AO with horizon-based AO (HBAO; in a
  desk simulation, building feet went from 0.92 to 0.8) [13].

## Guidance for a renderer in this repository

Write the choice in `architecture_selection` and the measured result in
`performance_knowledge`, as SKILL.md of map-from-scratch asks.

1. **First decide whether the request needs real 3D.** A top-down map with
   "building heights" is a choropleth or a 2.5D offset. For 2.5D in Canvas 2D,
   do as Marble and Mapnik: offset roofs in screen pixels, cull walls by the
   sign of the edge cross product, no depth buffer [31][61]. Real 3D (free
   pitch, interlocking buildings) needs a depth buffer from the first frame
   [9]. List what is not done (shadows, AO, PBR, glTF, labels over buildings)
   in `not_doing`.
2. **Bake walls, normals and winding.** In `bake.ts`: project to the local
   grid the examples already use, so vertices are relative to the area's
   origin and no high/low split is needed [48][45]; dedupe vertices in
   projected units, then earcut roofs [23][49]; force ring orientation and
   test it as an invariant [34]; emit 4 vertices per wall edge with a flat
   int8 normal [5][18]; drop walls on tile or clip seams [40]. Instance walls
   per edge only when the wall vertex count is what blows a memory budget [3];
   for a few hundred buildings a baked mesh is simpler [3].
3. **Quantise heights from the error at the closest zoom.** Pick the unit (1/16 m,
   cm) and check the tallest feature fits the attribute: 1/16 m in int16
   leaves about 2,047 m [36][41]. Use highp for anything with a large range in
   the shader [2].
4. **Lay out indices for the cuts the request makes.** One buffer per tile or
   per colour [68][60]; uint32 indices (WebGL2 has them) or split at 65,535
   vertices [68]; triangles sorted by building height with a prefix count per
   threshold if the view zooms out over a city [18]. Weld identical
   quantised vertices at bake time and measure the saving [17].
5. **Cover tall geometry and gate by zoom.** With pitch, extend tile or chunk
   culling to the near-side triangle [37]; bind buildings to the zoom band where
   they are visible, at bake time [51][64].
6. **Light in the local frame, unsheared.** A fixed light in local
   east/north/up and a minimum fill keep walls readable [13][38]. Add a
   verification check modelled on ortho-earth's: two identical buildings in
   different places must give equal wall luminance [13].
7. **Shadows and AO only when asked**, and then count caster triangles first:
   ortho-earth's cost followed LOD2 density (about 7M triangles), not the
   technique [15]. Compose AO with a blend state, not a full-res target [13].
8. **Plan labels and blending before the first building.** OsmAnd's sequence
   shows that extrusions force changes to alpha and symbol order [65][66][67].
9. **Test invariants, not golden meshes**: triangle count, area, bounds,
   winding, watertightness if exported for printing, and pixel checks read back
   from the screen [19][20]. If the request is printing, close the mesh (walls
   and a base) at bake time [19].
10. **Imported models**: convert to the renderer's vertex format at bake time,
    support only the glTF subset the data uses, and test through the
    production path, instanced or not [55][57][58]. In WebGL2 there are no
    bindless textures: use an atlas or a texture array with sizes fixed at
    bake time [59]. Flat overlays on terrain can be rasterised into the
    terrain texture; buildings cannot [46][58].

## Sources

1. https://github.com/maplibre/maplibre-gl-js/commit/c48a56be0
2. https://github.com/maplibre/maplibre-gl-js/commit/460c28541
3. https://github.com/maplibre/maplibre-native/commit/6aa804f7e
4. https://github.com/maplibre/maplibre-native/commit/1b6ed3542
5. ref-repos/maplibre__maplibre-native/src/mln/renderer/buckets/fill_extrusion_bucket.hpp
6. https://github.com/maplibre/maplibre-rs/pull/192
7. https://github.com/opengeos/RustGIS/pull/433
8. https://github.com/opengeos/RustGIS/pull/489
9. https://github.com/opengeos/RustGIS/pull/492
10. ref-repos/CesiumGS__cesium/CHANGES.md
11. https://github.com/CesiumGS/cesium/pull/10589
12. https://github.com/visgl/deck.gl/commit/85298e97
13. https://github.com/kenjiyoshidahome2026-bit/ortho-earth/issues/46
14. https://github.com/kenjiyoshidahome2026-bit/ortho-earth/issues/63
15. https://github.com/kenjiyoshidahome2026-bit/ortho-earth/issues/112
16. https://github.com/kenjiyoshidahome2026-bit/ortho-earth/commit/1db85633
17. https://github.com/kenjiyoshidahome2026-bit/ortho-earth/commit/74b8d5f0
18. https://github.com/kenjiyoshidahome2026-bit/ortho-earth/commit/da8dc2f4
19. https://github.com/qgis/QGIS/pull/63444
20. https://github.com/qgis/QGIS/pull/63173
21. https://github.com/qgis/QGIS/pull/58583
22. https://gdal.org/en/latest/development/rfc/rfc64_triangle_polyhedralsurface_tin.html
23. https://github.com/qgis/QGIS/commit/7bce7ea9ca
24. https://github.com/qgis/QGIS/commit/35b37a228f
25. https://github.com/qgis/QGIS/commit/b773d617b1
26. https://github.com/qgis/QGIS/commit/127fabd093
27. https://github.com/qgis/QGIS/commit/434e7028dc
28. https://github.com/qgis/QGIS/commit/40df5696b5
29. https://github.com/KDE/marble/commit/8a9ba9981
30. https://github.com/KDE/marble/commit/60128d0bc
31. https://github.com/KDE/marble/commit/02645ed73
32. https://github.com/KDE/marble/commit/2777cef99
33. https://github.com/KDE/marble/commit/207765fd4
34. https://github.com/KDE/marble/commit/d3c923f75
35. https://github.com/KDE/marble/commit/48c412ef1
36. https://github.com/tangrams/tangram/commit/9c6123a3
37. ref-repos/tangrams__tangram-es/core/src/view/view.cpp
38. https://github.com/tangrams/tangram-es/pull/602
39. https://github.com/tangrams/tangram-es/issues/578
40. https://github.com/heremaps/harp.gl/commit/6e977615
41. https://github.com/maptalks/maptalks.js/commit/6768d1adf
42. https://github.com/openglobus/openglobus/issues/963
43. https://github.com/openglobus/openglobus/issues/824
44. https://github.com/openglobus/openglobus/commit/7e774ca7
45. ref-repos/openglobus__openglobus/src/segment/Segment.ts
46. ref-repos/iTowns__itowns/docs/tutorials/Fundamentals.md
47. https://github.com/iTowns/itowns/commit/f286b40
48. https://github.com/iTowns/itowns/pull/1662
49. https://github.com/iTowns/itowns/pull/195
50. https://github.com/iTowns/itowns/pull/2691
51. https://github.com/iTowns/itowns/commit/1f8323ff
52. https://github.com/iTowns/itowns/commit/082eda8e
53. https://github.com/TerriaJS/terriajs/commit/94bfb5dbc93939f77cf8e6b869112862a15cb843
54. https://github.com/pelicanmapping/osgearth/issues/1472
55. https://github.com/pelicanmapping/osgearth/issues/2046
56. https://github.com/pelicanmapping/osgearth/issues/2472
57. https://github.com/pelicanmapping/osgearth/issues/1598
58. https://github.com/pelicanmapping/osgearth/issues/693
59. https://github.com/pelicanmapping/osgearth/commit/f8da52de9
60. https://github.com/pelicanmapping/osgearth/commit/628d38244
61. https://github.com/mapnik/mapnik/commit/4a9320942
62. https://github.com/osmandapp/OsmAnd-core/blob/master/src/Map/GeometryModifiers.cpp
63. https://github.com/osmandapp/OsmAnd-core/blob/master/src/Map/OpenGL/AtlasMapRenderer_OpenGL.cpp
64. https://github.com/osmandapp/OsmAnd-core/pull/986
65. https://github.com/osmandapp/OsmAnd-core/pull/925
66. https://github.com/osmandapp/OsmAnd-core/pull/974
67. https://github.com/osmandapp/OsmAnd-core/pull/975
68. https://github.com/osmandapp/OsmAnd-core/commit/ebaace2a
69. https://github.com/organicmaps/organicmaps/pull/5397