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.
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.