Skip to content
Adding 3D Models Using three.js on Terrain

Adding 3D Models Using three.js on Terrain

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

{
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
            }
        }

import * as THREE from 'three';
    import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

    const API_KEY = 'toursprung';

    async function main() {
        const map = new maptoolkit.Map({
            container: 'map',
            center: [11.5257, 47.668],
            zoom: 16.27,
            pitch: 60,
            bearing: -28.5,
            attributionControl: { compact: false },
            canvasContextAttributes: { antialias: true },
            style: {
                version: 8,
                layers: [
                    {
                        id: 'baseColor',
                        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',
                        tiles: [`https://vtc-cdn.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
                        tileSize: 256,
                        minzoom: 5,
                        maxzoom: 12,
                        encoding: 'terrarium'
                    },
                    hillshadeSource: {
                        type: 'raster-dem',
                        tiles: [`https://vtc-cdn.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
                        tileSize: 256,
                        minzoom: 5,
                        maxzoom: 12,
                        encoding: 'terrarium'
                    }
                },
            }
        });

        map.addControl(new maptoolkit.NavigationControl(), 'top-right');

        function calculateDistanceMercatorToMeters(from, to) {
            const mercatorPerMeter = from.meterInMercatorCoordinateUnits();
            const dEast = to.x - from.x;
            const dEastMeter = dEast / mercatorPerMeter;
            const dNorth = from.y - to.y;
            const dNorthMeter = dNorth / mercatorPerMeter;
            return { dEastMeter, dNorthMeter };
        }

        async function loadModel() {
            const loader = new GLTFLoader();
            const gltf = await loader.loadAsync('https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf');
            return gltf.scene;
        }

        const sceneOrigin = new maptoolkit.LngLat(11.5255, 47.6677);
        const model1Location = new maptoolkit.LngLat(11.527, 47.6678);
        const model2Location = new maptoolkit.LngLat(11.5249, 47.6676);

        const customLayer = {
            id: '3d-model',
            type: 'custom',
            renderingMode: '3d',

            onAdd(map, gl) {
                this.camera = new THREE.Camera();
                this.scene = new THREE.Scene();
                this.scene.rotateX(Math.PI / 2);
                this.scene.scale.multiply(new THREE.Vector3(1, 1, -1));

                const light = new THREE.DirectionalLight(0xffffff);
                light.position.set(50, 70, -30).normalize();
                this.scene.add(light);

                const axesHelper = new THREE.AxesHelper(60);
                this.scene.add(axesHelper);

                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;

                const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin);
                const model1Mercator = maptoolkit.MercatorCoordinate.fromLngLat(model1Location);
                const model2Mercator = maptoolkit.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);

                this.renderer = new THREE.WebGLRenderer({
                    canvas: map.getCanvas(),
                    context: gl,
                    antialias: true
                });
                this.renderer.autoClear = false;
            },

            render(gl, args) {
                const offsetFromCenterElevation = map.queryTerrainElevation(sceneOrigin) || 0;
                const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin, offsetFromCenterElevation);

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

                const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
                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();
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Adding 3D Models Using three.js on Terrain – Maptoolkit Maps JS</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">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.2/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.2/dist/maptoolkit.css" />
    <script type="importmap">
        {
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
            }
        }
    </script>
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
    </style>
</head>
<body>
<div id="map"></div>
<script type="module">
    import * as THREE from 'three';
    import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

    const API_KEY = 'toursprung';

    async function main() {
        const map = new maptoolkit.Map({
            container: 'map',
            center: [11.5257, 47.668],
            zoom: 16.27,
            pitch: 60,
            bearing: -28.5,
            attributionControl: { compact: false },
            canvasContextAttributes: { antialias: true },
            style: {
                version: 8,
                layers: [
                    {
                        id: 'baseColor',
                        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',
                        tiles: [`https://vtc-cdn.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
                        tileSize: 256,
                        minzoom: 5,
                        maxzoom: 12,
                        encoding: 'terrarium'
                    },
                    hillshadeSource: {
                        type: 'raster-dem',
                        tiles: [`https://vtc-cdn.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
                        tileSize: 256,
                        minzoom: 5,
                        maxzoom: 12,
                        encoding: 'terrarium'
                    }
                },
            }
        });

        map.addControl(new maptoolkit.NavigationControl(), 'top-right');

        function calculateDistanceMercatorToMeters(from, to) {
            const mercatorPerMeter = from.meterInMercatorCoordinateUnits();
            const dEast = to.x - from.x;
            const dEastMeter = dEast / mercatorPerMeter;
            const dNorth = from.y - to.y;
            const dNorthMeter = dNorth / mercatorPerMeter;
            return { dEastMeter, dNorthMeter };
        }

        async function loadModel() {
            const loader = new GLTFLoader();
            const gltf = await loader.loadAsync('https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf');
            return gltf.scene;
        }

        const sceneOrigin = new maptoolkit.LngLat(11.5255, 47.6677);
        const model1Location = new maptoolkit.LngLat(11.527, 47.6678);
        const model2Location = new maptoolkit.LngLat(11.5249, 47.6676);

        const customLayer = {
            id: '3d-model',
            type: 'custom',
            renderingMode: '3d',

            onAdd(map, gl) {
                this.camera = new THREE.Camera();
                this.scene = new THREE.Scene();
                this.scene.rotateX(Math.PI / 2);
                this.scene.scale.multiply(new THREE.Vector3(1, 1, -1));

                const light = new THREE.DirectionalLight(0xffffff);
                light.position.set(50, 70, -30).normalize();
                this.scene.add(light);

                const axesHelper = new THREE.AxesHelper(60);
                this.scene.add(axesHelper);

                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;

                const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin);
                const model1Mercator = maptoolkit.MercatorCoordinate.fromLngLat(model1Location);
                const model2Mercator = maptoolkit.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);

                this.renderer = new THREE.WebGLRenderer({
                    canvas: map.getCanvas(),
                    context: gl,
                    antialias: true
                });
                this.renderer.autoClear = false;
            },

            render(gl, args) {
                const offsetFromCenterElevation = map.queryTerrainElevation(sceneOrigin) || 0;
                const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin, offsetFromCenterElevation);

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

                const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
                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>