Skip to content
Add 3D Tiles Using three.js

Add 3D Tiles Using three.js

Use a custom style layer with three.js to add 3D tiles to the map.

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

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

    const map = new maptoolkit.Map({
        container: 'map',
        style: `https://static.maptoolkit.net/styles/toursprung/terrain.json?api_key=${API_KEY}`,
        zoom: 1,
        center: [0, 0],
        pitch: 60,
        maxPitch: 80,
        attributionControl: { compact: false },
        canvasContextAttributes: { antialias: true }
    });

    map.addControl(new maptoolkit.NavigationControl(), '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.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.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 = 'toursprung';

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

    const map = new maptoolkit.Map({
        container: 'map',
        style: `https://static.maptoolkit.net/styles/toursprung/terrain.json?api_key=${API_KEY}`,
        zoom: 1,
        center: [0, 0],
        pitch: 60,
        maxPitch: 80,
        attributionControl: { compact: false },
        canvasContextAttributes: { antialias: true }
    });

    map.addControl(new maptoolkit.NavigationControl(), '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>