Skip to content

Draw 3D Buildings and glTF Models in MapLibre GL JS

On this page

License Version Downloads

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.

LayerWhat it draws
Fill3DLayerThe buildings3d layer of the Maptoolkit buildings tileset: buildings as 3D bodies with roof shapes.
GltfLayerglTF 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-gl
import { 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:

  1. Add the buildings tileset as a vector source.
  2. Add a fill-extrusion layer on the buildings2d source layer. It draws the buildings without a roof shape, and it makes MapLibre load the tiles that Fill3DLayer reads.
  3. Add Fill3DLayer on the buildings3d source 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

OptionTypeRequiredDescription
idstringYesLayer id.
sourcestringYesId of a vector source already added to the map.
source-layerstringYesSource layer to read, buildings3d for the Maptoolkit buildings.
filterfilter expressionNoFeatures that do not match are not drawn.
terrainSourceIdstringNoraster-dem source for the ground height. Default: the map’s active terrain.
layout.visibility"visible" | "none"NoDefault: "visible".
paint["fill-3d-color"]color or expressionNoColor per feature. Default: white.
paint["fill-3d-opacity"]number or expressionNoOpacity 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 layerZoomContentsDraw it with
buildings2d12 to 15Building footprints from OpenStreetMap with their height.fill-extrusion
buildings3d15Buildings 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:

FieldFrom zoomDescription
height12Height in meters. At zoom 12, neighboring buildings are merged into blocks and the height is in 5 m steps.
min_height13Height in meters at which the building starts above the ground.
extrude13false for the outline of a building that is drawn by its parts. Leave these out, as in the example above.
colour_index13A number from 0 to 9, to vary the color between neighboring buildings.
building_colour13Building color from OpenStreetMap, where mapped.
building13school, church, public or train_station. Not set for other buildings.
roof_shape, roof_height, roof_colour, roof_material, …14The OpenStreetMap roof tags (roof:shape, roof:height, …), unchanged.

Fields of buildings3d:

FieldDescription
typeroof or wall. Use it to give roofs their own color.
sourceosm for OpenStreetMap buildings, citygml for buildings from a city model.
colour_indexA 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

OptionTypeRequiredDescription
idstringYesLayer id.
sourcestringYesId of a geojson or vector source already added to the map.
models{ [id]: url }YesglTF or GLB files by id.
source-layerstringFor vector sourcesSource layer to read.
filterfilter expressionNoFeatures that do not match get no model.
minzoom / maxzoomnumberNoZoom range in which the layer is shown.
layout["model-id"]string or expressionNoWhich entry of models a feature uses. Default: the only model.
layout["model-placement"]"auto" | "point" | "line" | "fill"NoHow features become models. Default: "auto", by geometry type.
layout["model-spacing"]number or expressionNoMeters between models on lines and in polygons. Default: 25.
paint["model-scale"][x, y, z], number or expressionNoFactor on the model’s own size. Default: [1, 1, 1].
paint["model-rotation"][x, y, z] or expressionNoDegrees around east, north and up. z is a compass heading.
paint["model-translation"][x, y, z] or expressionNoOffset in meters toward east, north and up. Default: [0, 0, 0].
paint["model-color"]color or expressionNoMultiplied onto the model’s own colors.
paint["model-opacity"]number or expressionNoMultiplied 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 Fill3DLayer building does not hide it.
  • On a strongly tilted map, the Fill3DLayer buildings nearest to the camera can stay flat for a moment.
  • GltfLayer does 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.