在高德地图实现自动巡航
介绍
最近我接到了一个场景需求,需要在高德地图上实现仿照自动驾驶导航界面的自动巡航效果,相对于场景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复制代码// 加载主体NPCfunction 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
来源:稀土掘金
本站部分内容来源于互联网,仅供学习交流之用。如有侵犯您的版权,请及时联系我们,我们将尽快处理。