Skip to content
Add a 3D Model Using three.js

Add a 3D Model Using three.js in Maptoolkit Maps JS

Placing a GLTF model at a real-world location on a Maptoolkit map requires a custom layer that connects three.js to the map’s WebGL context. The model is positioned using geographic coordinates converted to the map’s Mercator projection, so it stays locked to the correct spot as the camera moves. Use this technique to overlay architectural models, vehicle assets, or any 3D object at a specific address or point of interest.

{
            "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 = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        zoom: 18,
        center: [11.39085, 47.27574],
        pitch: 60,
        attributionControl: { compact: false },
        canvasContextAttributes: { antialias: true }
    });

    // terrainControl:false matters here. Left on, the control switches 3D terrain on the
    // first time the map is tilted, and this custom layer draws at a fixed altitude, so the
    // ground would rise through the scene.
    map.addControl(new maptoolkit.NavigationControl({ terrainControl: false }), 'top-right');

    const modelOrigin = [11.39085, 47.27574];
    const modelAltitude = 0;
    const modelRotate = [Math.PI / 2, 0, 0];

    const modelAsMercatorCoordinate = maptoolkit.MercatorCoordinate.fromLngLat(modelOrigin, modelAltitude);

    const modelTransform = {
        translateX: modelAsMercatorCoordinate.x,
        translateY: modelAsMercatorCoordinate.y,
        translateZ: modelAsMercatorCoordinate.z,
        rotateX: modelRotate[0],
        rotateY: modelRotate[1],
        rotateZ: modelRotate[2],
        scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits()
    };

    const customLayer = {
        id: '3d-model',
        type: 'custom',
        renderingMode: '3d',
        onAdd(map, gl) {
            this.camera = new THREE.Camera();
            this.scene = new THREE.Scene();

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

            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;

            this.renderer = new THREE.WebGLRenderer({
                canvas: map.getCanvas(),
                context: gl,
                antialias: true
            });
            this.renderer.autoClear = false;
        },
        render(gl, args) {
            const rotationX = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), modelTransform.rotateX);
            const rotationY = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 1, 0), modelTransform.rotateY);
            const rotationZ = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 0, 1), modelTransform.rotateZ);

            const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
            const l = new THREE.Matrix4()
                .makeTranslation(modelTransform.translateX, modelTransform.translateY, modelTransform.translateZ)
                .scale(new THREE.Vector3(modelTransform.scale, -modelTransform.scale, modelTransform.scale))
                .multiply(rotationX).multiply(rotationY).multiply(rotationZ);

            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 Using three.js - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Use a custom style layer with three.js to add a 3D model to the map." />
    <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.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/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 = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        zoom: 18,
        center: [11.39085, 47.27574],
        pitch: 60,
        attributionControl: { compact: false },
        canvasContextAttributes: { antialias: true }
    });

    // terrainControl:false matters here. Left on, the control switches 3D terrain on the
    // first time the map is tilted, and this custom layer draws at a fixed altitude, so the
    // ground would rise through the scene.
    map.addControl(new maptoolkit.NavigationControl({ terrainControl: false }), 'top-right');

    const modelOrigin = [11.39085, 47.27574];
    const modelAltitude = 0;
    const modelRotate = [Math.PI / 2, 0, 0];

    const modelAsMercatorCoordinate = maptoolkit.MercatorCoordinate.fromLngLat(modelOrigin, modelAltitude);

    const modelTransform = {
        translateX: modelAsMercatorCoordinate.x,
        translateY: modelAsMercatorCoordinate.y,
        translateZ: modelAsMercatorCoordinate.z,
        rotateX: modelRotate[0],
        rotateY: modelRotate[1],
        rotateZ: modelRotate[2],
        scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits()
    };

    const customLayer = {
        id: '3d-model',
        type: 'custom',
        renderingMode: '3d',
        onAdd(map, gl) {
            this.camera = new THREE.Camera();
            this.scene = new THREE.Scene();

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

            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;

            this.renderer = new THREE.WebGLRenderer({
                canvas: map.getCanvas(),
                context: gl,
                antialias: true
            });
            this.renderer.autoClear = false;
        },
        render(gl, args) {
            const rotationX = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), modelTransform.rotateX);
            const rotationY = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 1, 0), modelTransform.rotateY);
            const rotationZ = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 0, 1), modelTransform.rotateZ);

            const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
            const l = new THREE.Matrix4()
                .makeTranslation(modelTransform.translateX, modelTransform.translateY, modelTransform.translateZ)
                .scale(new THREE.Vector3(modelTransform.scale, -modelTransform.scale, modelTransform.scale))
                .multiply(rotationX).multiply(rotationY).multiply(rotationZ);

            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>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive map with zoom level 18, centered around [11.39085, 47.27574], with pitch 60. Add a 3D model using three.js from https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf.

How it works

A custom layer is the escape hatch: type: 'custom' with renderingMode: '3d' hands you the map’s own WebGL context in onAdd(map, gl), and the map calls your render(gl, matrix) on every frame.

Two details make it work rather than fight the map. The three.js renderer is constructed with canvas: map.getCanvas() and context: gl, so it draws into the map’s canvas instead of its own. And renderer.autoClear = false, so three.js does not wipe the map out from under your scene on each frame.

The matrix passed to render is the map’s current view-projection matrix. Assigning it to the camera is what keeps the model locked to its location while you pan, zoom and tilt, rather than floating over the screen.

The model is loaded with GLTFLoader and added to the three.js scene, then positioned by a transform built from a longitude, latitude and altitude. That transform converts geographic coordinates into the map’s internal coordinate space, which is why the model sits at a place on the ground rather than at an arbitrary point in the scene.

Two directional lights are added from opposite sides. A GLTF model lit from one direction reads as a flat silhouette from the other, which is very visible once the user rotates the map.

Next steps

Getting one model placed is the hard part; the interesting version is many. That means loading once and reusing the geometry across instances rather than loading per position, which is where three.js instancing earns its keep. Adding a shadow is the other step that makes a model look placed rather than floating.

Lighting is the other thing that separates a placed model from a convincing one. Matching the light direction to the map’s own hillshading, and to the time of day if the map shows one, does more for believability than model detail does.