Adding 3D Models Using three.js on Terrain in Maptoolkit Maps JS
When 3D terrain is active, the map’s surface is no longer flat, which means you need to account for elevation when positioning three.js models. This example uses the map’s queryTerrainElevation method to get the terrain height at a coordinate and offset the model’s vertical position accordingly. Use this technique to place buildings, vehicles, or markers on a terrain-aware 3D map without having them float above or sink into the ground.
{
"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';
async function main() {
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
center: [11.5257, 47.668],
zoom: 16.27,
pitch: 60,
bearing: -28.5,
attributionControl: { compact: false },
canvasContextAttributes: { antialias: true },
style: {
version: 8,
layers: [
{
id: 'baseColor',
type: 'background',
paint: { 'background-color': '#fff', 'background-opacity': 1.0 },
},
{
id: 'hills',
type: 'hillshade',
source: 'hillshadeSource',
layout: { visibility: 'visible' },
paint: { 'hillshade-shadow-color': '#473B24' }
}
],
terrain: { source: 'terrainSource', exaggeration: 1 },
sources: {
terrainSource: {
type: 'raster-dem',
tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
tileSize: 256,
minzoom: 5,
maxzoom: 12,
encoding: 'terrarium'
},
hillshadeSource: {
type: 'raster-dem',
tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
tileSize: 256,
minzoom: 5,
maxzoom: 12,
encoding: 'terrarium'
}
},
}
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
function calculateDistanceMercatorToMeters(from, to) {
const mercatorPerMeter = from.meterInMercatorCoordinateUnits();
const dEast = to.x - from.x;
const dEastMeter = dEast / mercatorPerMeter;
const dNorth = from.y - to.y;
const dNorthMeter = dNorth / mercatorPerMeter;
return { dEastMeter, dNorthMeter };
}
async function loadModel() {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf');
return gltf.scene;
}
const sceneOrigin = new maptoolkit.LngLat(11.5255, 47.6677);
const model1Location = new maptoolkit.LngLat(11.527, 47.6678);
const model2Location = new maptoolkit.LngLat(11.5249, 47.6676);
const customLayer = {
id: '3d-model',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
this.scene.rotateX(Math.PI / 2);
this.scene.scale.multiply(new THREE.Vector3(1, 1, -1));
const light = new THREE.DirectionalLight(0xffffff);
light.position.set(50, 70, -30).normalize();
this.scene.add(light);
const axesHelper = new THREE.AxesHelper(60);
this.scene.add(axesHelper);
const sceneElevation = map.queryTerrainElevation(sceneOrigin) || 0;
const model1Elevation = map.queryTerrainElevation(model1Location) || 0;
const model2Elevation = map.queryTerrainElevation(model2Location) || 0;
const model1up = model1Elevation - sceneElevation;
const model2up = model2Elevation - sceneElevation;
const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin);
const model1Mercator = maptoolkit.MercatorCoordinate.fromLngLat(model1Location);
const model2Mercator = maptoolkit.MercatorCoordinate.fromLngLat(model2Location);
const { dEastMeter: model1east, dNorthMeter: model1north } = calculateDistanceMercatorToMeters(sceneOriginMercator, model1Mercator);
const { dEastMeter: model2east, dNorthMeter: model2north } = calculateDistanceMercatorToMeters(sceneOriginMercator, model2Mercator);
model1.position.set(model1east, model1up, model1north);
model2.position.set(model2east, model2up, model2north);
this.scene.add(model1);
this.scene.add(model2);
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.autoClear = false;
},
render(gl, args) {
const offsetFromCenterElevation = map.queryTerrainElevation(sceneOrigin) || 0;
const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin, offsetFromCenterElevation);
const sceneTransform = {
translateX: sceneOriginMercator.x,
translateY: sceneOriginMercator.y,
translateZ: sceneOriginMercator.z,
scale: sceneOriginMercator.meterInMercatorCoordinateUnits()
};
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4()
.makeTranslation(sceneTransform.translateX, sceneTransform.translateY, sceneTransform.translateZ)
.scale(new THREE.Vector3(sceneTransform.scale, -sceneTransform.scale, sceneTransform.scale));
this.camera.projectionMatrix = m.multiply(l);
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
map.triggerRepaint();
}
};
const results = await Promise.all([map.once('load'), loadModel()]);
const model1 = results[1];
const model2 = model1.clone();
map.addLayer(customLayer);
}
main();<!DOCTYPE html>
<html lang="en">
<head>
<title>Adding 3D Models Using three.js on Terrain - Maptoolkit Maps JS</title>
<meta property="og:description" content="Use a custom style layer with three.js to add 3D models to a map with 3D terrain." />
<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';
async function main() {
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
center: [11.5257, 47.668],
zoom: 16.27,
pitch: 60,
bearing: -28.5,
attributionControl: { compact: false },
canvasContextAttributes: { antialias: true },
style: {
version: 8,
layers: [
{
id: 'baseColor',
type: 'background',
paint: { 'background-color': '#fff', 'background-opacity': 1.0 },
},
{
id: 'hills',
type: 'hillshade',
source: 'hillshadeSource',
layout: { visibility: 'visible' },
paint: { 'hillshade-shadow-color': '#473B24' }
}
],
terrain: { source: 'terrainSource', exaggeration: 1 },
sources: {
terrainSource: {
type: 'raster-dem',
tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
tileSize: 256,
minzoom: 5,
maxzoom: 12,
encoding: 'terrarium'
},
hillshadeSource: {
type: 'raster-dem',
tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
tileSize: 256,
minzoom: 5,
maxzoom: 12,
encoding: 'terrarium'
}
},
}
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
function calculateDistanceMercatorToMeters(from, to) {
const mercatorPerMeter = from.meterInMercatorCoordinateUnits();
const dEast = to.x - from.x;
const dEastMeter = dEast / mercatorPerMeter;
const dNorth = from.y - to.y;
const dNorthMeter = dNorth / mercatorPerMeter;
return { dEastMeter, dNorthMeter };
}
async function loadModel() {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf');
return gltf.scene;
}
const sceneOrigin = new maptoolkit.LngLat(11.5255, 47.6677);
const model1Location = new maptoolkit.LngLat(11.527, 47.6678);
const model2Location = new maptoolkit.LngLat(11.5249, 47.6676);
const customLayer = {
id: '3d-model',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
this.scene.rotateX(Math.PI / 2);
this.scene.scale.multiply(new THREE.Vector3(1, 1, -1));
const light = new THREE.DirectionalLight(0xffffff);
light.position.set(50, 70, -30).normalize();
this.scene.add(light);
const axesHelper = new THREE.AxesHelper(60);
this.scene.add(axesHelper);
const sceneElevation = map.queryTerrainElevation(sceneOrigin) || 0;
const model1Elevation = map.queryTerrainElevation(model1Location) || 0;
const model2Elevation = map.queryTerrainElevation(model2Location) || 0;
const model1up = model1Elevation - sceneElevation;
const model2up = model2Elevation - sceneElevation;
const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin);
const model1Mercator = maptoolkit.MercatorCoordinate.fromLngLat(model1Location);
const model2Mercator = maptoolkit.MercatorCoordinate.fromLngLat(model2Location);
const { dEastMeter: model1east, dNorthMeter: model1north } = calculateDistanceMercatorToMeters(sceneOriginMercator, model1Mercator);
const { dEastMeter: model2east, dNorthMeter: model2north } = calculateDistanceMercatorToMeters(sceneOriginMercator, model2Mercator);
model1.position.set(model1east, model1up, model1north);
model2.position.set(model2east, model2up, model2north);
this.scene.add(model1);
this.scene.add(model2);
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.autoClear = false;
},
render(gl, args) {
const offsetFromCenterElevation = map.queryTerrainElevation(sceneOrigin) || 0;
const sceneOriginMercator = maptoolkit.MercatorCoordinate.fromLngLat(sceneOrigin, offsetFromCenterElevation);
const sceneTransform = {
translateX: sceneOriginMercator.x,
translateY: sceneOriginMercator.y,
translateZ: sceneOriginMercator.z,
scale: sceneOriginMercator.meterInMercatorCoordinateUnits()
};
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4()
.makeTranslation(sceneTransform.translateX, sceneTransform.translateY, sceneTransform.translateZ)
.scale(new THREE.Vector3(sceneTransform.scale, -sceneTransform.scale, sceneTransform.scale));
this.camera.projectionMatrix = m.multiply(l);
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
map.triggerRepaint();
}
};
const results = await Promise.all([map.once('load'), loadModel()]);
const model1 = results[1];
const model2 = model1.clone();
map.addLayer(customLayer);
}
main();
</script>
</body>
</html>How it works
With 3D terrain enabled the ground is a mesh rather than a plane, so a model placed at altitude zero no longer sits on the surface: it sits at sea level, which on a mountainside means buried or floating.
The fix is to ask the map how high the ground is at that coordinate and use the answer as the model’s altitude. That query only works once the terrain source has loaded, so it cannot run at construction time.
Everything else is the standard custom layer: the three.js renderer shares the map’s canvas
and GL context, autoClear stays false, and the view-projection matrix is applied to the
camera each frame.
Depth is the detail people miss. With terrain on, a model behind a ridge should be hidden by it, which only happens if the scene is drawn with depth testing against the map’s own depth buffer rather than painted over the top.
Next steps
Once one model sits correctly on the surface, the useful version is many, each querying its own ground height. That is the shape of a hut network, a lift system or a set of markers that have to stay planted as the user tilts.
The step after is making the model react to the terrain rather than just resting on it: orienting to the slope, or hiding when the ground between the camera and the model rises in front of it.