Dry stuff! How to achieve automatic cruise effect on Amap?

introduce

To realize the automatic cruise effect of the automatic driving navigation interface on the Amap map, you only need to display the subject vehicle (hereinafter referred to as NPC) on the 3DTiles layer to move smoothly along the established path. There is no need to consider the collision problem between the NPC and the scene. , so the NPC and scene can be directly split into two independent layers. This article focuses on how to implement NPC movement.

226372db53a8e604d90342bf6aafb413.gif

demand analysis

First, we need to obtain data and generate a moving path, and then draw the cruise trajectory. In order to achieve the cruise effect, we need to load and place the model, and reset the position and frontal orientation of the model at each frame. At the same time, as the model moves, we need to update the position and orientation of the camera, so we extract the following implementation steps.

1. Obtain data, generate movement paths, and draw cruise trajectories;

269a690484f40f97ffaba7e548e927aa.gif

2. Load, place and adjust the model;

6e66963f28647714c27730adab3de4c2.png

3. Move the model and reset the position and frontal orientation of the model in each frame;

af18545cabcde827180ecbff81ff14ee.gif

4. As the model moves, update the position of the camera and the position where the camera is looking;

c4bfb340d3bcf843a4e03d999f6c82fe.gif

5. Finally, let the NPC layer and 3DTiles layer be integrated as much as possible.

395e83ed7fa1adbde34adb05382fe082.gif

Technical point analysis

The subject moves along the trajectory

There are two ways to implement movement along the trajectory in threejs. Method one is to pre-calculate the intermediate interpolation points between each key node in the entire route (as shown by the green dots in the figure). After obtaining a series of coordinates, You only need to move the subject to the position of the interpolation point in each frame. This method requires that the interpolation points are dense enough, otherwise the final effect will not be smooth enough and the car will jump like an electronic jump.

5fc08af2d6101718b1cd35196759c767.png

Another method is dynamic calculation. We only consider the time and position relationship between two key points each time, that is, input the starting point A, end point B, the total time of movement, and the speed curve of movement (constant speed, acceleration, slow motion and slow stop). etc.), and then you can get the position where the NPC should be based on the progress of the current moment in the total duration.

1069c91625937a9b3fbdb49f9814c1d1.gif

Camera follows

In order to realize the movement of objects and lens following, I tried the ViewControl lens animation provided by the Amap data visualization API and the movement solution based on threejs. Finally, I chose the solution that combines threejs and the Amap API. The following is a comparison of the technical solutions:

426fdf537d86473c3888bc00eb7d7adf.png

Two route welding

The path data of the demonstration page in this article uses AutoNavi and the drag-and-drop navigation plug-in AMap.DragRoute. By dragging any point on the existing navigation path with the mouse, the navigation starting point, way point, and end point can be adjusted. The system queries the dragged navigation path in real time based on the adjusted starting point, way point, and end point information. However, it was found that the returned route data did not include the connection between the two routes. This situation usually occurs at traffic lights and intersections and needs to be handled by yourself. In order to avoid interfering with the theme of the article, it is enough to know that this situation exists. The specific coding of route welding will be discussed later.

6d8524d841be6b491f3eebab4a103b7a.png

Directly connecting the end point of LineA and the starting point of LineB with a line segment will cause the object to move in an abnormal direction and the camera to rotate abruptly. Therefore, when the distance between the two endpoints is greater than a certain threshold, a method needs to be provided to automatically "weld" the two line segments. The purpose of smoothing the welding route is to make the object move more smoothly. We can choose to process it in advance or in real time, depending on the situation.

There will be several situations here, we will deal with them respectively:

The extension lines of LineA and LineB must intersect, and a smooth Bezier curve needs to be generated to connect the two endpoints;

c90094b06dabbfa81407c558a22f6a01.gif

LineA and LineB are on the same straight line, and you only need to connect the two endpoints;

c6101f88681e965f32d9cb3a5ae0966b.png

LineA and LineB are parallel, so you need to generate half a rounded rectangle edge to connect the end points.

a7f8c7c9dfe29a5afe4a4403f7f2de25.png

Code

1. Obtain data, generate movement paths, and draw cruise trajectories;

