Add a 3D Model using Three.js ​
Use a custom style layer with Three.js to load and render a 3D GLTF model directly on the map. The model is georeferenced using the map's own projection matrix, so it stays anchored to a real-world coordinate.
Three.js is loaded via CDN — no build step needed for this example.
How It Works ​
A custom layer gives you full access to the map's WebGL context. Three.js shares that context so both the map and your 3D model render on the same canvas.
const customLayer = {
id: '3d-model',
type: 'custom',
renderingMode: '3d', // required for correct depth buffer
onAdd(map, gl) {
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
// Load GLTF model
const loader = new GLTFLoader();
loader.load('path/to/model.gltf', (gltf) => {
this.scene.add(gltf.scene);
});
// Share the map's WebGL canvas and context
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.autoClear = false;
},
render(gl, args) {
// Georeference the model using map's projection matrix
const modelMatrix = map.transform.getMatrixForModel([lng, lat], altitude);
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4().fromArray(modelMatrix).scale(new THREE.Vector3(scale, scale, scale));
this.camera.projectionMatrix = m.multiply(l);
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
this.map.triggerRepaint(); // keep re-rendering
}
};
map.on('style.load', () => {
map.addLayer(customLayer);
});Import Three.js ​
Use an importmap to load Three.js and GLTFLoader from CDN — no npm needed:
<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>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// ... your code
</script>Georeference the Model ​
map.transform.getMatrixForModel() converts a [lng, lat] coordinate into the correct 4×4 matrix for the current projection (globe or Mercator):
const modelMatrix = map.transform.getMatrixForModel(
[148.9819, -35.39847], // [lng, lat]
0 // altitude in meters
);Scale the model up if needed — GLTF models are often designed at real-world scale (meters), but at global zoom you need a large multiplier:
.scale(new THREE.Vector3(10_000, 10_000, 10_000))Toggle Globe vs Mercator ​
const current = map.getProjection();
map.setProjection({
type: current.type === 'globe' ? 'mercator' : 'globe'
});Complete Example ​
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link href="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.css" rel="stylesheet" />
<script src="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.js"></script>
<style>body { margin: 0; } #map { height: 100vh; width: 100%; }</style>
<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>
</head>
<body>
<div id="map"></div>
<button id="btn-toggle" style="position:absolute;top:20px;left:50%;transform:translateX(-50%);padding:10px 20px;background:#3b82f6;color:white;border:none;border-radius:6px;cursor:pointer;">
Toggle Globe / Mercator
</button>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const map = new mapmetricsgl.Map({
container: 'map',
style: '<StyleFile_URL_with_Token>',
zoom: 5.5,
center: [150.16546, -35.01717],
pitch: 70,
maxPitch: 80,
canvasContextAttributes: { antialias: true }
});
map.on('style.load', () => {
map.setProjection({ type: 'globe' });
});
document.getElementById('btn-toggle').addEventListener('click', () => {
const current = map.getProjection();
map.setProjection({ type: current.type === 'globe' ? 'mercator' : 'globe' });
});
const customLayer = {
id: '3d-model',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
this.map = map;
const light1 = new THREE.DirectionalLight(0xffffff);
light1.position.set(0, -70, 100).normalize();
this.scene.add(light1);
const light2 = new THREE.DirectionalLight(0xffffff);
light2.position.set(0, 70, 100).normalize();
this.scene.add(light2);
new GLTFLoader().load(
'/models/satellite_dish.glb',
(gltf) => { this.scene.add(gltf.scene); }
);
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.autoClear = false;
},
render(gl, args) {
const modelMatrix = map.transform.getMatrixForModel([148.9819, -35.39847], 0);
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4().fromArray(modelMatrix).scale(new THREE.Vector3(10000, 10000, 10000));
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>For more information, visit the MapMetrics GitHub repository.