Add 3D tiles using three.js
Use a custom style layer with three.js to add 3D tiles to the map.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Add 3D tiles using three.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">
<link rel='stylesheet' href='https://unpkg.com/maplibre-gl@5.23.0/dist/maplibre-gl.css' />
<script src='https://unpkg.com/maplibre-gl@5.23.0/dist/maplibre-gl.js'></script>
<style>
body { margin: 0; padding: 0; }
html, body, #map { height: 100%; }
</style>
</head>
<body>
<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>
<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";
let scene,camera,renderer,mapInstance,tiles,tilesCamera;
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/bright',
zoom: 1,
center: [0, 0],
pitch: 60,
maxPitch: 80,
canvasContextAttributes: {antialias: true}
});
// Convert Cartesian coordinates to latitude and longitude
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,
};
};
/**
* Load 3D model
* @param {string} url Model URL
* @param {number} altOffset Model altitude offset (set appropriate value to align with the ground)
*/
async function load3dtiles(url, altOffset = 0) {
let localTransform;
function getModelTransform(coord, rotate= [Math.PI / 2, 0, 0]) {
const modelAsMercatorCoordinate = maplibregl.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);
}
// Initialize tiles
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;
// Adjust model matrix
const loadTileSet = () => {
if (loadedTileSetHandled) {
tiles?.removeEventListener("load-tileset", loadTileSet);
return;
}
const scale = 1;
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];
// Get matrix
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);
// Update matrix
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) {
// Update camera matrix and render
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>