Skip to content

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.

javascript
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:

html
<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):

javascript
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:

javascript
.scale(new THREE.Vector3(10_000, 10_000, 10_000))

Toggle Globe vs Mercator ​

javascript
const current = map.getProjection();
map.setProjection({
  type: current.type === 'globe' ? 'mercator' : 'globe'
});

Complete Example ​

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