Skip to content

Add a 3D model to globe using three.js

Use a custom style layer with three.js to add a 3D model to a globe.

import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.1.0/dist/maplibre-gl.mjs';

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

const map = new maplibregl.Map({
    container: 'map',
    style: 'https://demotiles.maplibre.org/style.json',
    zoom: 5.5,
    center: [150.16546137527212, -35.017179237129994],
    maxPitch: 80,
    pitch: 70,
    canvasContextAttributes: {antialias: true} // create the gl context with MSAA antialiasing, so custom layers are antialiased
});

map.on('style.load', () => {
    map.setProjection({
        type: 'globe', // Set projection to globe
    });
});

// The API demonstrated in this example will work regardless of projection.
// Click this button to toggle it.
document.getElementById('project').addEventListener('click', () => {
    // Toggle projection
    const currentProjection = map.getProjection();
    map.setProjection({
        type: currentProjection.type === 'globe' ? 'mercator' : 'globe',
    });
});

// Model matrix for the mercator projection: place the model at the given lng/lat/altitude
// in MapLibre's mercator coordinate space and scale meters into mercator units.
function getMercatorModelMatrix(location, altitude) {
    const merc = maplibregl.MercatorCoordinate.fromLngLat(location, altitude);
    const scale = merc.meterInMercatorCoordinateUnits();
    return new THREE.Matrix4()
        .makeTranslation(merc.x, merc.y, merc.z)
        .multiply(new THREE.Matrix4().makeRotationZ(Math.PI))
        .multiply(new THREE.Matrix4().makeRotationX(Math.PI / 2))
        .multiply(new THREE.Matrix4().makeScale(-scale, scale, scale));
}

// Model matrix for the globe (vertical perspective) projection: rotate the model onto the
// sphere at the given lng/lat, lift it by its altitude and scale meters into unit-sphere units.
const earthRadius = 6371008.8; // meters, matches MapLibre's internal value
function getGlobeModelMatrix(location, altitude) {
    const [lng, lat] = location;
    const scale = 1 / earthRadius;
    return new THREE.Matrix4()
        .makeRotationY(lng / 180 * Math.PI)
        .multiply(new THREE.Matrix4().makeRotationX(-lat / 180 * Math.PI))
        .multiply(new THREE.Matrix4().makeTranslation(0, 0, 1 + altitude / earthRadius))
        .multiply(new THREE.Matrix4().makeRotationX(Math.PI / 2))
        .multiply(new THREE.Matrix4().makeScale(scale, scale, scale));
}

// configuration of the custom layer for a 3D model per the CustomLayerInterface
const customLayer = {
    id: '3d-model',
    type: 'custom',
    renderingMode: '3d', // The layer MUST be marked as 3D in order to get the proper depth buffer with globe depths in it.
    onAdd(map, gl) {
        this.camera = new THREE.Camera();
        this.scene = new THREE.Scene();

        // create two three.js lights to illuminate the model
        const directionalLight = new THREE.DirectionalLight(0xffffff);
        directionalLight.position.set(0, -70, 100).normalize();
        this.scene.add(directionalLight);

        const directionalLight2 = new THREE.DirectionalLight(0xffffff);
        directionalLight2.position.set(0, 70, 100).normalize();
        this.scene.add(directionalLight2);

        // use the three.js GLTF loader to add the 3D model to the three.js scene
        const loader = new GLTFLoader();
        loader.load(
            'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
            (gltf) => {
                this.scene.add(gltf.scene);
            }
        );
        this.map = map;

        // 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, args) {
        // parameters to ensure the model is georeferenced correctly on the map
        const modelOrigin = [148.9819, -35.39847];
        const modelAltitude = 0;

        // Make the object ~10s of km tall to make it visible at planetary scale.
        const scaling = 10_000.0;

        // Build the model matrix for the currently rendered projection. `projectionData`
        // exposes `projectionTransition`, which is 0 for mercator and 1 for globe (it also
        // follows the globe->mercator transition when zooming in), so it tells us which
        // coordinate space the model matrix needs to be built in.
        const isGlobe = args.defaultProjectionData.projectionTransition > 0;
        const l = isGlobe ?
            getGlobeModelMatrix(modelOrigin, modelAltitude) :
            getMercatorModelMatrix(modelOrigin, modelAltitude);
        l.scale(new THREE.Vector3(scaling, scaling, scaling));

        const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
        this.camera.projectionMatrix = m.multiply(l);
        this.renderer.resetState();
        this.renderer.render(this.scene, this.camera);
        this.map.triggerRepaint();
    }
};

map.on('style.load', () => {
    map.addLayer(customLayer);
});
<!DOCTYPE html>
<html lang="en">

<head>
    <title>Add a 3D model to globe using three.js</title>
    <meta property="og:description" content="Use a custom style layer with three.js to add a 3D model to a globe." />
    <meta property="og:category" content="3D Models & Buildings" />
    <meta property="og:created" content="2025-06-25" />
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel='stylesheet' href='https://unpkg.com/maplibre-gl@6.1.0/dist/maplibre-gl.css' />

    <style>
        body {
            margin: 0;
            padding: 0;
        }

        html,
        body,
        #map {
            height: 100%;
        }
        #project {
            display: block;
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translate(-50%);
            width: 50%;
            height: 40px;
            padding: 10px;
            border: none;
            border-radius: 3px;
            font-size: 12px;
            text-align: center;
            color: #fff;
            background: #ee8a65;
        }
    </style>
