Implementing Automatic Cruise Navigation on AMap with Three.js
The article walks through building an automatic cruise navigation effect on AMap with Three.js by acquiring route data, merging GeoJSON paths, loading a 3D vehicle model, animating its movement and camera follow using TWEEN, and blending the NPC layer with satellite and 3DTiles layers for a smooth autonomous navigation experience.
Introduction
This article demonstrates how to create an automatic cruise effect for a navigation interface on AMap. The goal is to display a main vehicle (NPC) moving smoothly along a predefined path on a 3DTiles layer without handling collision detection. The NPC and the scene are split into two independent layers.
Requirement Analysis
The implementation consists of the following steps:
Obtain data, generate a movement path, and draw the cruise trajectory.
Load, place, and adjust the 3D model.
Update the model’s position and orientation each frame.
Update the camera position and its look‑at direction as the model moves.
Blend the NPC layer with the 3DTiles layer as closely as possible.
Technical Point Analysis
Moving the NPC Along a Trajectory
Two approaches are described:
Pre‑compute interpolation points between key nodes and move the NPC to each point per frame. This requires a dense set of points for smooth motion.
Dynamic calculation: for each frame, compute the NPC’s position based on the start point, end point, total duration, and a speed curve (linear, easing, etc.).
Camera Follow
The author tried AMap’s ViewControl animation and a Three.js‑based solution, finally choosing a hybrid approach that combines Three.js with AMap APIs.
Welding Two Routes
When a dragged route produces a gap between two segments, the article outlines three cases and corresponding solutions:
If the extended lines of Segment A and Segment B intersect, generate a smooth Bézier curve to connect them.
If they are collinear, simply connect the two endpoints.
If they are parallel, create a half‑rounded rectangle edge to bridge the gap.
Code Implementation
1. Data Acquisition and Path Generation
// Final path data
const PATH_DATA = {features: []};
var path = [];
path.push([113.532592, 22.788502]); // start
path.push([113.532592, 22.788502]); // via
path.push([113.532553, 22.788321]); // end
map.plugin("AMap.DragRoute", function(){
// Construct drag‑route instance
route = new AMap.DragRoute(map, path, AMap.DrivingPolicy.LEAST_FEE);
route.search();
route.on('complete', function({type, target, data}){
const res = data.routes[0].steps.map(v=>{
var arr = v.path.map(o=>[o.lng, o.lat]);
return {
"type":"Feature",
"geometry":{
"type":"MultiLineString",
"coordinates":[arr]
},
"properties":{
"instruction":v.instruction,
"distance":v.distance,
"duration":v.duration,
"road":v.road
}
};
});
PATH_DATA.features = res;
});
});
// Draw the flowline for debugging
const layer = new FlowlineLayer({
map: getMap(),
zooms: [4, 22],
data: PATH_DATA,
speed: 0.4,
lineWidth: 2,
altitude: 0.5
});2. Merging GeoJSON into a Single Route
// Merged path data (spatial coordinates)
var _PATH_COORDS = [];
// Merged path data (geographic coordinates)
var _PATH_LNG_LAT = [];
initData(geoJSON){
const {features} = geoJSON;
this._data = JSON.parse(JSON.stringify(features));
this._data.forEach((feature)=>{
const {geometry} = feature;
const {type, coordinates} = geometry;
if(type==='MultiLineString'){
feature.geometry.coordinates = coordinates.map(sub=>this.handleOnePath(sub));
}
if(type==='LineString'){
feature.geometry.coordinates = this.handleOnePath(coordinates);
}
});
}
/**
* Process a single path
* @param {Array} path Geographic coordinates [[x,y,z]...]
* @returns {Array} Spatial coordinates [[x',y',z']...]
*/
handleOnePath(path){
const { _PATH_LNG_LAT, _PATH_COORDS, _NPC_ALTITUDE } = this;
const len = _PATH_COORDS.length;
const arr = path.map(v=>[v[0], v[1], v[2]||this._NPC_ALTITUDE]);
if(len>0){
const {x,y,z} = _PATH_LNG_LAT[len-1];
if(JSON.stringify([x,y,z])===JSON.stringify(arr[0])) arr.shift();
}
_PATH_LNG_LAT.push(...arr.map(v=>new THREE.Vector3().fromArray(v)));
const xyArr = this.customCoords.lngLatsToCoords(arr).map((v,i)=>[v[0], v[1], arr[i][2]||_NPC_ALTITUDE]);
_PATH_COORDS.push(...xyArr.map(v=>new THREE.Vector3().fromArray(v)));
return arr;
}3. Loading and Placing the NPC Model
// Load NPC model
function getModel(scene){
return new Promise(resolve=>{
const loader = new GLTFLoader();
loader.load('./static/gltf/car/car1.gltf', gltf=>{
const model = gltf.scene.children[0];
const size = 1.0;
model.scale.set(size,size,size);
resolve(model);
});
});
}
initNPC(){
const {_PATH_COORDS, scene} = this;
const {NPC} = this._conf;
NPC.up.set(0,0,1);
if(_PATH_COORDS.length>1){
NPC.position.copy(_PATH_COORDS[0]);
NPC.lookAt(_PATH_COORDS[1]);
}
scene.add(NPC);
}4. Movement Controller and Camera Update
initController(){
const target = {t:0};
const duration = this.getMoveDuration();
const {_PATH_COORDS,_PATH_LNG_LAT,map} = this;
this._rayController = new TWEEN.Tween(target)
.to({t:1},duration)
.easing(TWEEN.Easing.Linear.None)
.onUpdate(()=>{
const {NPC,cameraFollow} = this._conf;
const nextIndex = this.getNextStepIndex();
const point = new THREE.Vector3().copy(_PATH_COORDS[this.npc_step]);
const nextPoint = new THREE.Vector3().copy(_PATH_COORDS[nextIndex]);
const position = new THREE.Vector3().copy(point).lerp(nextPoint,target.t);
if(NPC) NPC.position.copy(position);
if(cameraFollow){
const pointLngLat = new THREE.Vector3().copy(_PATH_LNG_LAT[this.npc_step]);
const nextPointLngLat = new THREE.Vector3().copy(_PATH_LNG_LAT[nextIndex]);
const positionLngLat = new THREE.Vector3().copy(pointLngLat).lerp(nextPointLngLat,target.t);
this.updateMapCenter(positionLngLat);
const angle = this.getAngle(position,_PATH_COORDS[(this.npc_step+3)%_PATH_COORDS.length]);
this.updateMapRotation(angle);
}
})
.onStart(()=>{
const {NPC} = this._conf;
const nextPoint = _PATH_COORDS[(this.npc_step+3)%_PATH_COORDS.length];
if(NPC){
NPC.lookAt(nextPoint);
NPC.up.set(0,0,1);
}
})
.onComplete(()=>{
this.npc_step = this.getNextStepIndex();
const newDuration = this.getMoveDuration();
target.t = 0;
this._rayController.stop().to({t:1},newDuration).start();
})
.start();
}
animate(time){
const {_rayController,_isMoving} = this;
if(_rayController && _isMoving) _rayController.update(time);
if(this.map) this.map.render();
requestAnimationFrame(()=>this.animate());
}
updateMapCenter(positionLngLat){
this.map.panTo([positionLngLat.x, positionLngLat.y],0);
}
updateMapRotation(angle){
if(Math.abs(angle)>=1.0) this.map.setRotation(angle,true,0);
}
/** Calculate angle between movement direction and Y‑axis */
getAngle(origin,target){
const deltaX = target.x - origin.x;
const deltaY = target.y - origin.y;
const rad = Math.atan2(deltaY, deltaX);
let angle = rad * 180 / Math.PI;
angle = angle>=0 ? angle : 360 + angle;
angle = 90 - angle;
const res = angle>=-180 ? angle : angle + 360;
return res * -1;
}5. Adding Satellite and 3D Tiles Layers
// Add satellite imagery
const satelliteLayer = new AMap.TileLayer.Satellite();
getMap().add([satelliteLayer]);
// Create 3D Tiles layer
const layer = new TilesLayer({
map: getMap(),
center: mapConf.center,
zooms: [4,22],
zoom: mapConf.zoom,
interact: false,
tilesURL: mapConf.tilesURL
});Conclusion
The article provides a complete workflow—from data acquisition to model animation and camera synchronization—enabling a smooth autonomous navigation experience on AMap using Three.js. The code snippets can be directly integrated into a web project that requires NPC movement along draggable routes.
Amap Tech
Official Amap technology account showcasing all of Amap's technical innovations.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.