ts-maps

3D rendering

On this page 8

ts-maps has four 3D primitives, all driven by the same WebGL2 renderer used by VectorTileMapLayer when renderer: 'webgl' is active.

APIWhat it does
map.setTerrain({ source, exaggeration })DEM-based mesh warping — see Terrain.
map.setFog({ color, 'horizon-blend', range, 'high-color', 'star-intensity' })Atmospheric haze — CSS gradient overlay tied to pitch.
map.setSky({ 'sky-color', 'horizon-color', 'fog-ground-blend', 'sun-position', 'sun-intensity' })Sky gradient that fades in as pitch rises.
map.addCustomLayer({ id, type: 'custom', render(gl, projectionMatrix) })Your own WebGL code runs per frame alongside the built-in layers.

Plus the style-spec layer type fill-extrusion for extruding polygons to 3D prisms with directional lighting. See Style spec for paint properties.

Pitch

setPitch tilts the map through a real perspective camera — the same one latLngToContainerPoint projects through — so streets converge towards the horizon and everything placed by projection lines up with the tiles under it. Labels, markers, popups and tooltips stand upright at their projected points rather than lying flat on the tilted ground.

Pitch goes up to 85° by default (maxPitch), as in Apple Maps. Past about 72° the horizon comes into view: a sky is drawn above it — the theme's own, or the one setSky sets — and the most distant ground fades into a haze beneath it. Labels too far off to read are left out, and a drag, pan or zoom anchored near the horizon holds on to legible ground rather than flinging the map towards it.

Panning a tilted map moves the ground under a fixed camera: a drag keeps the point you grabbed under the pointer, and the horizon stays where it is.

Tiles are loaded for the ground the view actually sees, at a detail that falls off with distance, as in Apple Maps: full detail near the camera, coarser tiles towards the horizon where a full-detail tile would only be a few pixels across. A 60° view needs about half the tiles it would at full detail, and loads the near ground first. A tile layer's detailLevels option (default 5) caps how many zoom levels coarser the distance may go; 0 keeps full detail throughout.

Fog + sky

Both setFog and setSky render as a stacked linear-gradient overlay (<div class="ts-maps-atmosphere">) positioned above the tile pane with pointer-events: none. Opacity scales with pitch, so a top-down map (pitch: 0) shows no overlay at all.

map.setSky({
  'sky-color': '#87ceeb',
  'horizon-color': '#ffffff',
  'fog-ground-blend': 0.7,
  'sun-position': [0, 45],   // [azimuth_deg, altitude_deg]
  'sun-intensity': 0.3,
})

map.setFog({
  color: 'rgb(245, 247, 250)',
  'horizon-blend': 0.1,
  range: [0.5, 10],
  'star-intensity': 0,       // 0 — 1
})

Validation:

  • setFog: range[0] < range[1] required; star-intensity >= 0.
  • setSky: NaN anywhere throws; fog-ground-blend and sun-intensity are clamped to [0, 1].

Events: fogchange, skychange — fire on every call (including null clears).

Disable either with map.setFog(null) / map.setSky(null).

Custom WebGL layers

map.addCustomLayer({
  id: 'my-extras',
  type: 'custom',
  renderingMode: '3d',
  onAdd(_map, gl) {
    // Compile shaders / upload buffers once.
  },
  onRemove(_map, gl) {
    // Free anything onAdd created.
  },
  render(gl, projectionMatrix) {
    // Called per frame after the tile pass, with the same GL context the
    // tile layers used. Preserve GL state hygiene — save/restore blend
    // mode + depth test if you tweak them.
  },
})

Lookup / removal:

map.getCustomLayer('my-extras')       // CustomLayerInterface | undefined
map.getCustomLayers()                  // CustomLayerInterface[]
map.removeCustomLayer('my-extras')

Errors thrown from a single layer's render() are logged with console.warn but don't stop other custom layers from rendering.

Events: customlayer:add, customlayer:remove.

Fill-extrusion

VectorTileMapLayer accepts fill-extrusion style layers with these paint properties:

  • fill-extrusion-color — CSS colour or expression, default #000.
  • fill-extrusion-opacity — 0–1, default 1.
  • fill-extrusion-height — metres or expression, default 0.
  • fill-extrusion-base — metres or expression, default 0. Features where height <= base are skipped.
  • fill-extrusion-vertical-gradient — boolean (currently ignored by the shader; reserved for parity).
vectorTileLayer({
  url: 'https://tiles.example.com/{z}/{x}/{y}.pbf',
  renderer: 'webgl',
  layers: [{
    id: 'buildings',
    type: 'fill-extrusion',
    sourceLayer: 'building',
    paint: {
      'fill-extrusion-color': '#a69a8a',
      'fill-extrusion-height': ['get', 'height'],
      'fill-extrusion-base': ['get', 'min_height'],
      'fill-extrusion-opacity': 0.9,
    },
  }],
}).addTo(map)

