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.
| API | What 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-blendandsun-intensityare 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 whereheight <= baseare 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.glbor.gltfURL (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), andsetPosition,setRotation,setScale,setAltitudeto 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) andminPitch(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), andmatch(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.