</head>

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

    <div id="map"></div>
    <br />
    <button id="project">Toggle projection</button>
    <script type="module">
        import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.1.0/dist/maplibre-gl.mjs';

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

        const map = new maplibregl.Map({
            container: 'map',
            style: 'https://demotiles.maplibre.org/style.json',
            zoom: 5.5,
            center: [150.16546137527212, -35.017179237129994],
            maxPitch: 80,
            pitch: 70,
            canvasContextAttributes: {antialias: true} // create the gl context with MSAA antialiasing, so custom layers are antialiased
        });

        map.on('style.load', () => {
            map.setProjection({
                type: 'globe', // Set projection to globe
            });
        });

        // The API demonstrated in this example will work regardless of projection.
        // Click this button to toggle it.
        document.getElementById('project').addEventListener('click', () => {
            // Toggle projection
            const currentProjection = map.getProjection();
            map.setProjection({
                type: currentProjection.type === 'globe' ? 'mercator' : 'globe',
            });
        });

        // Model matrix for the mercator projection: place the model at the given lng/lat/altitude
        // in MapLibre's mercator coordinate space and scale meters into mercator units.
        function getMercatorModelMatrix(location, altitude) {
            const merc = maplibregl.MercatorCoordinate.fromLngLat(location, altitude);
            const scale = merc.meterInMercatorCoordinateUnits();
            return new THREE.Matrix4()
                .makeTranslation(merc.x, merc.y, merc.z)
                .multiply(new THREE.Matrix4().makeRotationZ(Math.PI))
                .multiply(new THREE.Matrix4().makeRotationX(Math.PI / 2))
                .multiply(new THREE.Matrix4().makeScale(-scale, scale, scale));
        }

        // Model matrix for the globe (vertical perspective) projection: rotate the model onto the
        // sphere at the given lng/lat, lift it by its altitude and scale meters into unit-sphere units.
        const earthRadius = 6371008.8; // meters, matches MapLibre's internal value
        function getGlobeModelMatrix(location, altitude) {
            const [lng, lat] = location;
            const scale = 1 / earthRadius;
            return new THREE.Matrix4()
                .makeRotationY(lng / 180 * Math.PI)
                .multiply(new THREE.Matrix4().makeRotationX(-lat / 180 * Math.PI))
                .multiply(new THREE.Matrix4().makeTranslation(0, 0, 1 + altitude / earthRadius))
                .multiply(new THREE.Matrix4().makeRotationX(Math.PI / 2))
                .multiply(new THREE.Matrix4().makeScale(scale, scale, scale));
        }

        // configuration of the custom layer for a 3D model per the CustomLayerInterface
        const customLayer = {
            id: '3d-model',
            type: 'custom',
            renderingMode: '3d', // The layer MUST be marked as 3D in order to get the proper depth buffer with globe depths in it.
            onAdd(map, gl) {
                this.camera = new THREE.Camera();
                this.scene = new THREE.Scene();

                // create two three.js lights to illuminate the model
                const directionalLight = new THREE.DirectionalLight(0xffffff);
                directionalLight.position.set(0, -70, 100).normalize();
                this.scene.add(directionalLight);

                const directionalLight2 = new THREE.DirectionalLight(0xffffff);
                directionalLight2.position.set(0, 70, 100).normalize();
                this.scene.add(directionalLight2);

                // use the three.js GLTF loader to add the 3D model to the three.js scene
                const loader = new GLTFLoader();
                loader.load(
                    'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
                    (gltf) => {
                        this.scene.add(gltf.scene);
                    }
                );
                this.map = map;

                // 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, args) {
                // parameters to ensure the model is georeferenced correctly on the map
                const modelOrigin = [148.9819, -35.39847];
                const modelAltitude = 0;

                // Make the object ~10s of km tall to make it visible at planetary scale.
                const scaling = 10_000.0;

                // Build the model matrix for the currently rendered projection. `projectionData`
                // exposes `projectionTransition`, which is 0 for mercator and 1 for globe (it also
                // follows the globe->mercator transition when zooming in), so it tells us which
                // coordinate space the model matrix needs to be built in.
                const isGlobe = args.defaultProjectionData.projectionTransition > 0;
                const l = isGlobe ?
                    getGlobeModelMatrix(modelOrigin, modelAltitude) :
                    getMercatorModelMatrix(modelOrigin, modelAltitude);
                l.scale(new THREE.Vector3(scaling, scaling, scaling));

                const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
                this.camera.projectionMatrix = m.multiply(l);
                this.renderer.resetState();
                this.renderer.render(this.scene, this.camera);
                this.map.triggerRepaint();
            }
        };

        map.on('style.load', () => {
            map.addLayer(customLayer);
        });
    </script>
</body>

</html>