Place Many 3D Models Using three.js in Maptoolkit Maps JS
Getting one model onto the map is the hard part; the interesting version is many. Loading the file once and drawing it at every position with an instanced mesh keeps a few hundred objects at full frame rate, where a scene per position does not. This example places a model across a grid of coordinates from a single draw call.
{
"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 ORIGIN = [11.39085, 47.27574];
const GRID = 8; // 8 x 8 instances
const SPACING_M = 90; // metres between them
const TARGET_HEIGHT_M = 45; // scale whatever model is loaded to this height
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: ORIGIN,
zoom: 15.5,
pitch: 60,
bearing: -20,
antialias: true,
attributionControl: { compact: false }
});
// 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({ visualizePitch: true, terrainControl: false }), 'top-right');
// Everything is positioned relative to one origin, in metres, inside the scene.
const originMercator = maptoolkit.MercatorCoordinate.fromLngLat(ORIGIN, 0);
const scale = originMercator.meterInMercatorCoordinateUnits();
const customLayer = {
id: 'models',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.map = map;
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
this.scene.add(new THREE.AmbientLight(0xffffff, 1.4));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(-70, 100, -70);
this.scene.add(sun);
new GLTFLoader().load(
'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
(gltf) => {
// Bake every node's transform into its geometry, so the meshes can be
// detached from the scene graph and instanced independently.
gltf.scene.updateMatrixWorld(true);
const meshes = [];
gltf.scene.traverse((child) => {
if (!child.isMesh) return;
const geometry = child.geometry.clone();
geometry.applyMatrix4(child.matrixWorld);
meshes.push({ geometry, material: child.material });
});
if (!meshes.length) return;
// Normalise: a glTF is authored in its own units, which are rarely metres.
const bounds = new THREE.Box3();
for (const m of meshes) {
m.geometry.computeBoundingBox();
bounds.union(m.geometry.boundingBox);
}
const size = bounds.getSize(new THREE.Vector3());
const centre = bounds.getCenter(new THREE.Vector3());
const unitScale = TARGET_HEIGHT_M / size.y;
for (const m of meshes) {
// Centre horizontally and put the base on the ground, then scale.
m.geometry.translate(-centre.x, -bounds.min.y, -centre.z);
m.geometry.scale(unitScale, unitScale, unitScale);
}
// One matrix per position, shared by every mesh of the model.
const matrices = [];
for (let x = 0; x < GRID; x++) {
for (let y = 0; y < GRID; y++) {
const matrix = new THREE.Matrix4();
matrix.makeRotationY((x + y) * 0.25);
matrix.setPosition(
(x - (GRID - 1) / 2) * SPACING_M,
0,
(y - (GRID - 1) / 2) * SPACING_M
);
matrices.push(matrix);
}
}
// One InstancedMesh per mesh in the model, not per position.
for (const m of meshes) {
const instanced = new THREE.InstancedMesh(m.geometry, m.material, matrices.length);
matrices.forEach((matrix, i) => instanced.setMatrixAt(i, matrix));
instanced.instanceMatrix.needsUpdate = true;
// The camera's projection matrix is supplied by MapLibre, so three.js
// cannot derive a correct frustum from it. Left on, it culls the
// instances at some tilt and bearing angles and they vanish.
instanced.frustumCulled = false;
this.scene.add(instanced);
}
document.getElementById('count').textContent =
`${matrices.length} instances, ${meshes.length} draw call${meshes.length > 1 ? 's' : ''}`;
map.triggerRepaint();
}
);
this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl, antialias: true });
this.renderer.autoClear = false;
},
render(gl, args) {
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4()
.makeTranslation(originMercator.x, originMercator.y, originMercator.z)
.scale(new THREE.Vector3(scale, -scale, scale))
.multiply(new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2));
this.camera.projectionMatrix = m.multiply(l);
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
}
};
map.on('style.load', () => map.addLayer(customLayer));<!DOCTYPE html>
<html lang="en">
<head>
<title>Many 3D Models - Maptoolkit Maps JS</title>
<meta property="og:description" content="Draw one model at many positions with instancing." />
<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%; }
#count {
position: absolute; top: 10px; left: 10px; z-index: 999;
background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
font: 13px/1.5 system-ui, sans-serif; padding: 8px 12px;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="count"></div>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const API_KEY = 'YOUR_API_KEY';
const ORIGIN = [11.39085, 47.27574];
const GRID = 8; // 8 x 8 instances
const SPACING_M = 90; // metres between them
const TARGET_HEIGHT_M = 45; // scale whatever model is loaded to this height
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: ORIGIN,
zoom: 15.5,
pitch: 60,
bearing: -20,
antialias: true,
attributionControl: { compact: false }
});
// 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({ visualizePitch: true, terrainControl: false }), 'top-right');
// Everything is positioned relative to one origin, in metres, inside the scene.
const originMercator = maptoolkit.MercatorCoordinate.fromLngLat(ORIGIN, 0);
const scale = originMercator.meterInMercatorCoordinateUnits();
const customLayer = {
id: 'models',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.map = map;
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
this.scene.add(new THREE.AmbientLight(0xffffff, 1.4));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(-70, 100, -70);
this.scene.add(sun);
new GLTFLoader().load(
'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
(gltf) => {
// Bake every node's transform into its geometry, so the meshes can be
// detached from the scene graph and instanced independently.
gltf.scene.updateMatrixWorld(true);
const meshes = [];
gltf.scene.traverse((child) => {
if (!child.isMesh) return;
const geometry = child.geometry.clone();
geometry.applyMatrix4(child.matrixWorld);
meshes.push({ geometry, material: child.material });
});
if (!meshes.length) return;
// Normalise: a glTF is authored in its own units, which are rarely metres.
const bounds = new THREE.Box3();
for (const m of meshes) {
m.geometry.computeBoundingBox();
bounds.union(m.geometry.boundingBox);
}
const size = bounds.getSize(new THREE.Vector3());
const centre = bounds.getCenter(new THREE.Vector3());
const unitScale = TARGET_HEIGHT_M / size.y;
for (const m of meshes) {
// Centre horizontally and put the base on the ground, then scale.
m.geometry.translate(-centre.x, -bounds.min.y, -centre.z);
m.geometry.scale(unitScale, unitScale, unitScale);
}
// One matrix per position, shared by every mesh of the model.
const matrices = [];
for (let x = 0; x < GRID; x++) {
for (let y = 0; y < GRID; y++) {
const matrix = new THREE.Matrix4();
matrix.makeRotationY((x + y) * 0.25);
matrix.setPosition(
(x - (GRID - 1) / 2) * SPACING_M,
0,
(y - (GRID - 1) / 2) * SPACING_M
);
matrices.push(matrix);
}
}
// One InstancedMesh per mesh in the model, not per position.
for (const m of meshes) {
const instanced = new THREE.InstancedMesh(m.geometry, m.material, matrices.length);
matrices.forEach((matrix, i) => instanced.setMatrixAt(i, matrix));
instanced.instanceMatrix.needsUpdate = true;
// The camera's projection matrix is supplied by MapLibre, so three.js
// cannot derive a correct frustum from it. Left on, it culls the
// instances at some tilt and bearing angles and they vanish.
instanced.frustumCulled = false;
this.scene.add(instanced);
}
document.getElementById('count').textContent =
`${matrices.length} instances, ${meshes.length} draw call${meshes.length > 1 ? 's' : ''}`;
map.triggerRepaint();
}
);
this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl, antialias: true });
this.renderer.autoClear = false;
},
render(gl, args) {
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4()
.makeTranslation(originMercator.x, originMercator.y, originMercator.z)
.scale(new THREE.Vector3(scale, -scale, scale))
.multiply(new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2));
this.camera.projectionMatrix = m.multiply(l);
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
}
};
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
InstancedMesh is the whole point. One geometry, one material and a matrix per instance go
to the GPU as a single draw call, so the cost scales with the complexity of the model
rather than with how many of them there are. Adding 64 clones of gltf.scene instead means
64 times the draw calls and a frame rate that collapses long before the grid gets interesting.
The readout says 64 instances and 13 draw calls, because this model is 13 meshes. A glTF
is a scene graph, not a single object, and each mesh carries its own material, so each needs
its own InstancedMesh. Thirteen is a constant: it stays thirteen at 64 instances and at
6,400. Building one instanced mesh per mesh, all sharing the same array of matrices, is the
general form of this technique.
Two things have to happen before a mesh can be detached from that graph and instanced.
updateMatrixWorld(true) then geometry.applyMatrix4(child.matrixWorld) bakes each node’s
position into its vertices, because the transform lived on the node you are throwing away.
Skip it and the parts of the model pile up at the origin, which renders as a single unreadable
lump or as nothing at all.
Then the scale. A glTF is authored in whatever units its creator chose, and the map works in
metres, so a model dropped in unscaled is usually invisible or the size of a district.
Measuring the combined bounding box and scaling to TARGET_HEIGHT_M makes the example work
with any model you swap in, and translating by -bounds.min.y first is what puts the base on
the ground rather than burying half of it.
The transform is applied once for the layer, not once per model. The scene is anchored at
a single origin in Mercator coordinates, and every instance is positioned inside it in
metres. That is what meterInMercatorCoordinateUnits() buys: one conversion at the layer
level, then ordinary metric offsets. Computing a Mercator coordinate per model, as the
single-model example does, does not scale and accumulates precision error far from the
origin.
Scene axes are not map axes. The -scale on Y flips the handedness, and the extra
makeRotationAxis(X, PI/2) stands glTF’s Y-up convention on the map’s Z-up ground plane.
Instance positions therefore use X and Z for the ground plane, with Y as height, which
catches people out when a grid comes out standing vertically in the air.
frustumCulled = false is not optional here. three.js decides what is on screen by
building a frustum from camera.projectionMatrix and camera.matrixWorldInverse. In a
MapLibre custom layer the projection matrix is handed to you fully composed and the camera’s
own transform is identity, so the frustum three.js derives does not describe the real view.
Left at the default, objects are culled at some tilt and bearing angles and disappear as the
user rotates the map, which looks like the layer breaking rather than a culling bug.
setMatrixAt writes into a buffer, and instanceMatrix.needsUpdate = true is required
after the loop. Without it the buffer never reaches the GPU and every instance renders
stacked at the origin, which looks like only one model loaded.
triggerRepaint is called once when the model finishes loading rather than every frame.
The single-model example calls it inside render, which forces a continuous repaint loop
and keeps the GPU busy even on a still map. Call it when something actually changes.
Precision is the practical limit on how far instances can spread. Mercator units are tiny numbers and float32 runs out of resolution, so a scene spanning tens of kilometres wants several layers with their own origins rather than one.
NavigationControl switches 3D terrain on the first time the map is tilted, and it does
not wait for its own button to be pressed: a Ctrl + drag is enough. A custom layer draws at
whatever altitude you gave it, so the ground then rises straight through the models and the
scene looks broken. terrainControl: false removes both the button and that behaviour, which
is why it is set above. Leave it on only if the layer reads
terrain elevation per
instance.Next steps
Per-instance colour is the next step and it is cheap: InstancedMesh takes
setColorAt alongside setMatrixAt, which turns the grid into a data visualisation where
each object carries a value.
On uneven ground the instances need to sit on the surface rather than at a single altitude, which means querying the terrain height per position, the same way a model on terrain does for one. If the goal is buildings rather than objects, extruded footprints from the vector tiles cost almost nothing and cover far more of the world.