Add a 3D Model with Shadow Using three.js in Maptoolkit Maps JS
Adding shadow casting to a 3D model on a map improves depth perception and makes the scene feel grounded in its location. This example builds on the basic three.js custom layer by enabling shadow maps and adding a directional light that casts shadows onto a plane beneath the model. Use this approach when presenting architectural or product models in a geographic context, or building realistic scene previews tied to a real address.
{
"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 map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
zoom: 18,
center: [11.39085, 47.27574],
pitch: 60,
attributionControl: { compact: false },
canvasContextAttributes: { antialias: true }
});
// 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({ terrainControl: false }), 'top-right');
const modelOrigin = [11.39085, 47.27574];
const modelAltitude = 0;
const modelRotate = [Math.PI / 2, 0, 0];
const modelAsMercatorCoordinate = maptoolkit.MercatorCoordinate.fromLngLat(modelOrigin, modelAltitude);
const modelTransform = {
translateX: modelAsMercatorCoordinate.x,
translateY: modelAsMercatorCoordinate.y,
translateZ: modelAsMercatorCoordinate.z,
rotateX: modelRotate[0],
rotateY: modelRotate[1],
rotateZ: modelRotate[2],
scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits()
};
const customLayer = {
id: '3d-model',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(100, 100, 100);
directionalLight.castShadow = true;
this.scene.add(directionalLight);
directionalLight.shadow.camera.near = 0.1;
directionalLight.shadow.camera.far = 2000;
directionalLight.shadow.camera.left = -500;
directionalLight.shadow.camera.right = 500;
directionalLight.shadow.camera.top = 500;
directionalLight.shadow.camera.bottom = -500;
directionalLight.shadow.mapSize.width = 4096;
directionalLight.shadow.mapSize.height = 4096;
const groundGeometry = new THREE.PlaneGeometry(1000, 1000);
const groundMaterial = new THREE.ShadowMaterial({ opacity: 0.5 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = modelAsMercatorCoordinate.z;
ground.receiveShadow = true;
this.scene.add(ground);
const loader = new GLTFLoader();
loader.load(
'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
(gltf) => {
gltf.scene.traverse((node) => {
if (node.isMesh || node.isLight) {
node.castShadow = true;
node.receiveShadow = true;
}
});
this.scene.add(gltf.scene);
}
);
this.map = map;
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.autoClear = false;
},
render(gl, args) {
const rotationX = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), modelTransform.rotateX);
const rotationY = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 1, 0), modelTransform.rotateY);
const rotationZ = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 0, 1), modelTransform.rotateZ);
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4()
.makeTranslation(modelTransform.translateX, modelTransform.translateY, modelTransform.translateZ)
.scale(new THREE.Vector3(modelTransform.scale, -modelTransform.scale, modelTransform.scale))
.multiply(rotationX).multiply(rotationY).multiply(rotationZ);
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);
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Add a 3D Model with Shadow Using three.js - Maptoolkit Maps JS</title>
<meta property="og:description" content="Use a custom style layer with three.js to add a 3D model with shadow to the map." />
<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';
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
zoom: 18,
center: [11.39085, 47.27574],
pitch: 60,
attributionControl: { compact: false },
canvasContextAttributes: { antialias: true }
});
// 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({ terrainControl: false }), 'top-right');
const modelOrigin = [11.39085, 47.27574];
const modelAltitude = 0;
const modelRotate = [Math.PI / 2, 0, 0];
const modelAsMercatorCoordinate = maptoolkit.MercatorCoordinate.fromLngLat(modelOrigin, modelAltitude);
const modelTransform = {
translateX: modelAsMercatorCoordinate.x,
translateY: modelAsMercatorCoordinate.y,
translateZ: modelAsMercatorCoordinate.z,
rotateX: modelRotate[0],
rotateY: modelRotate[1],
rotateZ: modelRotate[2],
scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits()
};
const customLayer = {
id: '3d-model',
type: 'custom',
renderingMode: '3d',
onAdd(map, gl) {
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(100, 100, 100);
directionalLight.castShadow = true;
this.scene.add(directionalLight);
directionalLight.shadow.camera.near = 0.1;
directionalLight.shadow.camera.far = 2000;
directionalLight.shadow.camera.left = -500;
directionalLight.shadow.camera.right = 500;
directionalLight.shadow.camera.top = 500;
directionalLight.shadow.camera.bottom = -500;
directionalLight.shadow.mapSize.width = 4096;
directionalLight.shadow.mapSize.height = 4096;
const groundGeometry = new THREE.PlaneGeometry(1000, 1000);
const groundMaterial = new THREE.ShadowMaterial({ opacity: 0.5 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = modelAsMercatorCoordinate.z;
ground.receiveShadow = true;
this.scene.add(ground);
const loader = new GLTFLoader();
loader.load(
'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
(gltf) => {
gltf.scene.traverse((node) => {
if (node.isMesh || node.isLight) {
node.castShadow = true;
node.receiveShadow = true;
}
});
this.scene.add(gltf.scene);
}
);
this.map = map;
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.autoClear = false;
},
render(gl, args) {
const rotationX = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), modelTransform.rotateX);
const rotationY = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 1, 0), modelTransform.rotateY);
const rotationZ = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(0, 0, 1), modelTransform.rotateZ);
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4()
.makeTranslation(modelTransform.translateX, modelTransform.translateY, modelTransform.translateZ)
.scale(new THREE.Vector3(modelTransform.scale, -modelTransform.scale, modelTransform.scale))
.multiply(rotationX).multiply(rotationY).multiply(rotationZ);
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>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
This builds on the basic three.js custom layer, so the renderer still shares the map’s canvas and GL context and still reads the view-projection matrix each frame.
Shadows need three things switched on together, and missing any one produces no shadow with no error: the renderer has to have shadow maps enabled, the light has to be told to cast, and the mesh has to be told both to cast and to receive. A shadow also needs something to land on, which is why there is a ground plane in the scene rather than the map surface doing the job. The map’s basemap is not part of the three.js scene and cannot receive a shadow from it.
The shadow camera’s bounds matter: too tight and the shadow is clipped at the edges, too loose and it turns blocky as resolution spreads over a larger area.
Next steps
Shadow quality is the dial: the shadow camera’s bounds trade sharpness against coverage, and tightening them around the model rather than the scene is the first thing to try when the edges look blocky.
Beyond that, the interesting step is making light meaningful rather than decorative. Driving the light direction from the real sun position for a given date and time turns the shadow into information, which is the basis of any shading or solar study. On uneven ground the same applies to the model itself, which has to sit on the terrain rather than at sea level.