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">

<head>
    <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='https://unpkg.com/maplibre-gl@4.2.0/dist/maplibre-gl.css' />
    <script src='https://unpkg.com/maplibre-gl@4.2.0/dist/maplibre-gl.js'></script>
    <style>
        body {
            margin: 0;
            padding: 0;
        }

        html,
        body,
        #map {
            height: 100%;
        }
    </style>
</head>

<body>
    <script src="https://unpkg.com/three@0.147.0/build/three.min.js"></script>
    <script src="https://unpkg.com/three@0.147.0/examples/js/loaders/GLTFLoader.js"></script>
    <div id="map"></div>
    <script>
        /**
         * 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: 'https://demotiles.maplibre.org/terrain-tiles/tiles.json',
                            tileSize: 256
                        },
                        hillshadeSource: {
                            type: 'raster-dem',
                            url: 'https://demotiles.maplibre.org/terrain-tiles/tiles.json',
                            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('https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf');
                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.
                     */

                    this.camera = 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();
                    this.scene.add(light);

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

                    // 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);

                    this.scene.add(model1);
                    this.scene.add(model2);

                    // 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));

                    this.camera.projectionMatrix = m.multiply(l);
                    this.renderer.resetState();
                    this.renderer.render(this.scene, this.camera);
                    map.triggerRepaint();
                }
            };

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

            map.addLayer(customLayer);
        }

        main();
    </script>
</body>

</html>