Add 3D Tiles Using three.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/[email protected]/build/three.module.js",
"three/examples/jsm/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
"3d-tiles-renderer": "https://cdn.jsdelivr.net/npm/[email protected]/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 }
});
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/[email protected]/examples/jsm/libs/draco/');
gltfLoader.setDRACOLoader(dracoLoader);
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('https://unpkg.com/[email protected]/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/[email protected]/dist/maptoolkit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@maptoolkit/[email protected]/dist/maptoolkit.css" />
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"three/examples/jsm/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
"3d-tiles-renderer": "https://cdn.jsdelivr.net/npm/[email protected]/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 }
});
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/[email protected]/examples/jsm/libs/draco/');
gltfLoader.setDRACOLoader(dracoLoader);
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('https://unpkg.com/[email protected]/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.