//最终路径数据
const PATH_DATA = {features: []} 


var path = [];
path.push([113.532592,22.788502]); //起点
path.push([113.532592,22.788502]); //经过
path.push([113.532553, 22.788321]); //终点


map.plugin("AMap.DragRoute", function() {
    //构造拖拽导航类
    route = new AMap.DragRoute(map, path, AMap.DrivingPolicy.LEAST_FEE); 
    //查询导航路径并开启拖拽导航
    route.search(); 
    route.on('complete',function({type,target, data}){
      // 获得路径数据后,处理成GeoJSON
      const res =  data.routes[0].steps.map(v=>{
            var arr = v.path.map(o=>{
                return [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
    })
});


// 使用数据绘制流光的轨迹线
// 这个图层的作用是便于调试运动轨迹是否吻合
const layer = new FlowlineLayer({
  map: getMap(),
  zooms: [4, 22],
  data: PATH_DATA,
  speed: 0.4,
  lineWidth: 2,
  altitude: 0.5
})

2. Merge GeoJSON data into a complete route data and preprocess the data;

// 合并后的路径数据(空间坐标)
var _PATH_COORDS = []
// 合并后的路径数据(地理坐标)
var _PATH_LNG_LAT = []


//处理转换图层基础数据的地理坐标为空间坐标,保留z轴数据
initData (geoJSON) {
    const { features } = geoJSON
    this._data = JSON.parse(JSON.stringify(features))


    this._data.forEach((feature, index) => {
      const { geometry } = feature
      const { type, coordinates } = geometry


      if (type === 'MultiLineString') {
        feature.geometry.coordinates = coordinates.map(sub => {
          return this.handleOnePath(sub)
        })
      }
      if (type === 'LineString') {
        feature.geometry.coordinates = this.handleOnePath(coordinates)
      }
    })
}
/**
   * 处理单条路径数据
   * @param {Array} path 地理坐标数据 [[x,y,z]...]
   * @returns {Array} 空间坐标数据 [[x',y',z']...]
   */
  handleOnePath (path) {
    const { _PATH_LNG_LAT, _PATH_COORDS, _NPC_ALTITUDE } = this
    const len = _PATH_COORDS.length
    const arr = path.map(v => {
      return [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)))


    // 转换空间坐标
    // customCoords.lngLatsToCoords会丢失z轴数据,需要重新赋值
    const xyArr = this.customCoords.lngLatsToCoords(arr).map((v, i) => {
      return [v[0], v[1], arr[i][2] || _NPC_ALTITUDE]
    })
    // 合并空间坐标
    _PATH_COORDS.push(...xyArr.map(v => new THREE.Vector3().fromArray(v)))
    // 返回空间坐标
    return arr
  }

3. Load, place and adjust the model;

// 加载主体NPC
function getModel (scene) {
  return new Promise((resolve) => {
    const loader = new GLTFLoader()
    loader.load('./static/gltf/car/car1.gltf', function (gltf) {
      const model = gltf.scene.children[0]


      // 调试代码
      // const axesHelper = new THREE.AxesHelper(50)
      // model.add(axesHelper)


      // 调整模型大小
      const size = 1.0
      model.scale.set(size, size, size)


      resolve(model)
    })
  })
}


// 初始化主体NPC的状态
initNPC () {
  const { _PATH_COORDS, scene } = this
  const { NPC } = this._conf


  // z轴朝上
  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. Here comes the point! Move the model, update the position and orientation of the NPC, and update the position and orientation of the camera. TWEEN is used as the controller for the movement state. It controls two key points (A and B) in the entire route (ABCD...) ) The moving state of the connection. When the movement of connection AB ends, the next connection BC will be opened immediately, and so on. Let’s briefly go over the implementation logic.

initController () {
  // 状态记录器
  const target = { t: 0 } 
  // 获取第一段线段的移动时长,具体实现就是两个坐标点的距离除以速度参数speed
  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(() => {
        //todo: 处理当前连线当前时刻,NPC的位置


        //通过状态值t, 计算NPC应该在的位置
        const position = new THREE.Vector3().copy(point).lerp(nextPoint, target.t)


        //todo: 处理地图中心位置,地图镜头朝向
     })
    .onStart(()=>{
       // todo: 处理NPC的朝向,每次开启路线都会执行
    })
    .onComplete(()=>{
      // todo: 停止当前路线、开启下一段路线
      this._rayController
          .stop()
          .to({ t: 1 }, duration)
          .start()
    })
}

5. As the model moves, update the position of the camera and the direction of the camera;

(1) The idea of ​​updating the camera position is the same as updating the NPC position. The difference is that geographical coordinates are used to calculate the intermediate interpolation, so as to facilitate the direct call to Amap's map.panTo(), and the same is true for map.setCenter().

// 计算两个lngLat端点的中间值
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)


// 更新地图中心到指定位置
updateMapCenter (positionLngLat) {
   // duration = 0 防止画面抖动
   this.map.panTo([positionLngLat.x, positionLngLat.y], 0)
}

(2) Update the camera orientation. The orientation is actually the direction of the vector. Two points determine the vector. Here, the current coordinates of the NPC and the coordinates of the fourth key point behind are used to determine the orientation. It can also be determined according to the actual situation.

//计算偏转角度
const angle = this.getAngle(position, _PATH_COORDS[(this.npc_step + 3) % _PATH_COORDS.length]
this.updateMapRotation(angle)


//更新地图旋转角度,正北为0度
updateMapRotation (angle) {
  if (Math.abs(angle) >= 1.0) {
    this.map.setRotation(angle, true, 0)
  }
}

Here is the complete code for steps 4 and 5.

// 是否镜头跟随NPC移动
const cameraFollow = true 


initController () {
  // 状态记录器
  const target = { t: 0 }
  // 获取第一段线段的移动时长,具体实现就是两个坐标点的距离除以速度参数speed
  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的位置
        NPC.position.copy(position)
      }


      // 需要镜头跟随
      if (cameraFollow) {
        // 计算两个lngLat端点的中间值
        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)
      }


      // 更新地图朝向
      if (cameraFollow) {
        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 duration = this.getMoveDuration()
      // 重新出发
      target.t = 0
      this._rayController
        .stop()
        .to({ t: 1 }, duration)
        .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) {
   // duration = 0 防止画面抖动
   this.map.panTo([positionLngLat.x, positionLngLat.y], 0)
}


//更新地图旋转角度
updateMapRotation (angle) {
  if (Math.abs(angle) >= 1.0) {
    this.map.setRotation(angle, true, 0)
  }
}


/**
 * 计算从当前位置到目标位置的移动方向与y轴的夹角
 * 顺时针为正,逆时针为负
 * @param {Object} origin 起始位置 {x,y}
 * @param  {Object} target 终点位置 {x,y}
 * @returns {number}
 */
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 // 将角度转换为与y轴的夹角
  const res = angle >= -180 ? angle : angle + 360 // 确定顺逆时针方向
  return res * -1
}

6. Finally, integrate the NPC layer and the 3DTiles layer as much as possible, add a real-scene layer of the 3D slice, and manually adjust the path data to match the real scene.

// 添加卫星影像地图
const satelliteLayer = new AMap.TileLayer.Satellite()
getMap().add([satelliteLayer])


//创建3D切片图层
const layer = new TilesLayer({
    map: getMap(),
    center: mapConf.center,
    zooms: [4, 22],
    zoom: mapConf.zoom,
    interact: false,
    tilesURL: mapConf.tilesURL
})

Related Links

Drag-and-drop driving route planning

https://lbs.amap.com/demo/javascript-api/example/driving-route/route-can-be-dragged/

Loca lens animation

https://lbs.amap.com/demo/loca-v2/demos/cat-view-control/view-control

Three.js specified path roaming

https://blog.csdn.net/u010657801/article/details/129754337

Formatted code blocks can be viewed at this link

https://juejin.cn/post/7238439667137593403

This article is provided by Zhang Linhai, a member of the Amap Developer Base Camp

Represents only the author's personal views

Recommended reading

Follow "Amap Technology" to learn more

Guess you like

Origin blog.csdn.net/amap_tech/article/details/131714361