Adding 3D Models Using three.js on Terrain
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/[email protected]/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/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://vtc-cdn.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://vtc-cdn.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/[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/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/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://vtc-cdn.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://vtc-cdn.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>