Add 3D model and animate along route
This examples illustrates how to use model layer and model source
to animate a glTF model of a plane along a route across the globe.
The sample also demonstrates how to animate model parts, such as gears and propellers, and overrides materials to implement effects like the blinking lights on the plane.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Add 3D model and animate along route</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.17.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.17.0/mapbox-gl.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
<div id="map"></div>
<script>
// TO MAKE THE MAP APPEAR YOU MUST
// ADD YOUR ACCESS TOKEN FROM
// https://account.mapbox.com
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
function asset(uri) {
return `https://docs.mapbox.com/mapbox-gl-js/assets/${uri}`;
}
const flightPathJsonUri = asset('flightpath.json');
const airplaneModelUri = asset('airplane.glb');
const style = {
'version': 8,
'imports': [
{
'id': 'basemap',
'url': 'mapbox://styles/mapbox/standard',
'config': {
'lightPreset': 'dusk',
'showPointOfInterestLabels': false,
'showRoadLabels': false
}
}
],
'sources': {
'flightpath': {
'type': 'geojson',
'data': flightPathJsonUri
},
'3d-model-source': {
'type': 'model',
'models': {
'plane': {
'uri': airplaneModelUri,
'position': [-122.38405485266087, 37.61853120385187],
'orientation': [0, 0, 0]
}
}
}
},
'layers': [
// Line layer for flight path
{
'id': 'flight-path-line',
'slot': 'middle',
'type': 'line',
'source': 'flightpath',
'layout': {
'line-join': 'round',
'line-cap': 'round'
},
'paint': {
'line-color': '#007cbf',
'line-emissive-strength': 1,
'line-width': 8
}
},
// Model layer adding the plane
{
'id': '3d-model-layer',
'type': 'model',
'source': '3d-model-source',
'slot': 'top',
'paint': {
// Altitude/elevation of plane is obtained from feature state
'model-translation': [
0,
0,
['feature-state', 'z-elevation']
],
'model-scale': [
'interpolate',
['exponential', 0.5],
['zoom'],
2.0,
['literal', [40000.0, 40000.0, 40000.0]],
14.0,
['literal', [1.0, 1.0, 1.0]]
],
'model-type': 'location-indicator',
// Override emissive strength of materials based on material name
'model-emissive-strength': [
'match',
['get', 'part'],
'lights_position_white',
['feature-state', 'light-emission-strobe'],
'lights_position_white_volume',
['feature-state', 'light-emission-strobe'],
'lights_anti_collision_red',
['feature-state', 'light-emission-strobe'],
'lights_anti_collision_red_volume',
['feature-state', 'light-emission-strobe'],
'lights_position_red',
['feature-state', 'light-emission'],
'lights_position_red_volume',
['feature-state', 'light-emission'],
'lights_position_green',
['feature-state', 'light-emission'],
'lights_position_green_volume',
['feature-state', 'light-emission'],
'lights_taxi_white',
['feature-state', 'light-emission-taxi'],
'lights_taxi_white_volume',
['feature-state', 'light-emission-taxi'],
0.0
],
// Override orientation of model parts based on glTF node name
'model-rotation': [
'match',
['get', 'part'],
// Animate gears
'front_gear',
['feature-state', 'front-gear-rotation'],
'rear_gears',
['feature-state', 'rear-gear-rotation'],
// Animate propellers
'propeller_left_outer',
['feature-state', 'propeller-rotation'],
'propeller_left_inner',
['feature-state', 'propeller-rotation'],
'propeller_right_outer',
['feature-state', 'propeller-rotation'],
'propeller_right_inner',
['feature-state', 'propeller-rotation'],
'propeller_left_outer_blur',
['feature-state', 'propeller-rotation-blur'],
'propeller_left_inner_blur',
['feature-state', 'propeller-rotation-blur'],
'propeller_right_outer_blur',
['feature-state', 'propeller-rotation-blur'],
'propeller_right_inner_blur',
['feature-state', 'propeller-rotation-blur'],
[0.0, 0.0, 0.0]
],
// Override opacity of materials based on material name
'model-opacity': [
'match',
['get', 'part'],
'lights_position_white_volume',
['*', ['feature-state', 'light-emission-strobe'], 0.25],
'lights_anti_collision_red_volume',
['*', ['feature-state', 'light-emission-strobe'], 0.45],
'lights_position_green_volume',
['*', ['feature-state', 'light-emission'], 0.25],
'lights_position_red_volume',
['*', ['feature-state', 'light-emission'], 0.25],
'lights_taxi_white',
['*', ['feature-state', 'light-emission-taxi'], 0.25],
'lights_taxi_white_volume',
['*', ['feature-state', 'light-emission-taxi'], 0.25],
'propeller_blur',
0.2,
1.0
]
}
}
]
};
// Mathematical helper functions for animation interpolation
function clamp(v) {
return Math.max(0.0, Math.min(v, 1.0));
}
function mix(a, b, mixFactor) {
const f = clamp(mixFactor);
return a * (1 - f) + b * f;
}
function rad2deg(angRad) {
return (angRad * 180.0) / Math.PI;
}
// FlightRoute class handles loading and sampling flight path data
class FlightRoute {
constructor(url) {
fetch(url)
.then((response) => response.json())
.catch((error) => {
console.error('Error loading flight path data:', error);
})
.then((data) => {
const targetRouteFeature = data.features[0];
this.setFromFeatureData(targetRouteFeature);
})
.catch((error) => {
console.error('Error loading flight path data:', error);
});
}
get totalLength() {
if (!this.distances || this.distances.length === 0) return 0;
return this.distances[this.distances.length - 1];
}
setFromFeatureData(targetRouteFeature) {
const coordinates = targetRouteFeature.geometry.coordinates;
this.elevationData = targetRouteFeature.properties.elevation;
this.coordinates = coordinates;
this.maxElevation = 0;
if (this.elevationData.length !== this.coordinates.length) {
console.error(
'Number of elevation samples does not match coordinate data length'
);
}
const distances = [0];
for (let i = 1; i < coordinates.length; i++) {
const segmentDistance =
turf.distance(
turf.point(coordinates[i - 1]),
turf.point(coordinates[i]),
{ units: 'kilometers' }
) * 1000.0;
distances.push(distances[i - 1] + segmentDistance);
this.maxElevation = Math.max(
this.maxElevation,
this.elevationData[i]
);
}
this.distances = distances;
}
sample(currentDistance) {
if (!this.distances || this.distances.length === 0) return null;
let segmentIndex =
this.distances.findIndex((d) => d >= currentDistance) - 1;
if (segmentIndex < 0) segmentIndex = 0;
const p1 = this.coordinates[segmentIndex];
const p2 = this.coordinates[segmentIndex + 1];
const segmentLength =
this.distances[segmentIndex + 1] - this.distances[segmentIndex];
const segmentRatio =
(currentDistance - this.distances[segmentIndex]) /
segmentLength;
const e1 = this.elevationData[segmentIndex];
const e2 = this.elevationData[segmentIndex + 1];
const bearing = turf.bearing(p1, p2);
const altitude = e1 + (e2 - e1) * segmentRatio;
const pitch = rad2deg(Math.atan2(e2 - e1, segmentLength));
return {
position: [
p1[0] + (p2[0] - p1[0]) * segmentRatio,
p1[1] + (p2[1] - p1[1]) * segmentRatio
],
altitude: altitude,
bearing: bearing,
pitch: pitch
};
}
}
function animSinPhaseFromTime(animTimeS, phaseLen) {
return (
Math.sin(((animTimeS % phaseLen) / phaseLen) * Math.PI * 2.0) *
0.5 +
0.5
);
}
class Airplane {
constructor() {
this.position = [0, 0];
this.altitude = 0;
this.bearing = 0;
this.pitch = 0;
this.roll = 0;
this.rearGearRotation = 0;
this.frontGearRotation = 0;
this.lightPhase = 0;
this.lightPhaseStrobe = 0;
this.lightTaxiPhase = 0;
this.animTimeS = 0;
}
update(target, dtimeMs) {
this.position[0] = mix(
this.position[0],
target.position[0],
dtimeMs * 0.05
);
this.position[1] = mix(
this.position[1],
target.position[1],
dtimeMs * 0.05
);
this.altitude = mix(this.altitude, target.altitude, dtimeMs * 0.05);
this.bearing = mix(this.bearing, target.bearing, dtimeMs * 0.01);
this.pitch = mix(this.pitch, target.pitch, dtimeMs * 0.01);
this.frontGearRotation = mix(0, 90, this.altitude / 50.0);
this.rearGearRotation = mix(0, -90, this.altitude / 50.0);
this.lightPhase =
animSinPhaseFromTime(this.animTimeS, 2.0) * 0.25 + 0.75;
this.lightPhaseStrobe = animSinPhaseFromTime(this.animTimeS, 1.0);
this.lightTaxiPhase = mix(1.0, 0, this.altitude / 100.0);
this.roll = rad2deg(
mix(
0,
Math.sin(this.animTimeS * Math.PI * 0.2) * 0.1,
(this.altitude - 50.0) / 100.0
)
);
this.animTimeS += dtimeMs / 1000.0;
}
}
const flightRoute = new FlightRoute(flightPathJsonUri);
const airplane = new Airplane();
// Function demonstrates how to update model source and feature state of plane to drive animation of model.
function updateModelSourceAndFeatureState(map, airplane) {
// Obtain model source
const modelSource = map.getSource('3d-model-source');
if (modelSource) {
// Update the model specification including position and orientation.
// Only changed properties are updated. This is efficient when the model URI remains the same.
// This approach is ideal for bulk updates of all models in a model source at once.
const modelsSpec = {
'plane': {
'uri': airplaneModelUri,
'position': airplane.position,
'orientation': [
airplane.roll,
airplane.pitch,
airplane.bearing + 90
],
// List of material names to be overridden in paint expressions.
'materialOverrideNames': [
'propeller_blur',
'lights_position_white',
'lights_position_white_volume',
'lights_position_red',
'lights_position_red_volume',
'lights_position_green',
'lights_position_green_volume',
'lights_anti_collision_red',
'lights_anti_collision_red_volume',
'lights_taxi_white',
'lights_taxi_white_volume'
],
// List of node names to be overridden in paint expressions.
'nodeOverrideNames': [
'front_gear',
'rear_gears',
'propeller_left_inner',
'propeller_left_outer',
'propeller_right_inner',
'propeller_right_outer',
'propeller_left_inner_blur',
'propeller_left_outer_blur',
'propeller_right_inner_blur',
'propeller_right_outer_blur'
]
}
};
modelSource.setModels(modelsSpec);
}
// Update feature state for plane.
// These properties are read in paint and layout expressions to override orientation and materials of model parts
const planeFeatureState = {
'z-elevation': airplane.altitude,
'front-gear-rotation': [0, 0, airplane.frontGearRotation],
'rear-gear-rotation': [0, 0, airplane.rearGearRotation],
'propeller-rotation': [
0,
0,
-(airplane.animTimeS % 0.5) * 2.0 * 360.0
],
'propeller-rotation-blur': [
0,
0,
(airplane.animTimeS % 0.1) * 10.0 * 360.0
],
'light-emission': airplane.lightPhase,
'light-emission-strobe': airplane.lightPhaseStrobe,
'light-emission-taxi': airplane.lightTaxiPhase
};
map.setFeatureState(
{ source: '3d-model-source', sourceLayer: '', id: 'plane' },
planeFeatureState
);
}
const map = (window.map = new mapboxgl.Map({
container: 'map',
projection: 'globe',
style,
interaction: true,
center: [-122.37204647633236, 37.619836883832306],
zoom: 19,
bearing: 0,
pitch: 45
}));
map.on('load', () => {
// Animation constants for aircraft flight simulation
// Total animation duration in milliseconds
const animationDuration = 50000;
// Altitude range in meters when to speed up animation
const flightTravelAltitudeMin = 200;
const flightTravelAltitudeMax = 3000;
// phase determines how far through the animation we are
let phase = 0;
let lastFrameTime;
let routeElevation = 0;
function frame(time) {
if (!lastFrameTime) lastFrameTime = time;
// Calculate delta time and derive animation factors for procedural flight path simulation
const frameDeltaTime = time - lastFrameTime;
const animFade = clamp(
(routeElevation - flightTravelAltitudeMin) /
(flightTravelAltitudeMax - flightTravelAltitudeMin)
);
// Time-lapse factor used to accelerate animation once aircraft is airborne
const timelapseFactor = mix(0.001, 10.0, animFade * animFade);
phase += (frameDeltaTime * timelapseFactor) / animationDuration;
lastFrameTime = time;
// Phase is normalized between 0 and 1
// When the animation is finished, reset to loop continuously
if (phase > 1) {
phase = 0;
routeElevation = 0;
}
// Sample route at current travel distance
const alongRoute = flightRoute.sample(
flightRoute.totalLength * phase
);
if (alongRoute) {
routeElevation = alongRoute.altitude;
// Update airplane state based on sampled route point and frame delta time
airplane.update(alongRoute, frameDeltaTime);
}
// Update 3D model layer based on current airplane state
updateModelSourceAndFeatureState(map, airplane);
// Update camera position to follow the aircraft
const camera = map.getFreeCameraOptions();
const cameraOffset = [
mix(-0.0014, 0.0, routeElevation / 200.0),
mix(0.0014, 0, routeElevation / 200.0)
];
// set the position and altitude of the camera
camera.position = mapboxgl.MercatorCoordinate.fromLngLat(
{
lng: airplane.position[0] + cameraOffset[0],
lat: airplane.position[1] + cameraOffset[1]
},
airplane.altitude + 50.0 + mix(0, 10000000.0, animFade)
);
// tell the camera to look at a point along the route
camera.lookAtPoint(
{
lng: airplane.position[0],
lat: airplane.position[1]
},
[0, 0, 1],
airplane.altitude
);
map.setFreeCameraOptions(camera);
window.requestAnimationFrame(frame);
}
window.requestAnimationFrame(frame);
});
</script>
</body>
</html>
This code snippet will not work as expected until you replace
YOUR_MAPBOX_ACCESS_TOKEN with an access token from your Mapbox account.Was this example helpful?