Draw 3D Buildings and glTF Models in MapLibre GL JS
On this page
Two MapLibre GL JS custom layers that draw real 3D geometry on the map with three.js. You add them with map.addLayer() like any other layer, and they sit on the terrain when the map has terrain.
| Layer | What it draws |
|---|---|
Fill3DLayer | The buildings3d layer of the Maptoolkit buildings tileset: buildings as 3D bodies with roof shapes. |
GltfLayer | glTF models at the features of a source: one per point, spaced along lines, or spread over polygons. |
To show the 3D buildings and trees of a Maptoolkit style, you do not need to set up these layers yourself, and you do not need to install this package. The Scene3D Control brings it along and does the setup for you. Use this package directly when you want your own colors, your own models, or a style without a 3D scene.
Install
npm install @maptoolkit/maplibre-3d-layers three maplibre-glimport { Fill3DLayer, GltfLayer } from "@maptoolkit/maplibre-3d-layers";Without a bundler
The package is ESM-only. Its standalone build includes three.js, so the import map needs only one entry, for maplibre-gl:
<link href="https://unpkg.com/maplibre-gl@^6.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<script type="importmap">
{
"imports": {
"maplibre-gl": "https://unpkg.com/maplibre-gl@^6.0.0/dist/maplibre-gl.mjs"
}
}
</script>
<script type="module">
import * as maplibregl from "maplibre-gl";
import { Fill3DLayer, GltfLayer } from "https://unpkg.com/@maptoolkit/maplibre-3d-layers@^0.5.3/dist/maplibre-3d-layers.standalone.mjs";
const map = new maplibregl.Map({ container: "map", style, center, zoom, pitch: 60, canvasContextAttributes: { antialias: true } });
map.on("load", () => map.addLayer(new GltfLayer({ /* options */ })));
</script>Point the import map at MapLibre’s own dist/maplibre-gl.mjs, not at a jsDelivr +esm URL, which bundles its own copy of MapLibre. Do not load three.js a second time on the same page.
Fill3DLayer
Fill3DLayer draws the 3D buildings of the Maptoolkit buildings tileset. Set it up in three steps:
- Add the buildings tileset as a vector source.
- Add a
fill-extrusionlayer on thebuildings2dsource layer. It draws the buildings without a roof shape, and it makes MapLibre load the tiles thatFill3DLayerreads. - Add
Fill3DLayeron thebuildings3dsource layer.
import * as maplibregl from "maplibre-gl";
import { Fill3DLayer } from "@maptoolkit/maplibre-3d-layers";
const API_KEY = "YOUR_API_KEY";
const map = new maplibregl.Map({
container: "map",
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.light.json?api_key=${API_KEY}`,
center: [16.3731, 48.2085],
zoom: 16,
pitch: 60,
canvasContextAttributes: { antialias: true },
});
map.on("load", () => {
// Add the buildings below the first label layer, so the labels stay on top.
const labels = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
map.addSource("buildings", {
type: "vector",
url: `https://tiles.maptoolkit.net/buildings.json?api_key=${API_KEY}`,
});
// Footprints raised to their height.
map.addLayer(
{
id: "buildings-2d",
type: "fill-extrusion",
source: "buildings",
"source-layer": "buildings2d",
// Skip the outline of a building that is drawn by its parts.
filter: ["!=", ["get", "extrude"], false],
paint: {
"fill-extrusion-color": "#e6e1d8",
"fill-extrusion-height": ["get", "height"],
"fill-extrusion-base": ["coalesce", ["get", "min_height"], 0],
"fill-extrusion-opacity": 0.9,
},
},
labels,
);
// Buildings with roof shapes and from city models, as 3D bodies.
map.addLayer(
new Fill3DLayer({
id: "buildings-3d",
source: "buildings",
"source-layer": "buildings3d",
paint: {
"fill-3d-color": ["match", ["get", "type"], "roof", "#b0483c", "#e6e1d8"],
"fill-3d-opacity": 0.9,
},
}),
labels,
);
});Never put a fill-extrusion or any other MapLibre layer on buildings3d. That layer stores a height for every point in a Maptoolkit extension of the Mapbox Vector Tile (MVT) format, and MapLibre’s own layers draw it as broken shapes.
Fill3DLayer requests the tiles of the source itself. These requests go through the map’s transformRequest, like MapLibre’s own, so headers and credentials you set there apply to them too.
Options
| Option | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Layer id. |
source | string | Yes | Id of a vector source already added to the map. |
source-layer | string | Yes | Source layer to read, buildings3d for the Maptoolkit buildings. |
filter | filter expression | No | Features that do not match are not drawn. |
terrainSourceId | string | No | raster-dem source for the ground height. Default: the map’s active terrain. |
layout.visibility | "visible" | "none" | No | Default: "visible". |
paint["fill-3d-color"] | color or expression | No | Color per feature. Default: white. |
paint["fill-3d-opacity"] | number or expression | No | Opacity from 0 to 1. Default: 1. |
Color and opacity can read the feature’s fields, the zoom and the map’s global state.
The buildings tileset
The TileJSON is https://tiles.maptoolkit.net/buildings.json?api_key=YOUR_API_KEY. It has tiles from zoom 12 to 15. Above zoom 15 the map shows the zoom 15 tiles.
| Source layer | Zoom | Contents | Draw it with |
|---|---|---|---|
buildings2d | 12 to 15 | Building footprints from OpenStreetMap with their height. | fill-extrusion |
buildings3d | 15 | Buildings as 3D bodies with the roof shape mapped in OpenStreetMap, and buildings from official 3D city models where available, for example in Vienna. | Fill3DLayer |
At zoom 15 each building is in exactly one of the two layers. Buildings with a roof shape other than flat, and buildings that a city model covers, are in buildings3d; all others are in buildings2d. Draw both layers to show all buildings. Up to zoom 14, buildings2d has every building.
Fields of buildings2d:
| Field | From zoom | Description |
|---|---|---|
height | 12 | Height in meters. At zoom 12, neighboring buildings are merged into blocks and the height is in 5 m steps. |
min_height | 13 | Height in meters at which the building starts above the ground. |
extrude | 13 | false for the outline of a building that is drawn by its parts. Leave these out, as in the example above. |
colour_index | 13 | A number from 0 to 9, to vary the color between neighboring buildings. |
building_colour | 13 | Building color from OpenStreetMap, where mapped. |
building | 13 | school, church, public or train_station. Not set for other buildings. |
roof_shape, roof_height, roof_colour, roof_material, … | 14 | The OpenStreetMap roof tags (roof:shape, roof:height, …), unchanged. |
Fields of buildings3d:
| Field | Description |
|---|---|
type | roof or wall. Use it to give roofs their own color. |
source | osm for OpenStreetMap buildings, citygml for buildings from a city model. |
colour_index | A number from 0 to 9, to vary the color between neighboring buildings. |
OpenStreetMap buildings in buildings3d also carry roof_shape, height, min_height and, where mapped, roof_colour, building_colour, roof_material and building.
GltfLayer
GltfLayer draws a glTF model at each feature of a source. This example places one model at a point and turns it to a compass heading:
import { GltfLayer } from "@maptoolkit/maplibre-3d-layers";
map.on("load", () => {
map.addSource("kiosks", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { heading: 30 },
geometry: { type: "Point", coordinates: [16.3725, 48.2083] },
},
],
},
});
map.addLayer(
new GltfLayer({
id: "kiosks-3d",
source: "kiosks",
models: { kiosk: "https://example.com/models/kiosk.glb" }, // your own glTF or GLB file
paint: {
"model-rotation": [0, 0, ["get", "heading"]], // degrees, clockwise from north
},
}),
);
});The geometry type of each feature decides how its models are placed: one model per point, a model every model-spacing meters along a line, or models spread over a polygon. A GeoJSON source needs nothing else. A vector source needs source-layer, and a normal style layer must use the same source so MapLibre loads its tiles. For a style’s own source, such as mtk in the Maptoolkit styles, the style’s layers already do that.
Options
| Option | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Layer id. |
source | string | Yes | Id of a geojson or vector source already added to the map. |
models | { [id]: url } | Yes | glTF or GLB files by id. |
source-layer | string | For vector sources | Source layer to read. |
filter | filter expression | No | Features that do not match get no model. |
minzoom / maxzoom | number | No | Zoom range in which the layer is shown. |
layout["model-id"] | string or expression | No | Which entry of models a feature uses. Default: the only model. |
layout["model-placement"] | "auto" | "point" | "line" | "fill" | No | How features become models. Default: "auto", by geometry type. |
layout["model-spacing"] | number or expression | No | Meters between models on lines and in polygons. Default: 25. |
paint["model-scale"] | [x, y, z], number or expression | No | Factor on the model’s own size. Default: [1, 1, 1]. |
paint["model-rotation"] | [x, y, z] or expression | No | Degrees around east, north and up. z is a compass heading. |
paint["model-translation"] | [x, y, z] or expression | No | Offset in meters toward east, north and up. Default: [0, 0, 0]. |
paint["model-color"] | color or expression | No | Multiplied onto the model’s own colors. |
paint["model-opacity"] | number or expression | No | Multiplied onto the model’s own opacity. Default: 1. |
Models face north when model-rotation is [0, 0, 0]. Most glTF models are made at real size, so with the default model-scale a 9 m tree is 9 m tall.
The layer places models only where they are on screen and draws distant models as flat images, so dense data such as forests stays fast. The package README covers these performance settings, per-component expressions and lighting.
Change values after adding a layer
Custom layers have no setPaintProperty(): paint and layout are read once when you add the layer. To change a value later, read it from MapLibre’s global state with ["global-state", name] and set it with map.setGlobalStateProperty():
map.setGlobalStateProperty("buildingOpacity", 0.9);
map.addLayer(
new Fill3DLayer({
id: "buildings-3d",
source: "buildings",
"source-layer": "buildings3d",
paint: { "fill-3d-opacity": ["global-state", "buildingOpacity"] },
}),
);
// Later, for example from a slider:
map.setGlobalStateProperty("buildingOpacity", 0.4);Each call to setGlobalStateProperty() is a style change, so keep the values coarse, for example whole degrees for a pitch.
Requirements
- MapLibre GL JS 6 or later and three.js 0.160 or later. Both are peer dependencies.
- For the Maptoolkit buildings: a Maptoolkit API key with access to the buildings tileset.
Limitations
- Terrain in front of a
Fill3DLayerbuilding does not hide it. - On a strongly tilted map, the
Fill3DLayerbuildings nearest to the camera can stay flat for a moment. GltfLayerdoes not play animations and skips skinned meshes. Draco- and KTX2-compressed glTF files are not supported.
License
maplibre-3d-layers is open-source under the MIT License.