天天看點

在高德地圖實作自動巡航

作者:閃念基因

介紹

最近我接到了一個場景需求,需要在高德地圖上實作仿照自動駕駛導航界面的自動巡航效果,相對于場景BIM模組化,這是一個較低成本的解決方案。

需求很簡單,隻要在3DTiles圖層上展示主體車輛(後文簡稱為NPC)沿着既定的路徑平滑移動就可以了,這裡不用考慮NPC與場景碰撞問題,是以可以直接把NPC和場景拆分為兩個獨立圖層。關于在高德地圖實作3DTiles的思路之前的文章已經分享過了,這裡着重介紹一下NPC移動要如何實作。

需求分析

首先,我們需要擷取資料并生成移動路徑,然後繪制巡航軌迹。為了實作巡航效果,我們需要加載并放置模型,并在每一幀重新設定模型的位置和正面朝向。同時,随着模型移動,我們需要更新鏡頭的位置和朝向。于是提取出如下的實作步驟:

1.擷取資料,生成移動路徑,并繪制巡航軌迹;

在高德地圖實作自動巡航

2.加載、放置、調整模型;

在高德地圖實作自動巡航

3.移動模型,在每一幀重新設定模型的位置、正面朝向;

在高德地圖實作自動巡航

4.随着模型移動,更新鏡頭的位置、以及鏡頭看向的位置;

在高德地圖實作自動巡航

5.最後讓NPC圖層和3DTiles圖層盡量融合

在高德地圖實作自動巡航

技術點分析

主體沿軌迹移動

threejs實作沿着軌迹移動有兩種做法,方法一是預先計算好整條路線裡,每個的關鍵節點之間的中間插值點(如圖中綠點所示),得到一系列的坐标之後,隻需要在每一幀将主體移動到插值點的位置就可以了,這個方法需要插值點足夠密集,否則最終效果會不夠平滑,車子會像電子躍遷一樣跳着走。

在高德地圖實作自動巡航

另一個方法就是動态計算,我們每次隻考慮兩個關鍵點之間的時間與位置關系,即輸入起點A、終點B、移動的總時間、移動的速度曲線(勻速、加速、緩動緩停等等),然後就可以根據目前時刻在總時長的進度獲得NPC應該在的位置。

在高德地圖實作自動巡航

鏡頭跟随

為了實作物體的運動和鏡頭跟随,我嘗試了高德資料可視化API提供的ViewControl鏡頭動畫,以及基于threejs的移動方案,最終選擇了threejs和高德API結合的方案,以下是技術方案的對比:

在高德地圖實作自動巡航

兩條路線的焊接

本文示範頁面的路徑資料使用高德,拖拽導航插件AMap.DragRoute。通過滑鼠拖拽已有導航路徑上的任一點,可以實作導航起點、途經點、終點的調整,系統根據調整後的起點、途經點、終點資訊,實時查詢拖拽後的導航路徑。然而發現傳回的路徑資料裡,并不包括兩段路線之間的連線,這種情況通查出現在紅綠燈、十字路口處,需要自己處理。為避免幹擾文章主題,這裡隻要知道有這個情況就行,路線焊接的具體編碼以後再講。

在高德地圖實作自動巡航

直接用線段連接配接LineA的終點和LineB的起點,會導緻物體移動朝向異常,鏡頭轉動也比較突兀,是以當兩個端點距離大于某個門檻值時,需要提供一個方法可以自動“焊接”兩條線段。平滑焊接路線的目的是為了讓物體移動更加平滑,我們可以選擇預先處理,或者實時處理,視情況而定。這裡會遇到幾種情況,我們分别處理:

LineA和LineB延長線必定相交,需要生成一條平滑的貝塞爾曲線連接配接這兩個端點;

在高德地圖實作自動巡航

LineA和LineB處于同一條直線,隻需要将兩個端點連接配接起來即可;

在高德地圖實作自動巡航

LineA和LineB平行,則需要生成半個圓角矩形的邊線将端點連接配接起來。

在高德地圖實作自動巡航

路徑坐标資料支援海拔

路徑資料為什麼要支援海拔高度?因為3D切片模型接近現實中的場景,并不是一個理想平面,而是有一定的坡度的,如果全程維持海報為0的路線行走,會發現車子有時候會突然遁地。另一方面,使移動路徑支援海報高度也便于擴充,目前是開車,後面如果有需求改成開船、開飛機也能應對自如。

在高德地圖實作自動巡航

代碼實作

1.擷取資料,生成移動路徑,并繪制巡航軌迹;

jsx複制代碼//最終路徑資料
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.将GeoJSON資料合并成一整條路線資料,并預處理好資料

jsx複制代碼// 合并後的路徑資料(空間坐标)
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.加載、放置、調整模型;

jsx複制代碼// 加載主體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.重點來了!移動模型,并更新NPC的位置和朝向、更新鏡頭的位置和朝向;

這裡使用了TWEEN做移動狀态的控制器,它控制的是一整條路線(A-B-C-D…)裡兩個關鍵點(A和B)連線的移動狀态,當連線AB的移動結束後,立即開啟下一個連線BC,以此類推。我們簡化過一下實作邏輯。

jsx複制代碼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.随着模型移動,更新鏡頭的位置、以及鏡頭朝向的位置;

(1)更新鏡頭位置與更新NPC位置思路一樣,不同的就是使用了地理坐标去計算中間插值,以友善直接調用高德的map.panTo(), 用map.setCenter()也是一樣的。

jsx複制代碼
// 計算兩個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)更新鏡頭朝向,朝向其實就是矢量方向,2個點确定矢量,在這裡取NPC目前坐标和後面第四個關鍵點的坐标确定朝向,也可以根據實際情況而定。

jsx複制代碼//計算偏轉角度
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)
  }
}
           

這是步驟4和5的完整代碼

jsx複制代碼// 是否鏡頭跟随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.最後讓NPC圖層和3DTiles圖層盡量融合,增加3D切片的實景圖層,手動調整路徑資料(節點坐标、海拔高度)與實景吻合即可。

jsx複制代碼// 添加衛星影像地圖
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
})
           

待改進的地方

1.拐彎處鏡頭朝向的過渡仍不夠平滑,後面會考慮拐點處使用密集點坐标的方式代替TWEEN動态計算坐标的方式。

2.高德地圖遠處的天空盒限制了前方都實線,并且與實景無法很好地融合,目前還沒有合适的處理方法,不知道高德技術大佬們有沒有招。

在高德地圖實作自動巡航

3.光影效果還沒有加上,沒有影子的車會讓人感覺好像是懸空的;近處放大畫面時車輪的滾動動畫也還沒做。

在高德地圖實作自動巡航

作者:gyratesky

連結:https://juejin.cn/post/7238439667137593403

來源:稀土掘金

繼續閱讀