Extrusions are drawn as real 3D buildings, the way Apple Maps draws them: boxes seen through the map's own camera, standing up off the ground as the map tilts, over the tiles and under the labels. The built-in styles.light() and styles.dark() extrude OpenMapTiles buildings to their mapped render_height, fading them in between zoom 14 and 15.

  • Real camera. Buildings go into one viewport-sized WebGL canvas with a projection identical to latLngToContainerPoint, so each footprint sits exactly on the ground it belongs to, at any bearing or pitch. Seen straight down, tall buildings lean gently away from the centre, as a real camera sees them.

  • Lighting. Roofs are lifted slightly above the base colour; walls are lit from the north-west and darken towards their foot, which is what separates a block of similar buildings into distinct boxes.

  • Distance. Buildings fade into the haze a few camera heights out, and tiles entirely past it are never meshed.

  • Cost. Geometry is built once per source tile — shared by every grid tile that shows part of it past the source's top zoom — packed to 20 bytes a vertex, and uploaded once. A frame is a matrix and a draw call per tile.

  • Labels behind buildings. A street name, or a point of interest with an icon, fades out when a building stands between it and the camera, and gives up its space to labels that can be seen — the line of sight from the label's spot on the ground to the camera is tested against the buildings' footprints and heights. Area names — districts, cities — have no single spot to hide and stay in view over the buildings, as in Apple Maps.

Height, base and colour may be data-driven and are evaluated at the tile's zoom; fill-extrusion-opacity is evaluated at the live zoom, so a layer can fade in smoothly. Where WebGL is unavailable, extrusions fall back to flat footprints in their own colour.

Roof shapes

Where a building's tile carries its roof, it is drawn pitched rather than flat: OpenStreetMap's roof:shape and roof:height (or roof:levels), and Overture's roof_shape and roof_height. Gabled, hipped, pyramidal and skillion roofs are drawn as such; gambrel and saltbox as gables, mansard as a hip, domes and onions as pyramids. A gable's ridge runs along the footprint's long side (roof:orientation=across turns it), a skillion slopes down towards roof:direction, and with no height a roof is pitched at 30°. The walls rise to meet it, gable ends included.

OpenMapTiles tiles leave roof shapes out, so the built-in styles over them stay flat-roofed. A building over a courtyard, or cut by the tile edge, is also left flat.

Landmarks

import { landmark } from 'ts-maps'

landmark({
  model: '/models/transamerica.glb',
  position: [37.7952, -122.4028],
  rotation: 0, // degrees clockwise; a glTF model faces south at 0
}).addTo(map)

A landmark is a glTF model standing where a building is, as Apple Maps swaps famous buildings' boxes for models of them. It is drawn in the building pass: lit, fogged and depth-tested with the buildings, and labels behind it are hidden as they are behind a building. By default the extruded building it stands on is left out (replace: false keeps it).

  • model: a .glb or .gltf URL (its buffers are fetched beside it), a .glb's bytes, or a parsed glTF with its buffers inline.
  • altitude (metres), rotation, scale (glTF is in metres), and setPosition, setRotation, setScale, setAltitude to move it.
  • minZoom: hidden zoomed out further than this, default 15.

Models are read for their triangles, node transforms, vertex colours and baseColorFactor. Textures are not drawn, and Draco- or meshopt-compressed models are refused with an error saying so.

Trees

import { trees } from 'ts-maps'

trees().addTo(map)

Trees planted in the woods the basemap already has (landcover class wood in OpenMapTiles, kind: forest in Protomaps and Shortbread), and single trees where a source has them (natural=tree). They are planted on a jittered grid: the same trees in the same places every time, and none twice across a tile edge. Each is a trunk and a low-poly crown in one of a few greens.

  • spacing: metres between trees in a wood, default 9.
  • minZoom (default 15) and minPitch (default 20°): trees come in as the map tilts, and are not drawn looking straight down.
  • maxPerTile: the budget, default 3000. Zoomed out, a tile covers more ground; past the budget, every wood at that zoom is planted more thinly, evenly, so no tile edge shows.
  • colors, height ([min, max] metres), and match(layer, properties) for a schema the default does not know.

Trees stop a few camera heights out, short of the haze, and each shown tile's trees are one mesh built when they first come into view.

Landmarks and trees are drawn by the first vector tile layer on the map that reads from a tile server (the basemap, in a styled map), so they need a vector basemap and WebGL. Every framework binding has them as <Landmark> and <Trees>.

Switching projection at runtime

projection: 'globe' can be set when the map is created, and changed at any time after:

map.setProjection('globe')
map.getProjection() // 'globe'
map.setProjection('mercator')

Nothing about the CRS changes — the projection is consulted while rendering — so this is a repaint rather than a rebuild, and the camera survives it. The map fires projectionchange.

That matters because "show me the globe" is a view toggle a user presses, not a property of how the map was built.