Skip to content
Add 3D Tiles Using three.js

Add 3D Tiles Using three.js in Maptoolkit Maps JS

Loading a 3D Tiles dataset into a Maptoolkit map requires a custom layer that bridges the map’s WebGL context with a three.js renderer. The renderer syncs its camera with the map’s projection matrix so 3D content stays aligned as you pan and zoom. Use this approach to display photogrammetry captures, city models, or any OGC 3D Tiles dataset alongside your existing map layers.

{
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
                "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/",
                "3d-tiles-renderer": "https://cdn.jsdelivr.net/npm/3d-tiles-renderer@0.4.21/build/index.three.js"
            }
        }

import * as THREE from 'three';
    import { TilesRenderer } from '3d-tiles-renderer';
    import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
    import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
    import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';

    const API_KEY = 'YOUR_API_KEY';

    let scene, camera, renderer, mapInstance, tiles, tilesCamera;

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        zoom: 1,
        center: [0, 0],
        pitch: 60,
        maxPitch: 80,
        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');

    function ecefToLngLatAlt(x, y, z) {
        const a = 6378137.0;
        const e2 = 6.69437999014e-3;
        const b = a * Math.sqrt(1 - e2);
        const ep2 = (a * a - b * b) / (b * b);
        const p = Math.sqrt(x * x + y * y);
        const th = Math.atan2(a * z, b * p);
        const lon = Math.atan2(y, x);
        const lat = Math.atan2(z + ep2 * b * Math.pow(Math.sin(th), 3), p - e2 * a * Math.pow(Math.cos(th), 3));
        const n = a / Math.sqrt(1 - e2 * Math.sin(lat) * Math.sin(lat));
        const alt = p / Math.cos(lat) - n;
        return { lng: (lon * 180) / Math.PI, lat: (lat * 180) / Math.PI, alt };
    }

    async function load3dtiles(url, altOffset = 0) {
        let localTransform;

        function getModelTransform(coord, rotate = [Math.PI / 2, 0, 0]) {
            const modelAsMercatorCoordinate = maptoolkit.MercatorCoordinate.fromLngLat([coord[0], coord[1]], coord[2]);
            return {
                translateX: modelAsMercatorCoordinate.x,
                translateY: modelAsMercatorCoordinate.y,
                translateZ: modelAsMercatorCoordinate.z,
                rotateX: rotate[0], rotateY: rotate[1], rotateZ: rotate[2],
                scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits(),
            };
        }

        function updateLocalTransform(modelOrigin = [0, 0, 0]) {
            const modelTransform = getModelTransform(modelOrigin);
            const axisX = new THREE.Vector3(1, 0, 0);
            const axisY = new THREE.Vector3(0, 1, 0);
            const axisZ = new THREE.Vector3(0, 0, 1);
            const rotationX = new THREE.Matrix4().makeRotationAxis(axisX, modelTransform.rotateX);
            const rotationY = new THREE.Matrix4().makeRotationAxis(axisY, modelTransform.rotateY);
            const rotationZ = new THREE.Matrix4().makeRotationAxis(axisZ, modelTransform.rotateZ);
            const scaleVec = new THREE.Vector3(modelTransform.scale, -modelTransform.scale, modelTransform.scale);
            localTransform = new THREE.Matrix4()
                .makeTranslation(modelTransform.translateX, modelTransform.translateY, modelTransform.translateZ)
                .scale(scaleVec)
                .multiply(rotationX).multiply(rotationY).multiply(rotationZ);
        }

        function initTiles(url, sceneInst, cameraInst, rendererInst) {
            const gltfLoader = new GLTFLoader();
            const dracoLoader = new DRACOLoader();
            dracoLoader.setDecoderPath('https://unpkg.com/three@0.183.0/examples/jsm/libs/draco/');
            gltfLoader.setDRACOLoader(dracoLoader);
            const ktx2Loader = new KTX2Loader();
            ktx2Loader.setTranscoderPath('https://unpkg.com/three@0.183.0/examples/jsm/libs/basis/');
            ktx2Loader.detectSupport(rendererInst);
            gltfLoader.setKTX2Loader(ktx2Loader);

            tiles = new TilesRenderer(url);
            tiles.group.name = 'tiles';
            sceneInst.add(tiles.group);
            tiles.setCamera(cameraInst);
            tiles.setResolutionFromRenderer(cameraInst, rendererInst);
            tiles.manager.addHandler(/\.(gltf|glb)$/g, gltfLoader);

            let loadedTileSetHandled = false;
            const loadTileSet = () => {
                if (loadedTileSetHandled) { tiles?.removeEventListener('load-tileset', loadTileSet); return; }
                const sphere = new THREE.Sphere();
                tiles.getBoundingSphere(sphere);
                const center = sphere.center.clone();
                const root = tiles.root;
                let m = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
                if (root.transform) m = root.transform;
                loadedTileSetHandled = true;
                const { lng, lat, alt } = ecefToLngLatAlt(center.x, center.y, center.z);
                map.jumpTo({ center: [lng, lat], zoom: 18, pitch: 60 });
                updateLocalTransform([lng, lat, alt + altOffset]);
                const rotationMat3 = new THREE.Matrix3().set(m[0], m[1], m[2], m[8], m[9], m[10], -m[4], -m[5], -m[6]);
                const rotationMat4 = new THREE.Matrix4().setFromMatrix3(rotationMat3);
                const moveToOrigin = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
                const finalMatrix = new THREE.Matrix4().multiplyMatrices(rotationMat4, moveToOrigin);
                tiles.group.matrix.copy(finalMatrix);
                tiles.group.matrixAutoUpdate = false;
                tiles.group.updateMatrixWorld(true);
            };
            tiles.addEventListener('load-tileset', loadTileSet);
            updateLocalTransform();
        }

        const customLayer = {
            id: '3d-tiles',
            type: 'custom',
            renderingMode: '3d',
            onAdd(mapArg, gl) {
                camera = new THREE.PerspectiveCamera();
                scene = new THREE.Scene();
                const ambientLight = new THREE.AmbientLight(0xffffff, 3);
                scene.add(ambientLight);
                mapInstance = mapArg;
                const canvas = mapArg.getCanvas();
                renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true });
                renderer.autoClear = false;
                tilesCamera = new THREE.PerspectiveCamera();
                initTiles(url, scene, tilesCamera, renderer);
            },
            render(_gl, args) {
                if (!camera || !renderer || !scene || !localTransform || !tilesCamera) return;
                camera.projectionMatrix.fromArray(args.defaultProjectionData.mainMatrix);
                camera.projectionMatrix.multiply(localTransform);
                const P = new THREE.Matrix4().fromArray(args.projectionMatrix);
                const invP = P.clone().invert();
                const V = new THREE.Matrix4().multiplyMatrices(invP, camera.projectionMatrix);
                tilesCamera.projectionMatrix.copy(P);
                tilesCamera.matrixWorldInverse.copy(V);
                tilesCamera.matrixWorld.copy(V).invert();
                renderer.resetState();
                renderer.render(scene, camera);
                if (tiles) tiles.update();
                mapInstance?.triggerRepaint();
            },
        };

        await map.once('style.load');
        map.addLayer(customLayer);
    }

    load3dtiles('https://pelican-public.s3.amazonaws.com/3dtiles/agi-hq/tileset.json', -300);
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Add 3D Tiles Using three.js - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Use a custom style layer with three.js to add 3D tiles 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.183.0/build/three.module.js",
                "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/",
                "3d-tiles-renderer": "https://cdn.jsdelivr.net/npm/3d-tiles-renderer@0.4.21/build/index.three.js"
            }
        }
    </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 { TilesRenderer } from '3d-tiles-renderer';
    import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
    import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
    import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';

    const API_KEY = 'YOUR_API_KEY';

    let scene, camera, renderer, mapInstance, tiles, tilesCamera;

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        zoom: 1,
        center: [0, 0],
        pitch: 60,
        maxPitch: 80,
        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');

    function ecefToLngLatAlt(x, y, z) {
        const a = 6378137.0;
        const e2 = 6.69437999014e-3;
        const b = a * Math.sqrt(1 - e2);
        const ep2 = (a * a - b * b) / (b * b);
        const p = Math.sqrt(x * x + y * y);
        const th = Math.atan2(a * z, b * p);
        const lon = Math.atan2(y, x);
        const lat = Math.atan2(z + ep2 * b * Math.pow(Math.sin(th), 3), p - e2 * a * Math.pow(Math.cos(th), 3));
        const n = a / Math.sqrt(1 - e2 * Math.sin(lat) * Math.sin(lat));
        const alt = p / Math.cos(lat) - n;
        return { lng: (lon * 180) / Math.PI, lat: (lat * 180) / Math.PI, alt };
    }

    async function load3dtiles(url, altOffset = 0) {
        let localTransform;

        function getModelTransform(coord, rotate = [Math.PI / 2, 0, 0]) {
            const modelAsMercatorCoordinate = maptoolkit.MercatorCoordinate.fromLngLat([coord[0], coord[1]], coord[2]);
            return {
                translateX: modelAsMercatorCoordinate.x,
                translateY: modelAsMercatorCoordinate.y,
                translateZ: modelAsMercatorCoordinate.z,
                rotateX: rotate[0], rotateY: rotate[1], rotateZ: rotate[2],
                scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits(),
            };
        }

        function updateLocalTransform(modelOrigin = [0, 0, 0]) {
            const modelTransform = getModelTransform(modelOrigin);
            const axisX = new THREE.Vector3(1, 0, 0);
            const axisY = new THREE.Vector3(0, 1, 0);
            const axisZ = new THREE.Vector3(0, 0, 1);
            const rotationX = new THREE.Matrix4().makeRotationAxis(axisX, modelTransform.rotateX);
            const rotationY = new THREE.Matrix4().makeRotationAxis(axisY, modelTransform.rotateY);
            const rotationZ = new THREE.Matrix4().makeRotationAxis(axisZ, modelTransform.rotateZ);
            const scaleVec = new THREE.Vector3(modelTransform.scale, -modelTransform.scale, modelTransform.scale);
            localTransform = new THREE.Matrix4()
                .makeTranslation(modelTransform.translateX, modelTransform.translateY, modelTransform.translateZ)
                .scale(scaleVec)
                .multiply(rotationX).multiply(rotationY).multiply(rotationZ);
        }

        function initTiles(url, sceneInst, cameraInst, rendererInst) {
            const gltfLoader = new GLTFLoader();
            const dracoLoader = new DRACOLoader();
            dracoLoader.setDecoderPath('https://unpkg.com/three@0.183.0/examples/jsm/libs/draco/');
            gltfLoader.setDRACOLoader(dracoLoader);
            const ktx2Loader = new KTX2Loader();
            ktx2Loader.setTranscoderPath('https://unpkg.com/three@0.183.0/examples/jsm/libs/basis/');
            ktx2Loader.detectSupport(rendererInst);
            gltfLoader.setKTX2Loader(ktx2Loader);

            tiles = new TilesRenderer(url);
            tiles.group.name = 'tiles';
            sceneInst.add(tiles.group);
            tiles.setCamera(cameraInst);
            tiles.setResolutionFromRenderer(cameraInst, rendererInst);
            tiles.manager.addHandler(/\.(gltf|glb)$/g, gltfLoader);

            let loadedTileSetHandled = false;
            const loadTileSet = () => {
                if (loadedTileSetHandled) { tiles?.removeEventListener('load-tileset', loadTileSet); return; }
                const sphere = new THREE.Sphere();
                tiles.getBoundingSphere(sphere);
                const center = sphere.center.clone();
                const root = tiles.root;
                let m = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
                if (root.transform) m = root.transform;
                loadedTileSetHandled = true;
                const { lng, lat, alt } = ecefToLngLatAlt(center.x, center.y, center.z);
                map.jumpTo({ center: [lng, lat], zoom: 18, pitch: 60 });
                updateLocalTransform([lng, lat, alt + altOffset]);
                const rotationMat3 = new THREE.Matrix3().set(m[0], m[1], m[2], m[8], m[9], m[10], -m[4], -m[5], -m[6]);
                const rotationMat4 = new THREE.Matrix4().setFromMatrix3(rotationMat3);
                const moveToOrigin = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
                const finalMatrix = new THREE.Matrix4().multiplyMatrices(rotationMat4, moveToOrigin);
                tiles.group.matrix.copy(finalMatrix);
                tiles.group.matrixAutoUpdate = false;
                tiles.group.updateMatrixWorld(true);
            };
            tiles.addEventListener('load-tileset', loadTileSet);
            updateLocalTransform();
        }

        const customLayer = {
            id: '3d-tiles',
            type: 'custom',
            renderingMode: '3d',
            onAdd(mapArg, gl) {
                camera = new THREE.PerspectiveCamera();
                scene = new THREE.Scene();
                const ambientLight = new THREE.AmbientLight(0xffffff, 3);
                scene.add(ambientLight);
                mapInstance = mapArg;
                const canvas = mapArg.getCanvas();
                renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true });
                renderer.autoClear = false;
                tilesCamera = new THREE.PerspectiveCamera();
                initTiles(url, scene, tilesCamera, renderer);
            },
            render(_gl, args) {
                if (!camera || !renderer || !scene || !localTransform || !tilesCamera) return;
                camera.projectionMatrix.fromArray(args.defaultProjectionData.mainMatrix);
                camera.projectionMatrix.multiply(localTransform);
                const P = new THREE.Matrix4().fromArray(args.projectionMatrix);
                const invP = P.clone().invert();
                const V = new THREE.Matrix4().multiplyMatrices(invP, camera.projectionMatrix);
                tilesCamera.projectionMatrix.copy(P);
                tilesCamera.matrixWorldInverse.copy(V);
                tilesCamera.matrixWorld.copy(V).invert();
                renderer.resetState();
                renderer.render(scene, camera);
                if (tiles) tiles.update();
                mapInstance?.triggerRepaint();
            },
        };

        await map.once('style.load');
        map.addLayer(customLayer);
    }

    load3dtiles('https://pelican-public.s3.amazonaws.com/3dtiles/agi-hq/tileset.json', -300);
</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 pitch 60. Load 3D tiles using three.js from https://pelican-public.s3.amazonaws.com/3dtiles/agi-hq/tileset.json and center the map on the tileset.

How it works

3D Tiles is an OGC format for streaming large 3D datasets, city models and photogrammetry meshes, as a tree of tiles that load by distance and screen size rather than all at once. Maps JS has no native 3D Tiles support, so this bridges to a three.js loader through a custom layer.

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 loader needs the camera each frame to decide which tiles to fetch, which is the part that differs from loading a single GLTF model. A static model is loaded once; a 3D Tiles set is continuously re-evaluated as the view changes, so the render callback both draws and drives loading.

Expect the dataset, not the map, to dominate performance here.

Next steps

The next decision is which dataset, because 3D Tiles is a container rather than a source. City models, photogrammetry meshes and scanned interiors all ship in it, and they differ enormously in size and in licence terms.

If the goal is buildings specifically, check whether extruded footprints from the vector tiles get you there first. They come from the vector tiles you already load, cost almost nothing, and cover far more of the world than any photogrammetry set does.