Skip to content

Adding 3D models with three.js on terrain

Use a custom style layer with three.js to add 3D models to a map with 3d terrain.

<!DOCTYPE html>
<html lang="en">

    <title>Adding 3D models with three.js on terrain</title>
    <meta property="og:description" content="Use a custom style layer with three.js to add 3D models to a map with 3d terrain." />
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel='stylesheet' href='' />
    <script src=''></script>
        body {
            margin: 0;
            padding: 0;

        #map {
            height: 100%;

    <script src=""></script>
    <script src=""></script>
    <div id="map"></div>
         * Objective:
         * Given two known world-locations `model1Location` and `model2Location`,
         * place two three.js objects on those locations at the appropriate height of
         * the terrain.

        async function main() {

            const THREE = window.THREE;

            const map = new maplibregl.Map({
                container: 'map',
                center: [11.5257, 47.668],
                zoom: 16.27,
                pitch: 60,
                bearing: -28.5,
                antialias: true,
                style: {
                    version: 8,
                    layers: [
                            id: 'baseColor', // Hides edges of terrain tiles, which have 'walls' going down to 0.
                            type: 'background',
                            paint: {
                                'background-color': '#fff',
                                'background-opacity': 1.0,
                        }, {
                            id: 'hills',
                            type: 'hillshade',
                            source: 'hillshadeSource',
                            layout: {visibility: 'visible'},
                            paint: {'hillshade-shadow-color': '#473B24'}
                    terrain: {
                        source: 'terrainSource',
                        exaggeration: 1,
                    sources: {
                        terrainSource: {
                            type: 'raster-dem',
                            url: '',
                            tileSize: 256
                        hillshadeSource: {
                            type: 'raster-dem',
                            url: '',
                            tileSize: 256

            * Helper function used to get threejs-scene-coordinates from mercator coordinates.
            * This is just a quick and dirty solution - it won't work if points are far away from each other
            * because a meter near the north-pole covers more mercator-units
            * than a meter near the equator.
            function calculateDistanceMercatorToMeters(from, to) {
                const mercatorPerMeter = from.meterInMercatorCoordinateUnits();
                // mercator x: 0=west, 1=east
                const dEast = to.x - from.x;
                const dEastMeter = dEast / mercatorPerMeter;
                // mercator y: 0=north, 1=south
                const dNorth = from.y - to.y;
                const dNorthMeter = dNorth / mercatorPerMeter;
                return {dEastMeter, dNorthMeter};

            async function loadModel() {
                const loader = new THREE.GLTFLoader();
                const gltf = await loader.loadAsync('');
                const model = gltf.scene;
                return model;

            // Known locations. We'll infer the elevation of those locations once terrain is loaded.
            const sceneOrigin = new maplibregl.LngLat(11.5255, 47.6677);
            const model1Location = new maplibregl.LngLat(11.527, 47.6678);
            const model2Location = new maplibregl.LngLat(11.5249, 47.6676);

            // Configuration of the custom layer for a 3D model, implementing `CustomLayerInterface`.
            const customLayer = {
                id: '3d-model',
                type: 'custom',
                renderingMode: '3d',

                onAdd(map, gl) {
                     * Setting up three.js scene.
                     * We're placing model1 and model2 in such a way that the whole scene fits over the terrain.

           = new THREE.Camera();
                    this.scene = new THREE.Scene();
                    // In threejs, y points up - we're rotating the scene such that it's y points along maplibre's up.
                    this.scene.rotateX(Math.PI / 2);
                    // In threejs, z points toward the viewer - mirroring it such that z points along maplibre's north.
                    this.scene.scale.multiply(new THREE.Vector3(1, 1, -1));
                    // We now have a scene with (x=east, y=up, z=north)

                    const light = new THREE.DirectionalLight(0xffffff);
                    // Making it just before noon - light coming from south-east.
                    light.position.set(50, 70, -30).normalize();

                    // Axes helper to show how threejs scene is oriented.
                    const axesHelper = new THREE.AxesHelper(60);

                    // Getting model elevations (in meters) relative to scene origin from maplibre's terrain.
                    const sceneElevation = map.queryTerrainElevation(sceneOrigin) || 0;
                    const model1Elevation = map.queryTerrainElevation(model1Location) || 0;
                    const model2Elevation = map.queryTerrainElevation(model2Location) || 0;
                    const model1up = model1Elevation - sceneElevation;
                    const model2up = model2Elevation - sceneElevation;

                    // Getting model x and y (in meters) relative to scene origin.
                    const sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(sceneOrigin);
                    const model1Mercator = maplibregl.MercatorCoordinate.fromLngLat(model1Location);
                    const model2Mercator = maplibregl.MercatorCoordinate.fromLngLat(model2Location);
                    const {dEastMeter: model1east, dNorthMeter: model1north} = calculateDistanceMercatorToMeters(sceneOriginMercator, model1Mercator);
                    const {dEastMeter: model2east, dNorthMeter: model2north} = calculateDistanceMercatorToMeters(sceneOriginMercator, model2Mercator);

                    model1.position.set(model1east, model1up, model1north);
                    model2.position.set(model2east, model2up, model2north);


                    // Use the MapLibre GL JS map canvas for three.js.
                    this.renderer = new THREE.WebGLRenderer({
                        canvas: map.getCanvas(),
                        context: gl,
                        antialias: true

                    this.renderer.autoClear = false;

                render(gl, mercatorMatrix) {

                    // `queryTerrainElevation` gives us the elevation of a point on the terrain
                    // **relative to the elevation of `center`**,
                    // where `center` is the point on the terrain that the middle of the camera points at.
                    // If we didn't account for that offset, and the scene lay on a point on the terrain that is
                    // below `center`, then the scene would appear to float in the air.
                    const offsetFromCenterElevation = map.queryTerrainElevation(sceneOrigin) || 0;
                    const sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(sceneOrigin, offsetFromCenterElevation);

                    const sceneTransform = {
                        translateX: sceneOriginMercator.x,
                        translateY: sceneOriginMercator.y,
                        translateZ: sceneOriginMercator.z,
                        scale: sceneOriginMercator.meterInMercatorCoordinateUnits()

                    const m = new THREE.Matrix4().fromArray(mercatorMatrix);
                    const l = new THREE.Matrix4()
                        .makeTranslation(sceneTransform.translateX, sceneTransform.translateY, sceneTransform.translateZ)
                        .scale(new THREE.Vector3(sceneTransform.scale, -sceneTransform.scale, sceneTransform.scale));

           = m.multiply(l);

            const results = await Promise.all([map.once('load'), loadModel()]);
            const model1 = results[1];
            const model2 = model1.clone();