地图交互与姿态控制

1,321 阅读10分钟

导读

阅读完此文,你会了解:

1、如何生成地图事件
2、如何做GPU图形拾取
3、如何计算地图姿态参数
4、与地图渲染边界有关的优化
5、如何生成复杂地图动画

地图的交互性能够表达更加丰富的数据信息,增强对受众的吸引力和受众对数据的理解。地图交互包含平移、旋转、倾斜、缩放和对图形的拾取等,这些交互都需要将一系列鼠标原生事件抽象和参数转换,最后将参数应用到地图的相机中,实现控制地图姿态的目的。

地图本身的姿态和飞行器飞行姿态类似,如下图:

  

                                                           飞行器姿态

地图的旋转对应飞行器的yaw(偏航)动作,地图的倾斜对应飞行器的pitch(俯仰)动作,地图没有对应的飞行器的roll(滚转)动作,地图的这些姿态都可以由地图透视相机的位置和yaw、pitch进行变换计算得到。接下来,我们将详细讲解如何将这些事件进行抽象、分发、拾取以及进行姿态控制。

地图交互原理

事件抽象

鼠标原生事件有click、dblclick、wheel、mousemove等,对于地图来说,简单的事件可直接使用鼠标原生事件触发地图事件;对于复杂的事件,比如,dragStart、drag、dragEnd、zoomStart、zoom、zoomEnd等,都是在鼠标原生事件基础上组合触发的。

地图的drag事件的一个周期,开始于鼠标左键点击地图,触发mousedown事件;然后鼠标移动,触发一次地图的dragStart;接下来鼠标在"mousedown"的状态持续移动,触发mousemove事件,同时连续触发地图的drag事件;最后,鼠标左键抬起,触发mouseup,并触发一次地图的dragEnd事件,结束一个drag的事件周期。经过连续的mousedown和mousemove两个事件的组合才能构成一个完整的地图drag事件,中间还需要设置一些标志量来确保合成地图事件的正确性。

                                                           事件生成流程

事件生成代码示例:

let isMouseDown = false
let isMouseMove = false

mapContainer.addEventListener('mousedown', onMouseDown)
mapContainer.addEventListener('mouseup', onMouseUp)
mapContainer.addEventListener('mousemove', onMouseMove)

function onMouseDown () {
  map.emit('mousedown')
  isMouseDown = true
}

function onMouseUp () {
  map.emit('mouseup')
  isMouseDown = false 

  if (isMouseMove) {
    map.emit('dragEnd')
    isMouseMove = false
  }
}

funciton onMouseMove () {
  map.emit('mousemove')

  if (isMouseDown) {
    if (!isMouseMove) {
      map.emit('dragStart')
    }
    map.emit('drag')
    isMouseMove = true
  }
}

对于zoom事件,它是由鼠标或者触摸板的wheel、dblclick触发的。当由鼠标滚轮或触摸板触发wheel事件时,将会触发一次zoomStart事件;在下钻动画的过程中,将持续触发zoom事件;下钻动画结束,将触发一次zoomEnd事件。当由鼠标dblclick事件触发时,也会同时触发一次click事件,所以需要与click事件做区分,将click延时一定时间执行,如果触发了dblclick,就将click的定时器清除:

let timer

mapContainer.addEventListener('click', onClick)
mapContainer.addEventListener('dblclick', onDblclick )

function onClick () {
  if(timer) {
    clearTimeout(timer)
    timer = undefined
  }

  timer = setTimeout(() => {
    // do domething
    map.emit('click')
  }, delay)
}

function onDblclick () {
  if (timer) {
    clearTimeout(timer)
    timer = undefined
  }

  // do something
  map.emit('dblclick')
  map.emit('zoom')
}

触摸板滑动事件与鼠标滚轮事件类似,但是不完全相同。触摸板滑动事件是由mac的触摸板触发的,和鼠标滚轮事件相比,触摸板滑动的触发更加密集,如果不做单独的优化,地图的下钻不会很平滑,达不到mac触摸板应有的体验。我们参考了mapbox-gl-js的处理逻辑来区分触摸板滑动和鼠标滚轮:github.com/mapbox/mapb… 。

事件分发

将事件进行抽象之后,接下来就是事件分发到各个图层,不同的图层根据事件做自己的逻辑处理。map中维护一个数组存储layer,遍历layer,为各个layer分发事件。

function onMapEventName(…args) {
  layers.forEach(function(layer) {
    layer.emit(eventName, …args)
  })
}

function onLayerEventName() {
  // do something
}

map.on(eventName, onMapEventName)
layer.on(eventName, onLayerEventName)

图形拾取

在ThreeJS中,如果要拾取某个图形,是通过Raycaster在CPU中实现的。如果图形在CPU中已经确定了坐标,从相机的位置到鼠标交互位置的世界坐标发射一条射线做碰撞,世界中的哪个对象能和射线相交,就说明鼠标现在拾取到了这个对象;然后按照被拾取对象与射线的距离进行排序,就能拾取到离鼠标最近的对象了。这种方式的缺陷是对于在GPU中确定大小和形状的图形无法很好检测。

GPU拾取是解决上述问题的一个方法。GPU拾取的一种方法是通过对渲染像素的读取确定被拾取的对象。

首先是图形id的维护。为了确定拾取的图形,需要为图形编号。编号id用到了颜色的r,g,b三个通道,每个通道8位,一共24位,最多能生成2^24种颜色。需要维护一个数组来记录哪个id已经被使用了。如果某个id被使用了,就将数组这个位置的值赋值为1,否则为0;为了减少生成id时检索哪个id可用的过程,维护了一个游标来记录当前的位置,每次生成id都从游标位置开始检索,直到检测到最大可用id;如果最大id不可用,就从0开始再次检索;如果循环了一轮都无法生成新id,就会抛出异常“id已被用尽”;一般来说,千万级别的id已经够用了;当删除id的时候,就把数组中这个id的值记为0,表示该id可重新被使用。

对于每个mesh,geometry需要添加一个attribute存储颜色id;material需要增加一个宏定义PICK,然后在片元着色器中根据这个宏定义进行判断,确定是图形着色值是渲染的颜色值还是做拾取的id值。

比如id为0x000001,作为拾取的mesh的颜色就是#000001。把这些作为拾取的mesh单独做离屏渲染,而不是渲染到canvas。鼠标做拾取的时候,读取鼠标拾取坐标点(x, y)在THREE.WebGLRenderTarget对应的像素值,然后把这个像素值转换为对应的id,就能知道当前鼠标拾取的是哪个物体了。因为拾取的是像素,对于不规则的物体,拾取也是很精确的。

                                                              GPU拾取细线

颜色与图形id编码原理:

/**
  * id转颜色
  * @param {Number} id
  */
function packID (id) {
  const r = id >> 16
  const g = (id - (r << 8)) >> 8
  const b = id - (r << 16) - (g << 8)
  return [r, g, b]
}

/**
  * 颜色转id
  * @param {Number} r
  * @param {Number} g
  * @param {Number} b
  */
function unpackID (r, g, b) {
  return (r << 16) + (g << 8) + b
}

着色器部分代码:

//顶点着色器
attribute vec4 a_color;
attribute vec3 a_idColor;

varying vec4 v_color;
varying vec3 v_idColor;

void main () {
  v_color = a_color;
  v_idColor = a_idColor;
  gl_Position = projectionMatrix * modelViewMatrix * position;
}
// 片元着色器
varying vec4 v_color;
varying vec3 v_idColor;

void main() {
  #if defined PICK
    gl_FragColor = vec4(vec3, 1.0);
  #else
    gl_FragColor = v_color; 
}

姿态控制

地图的姿态由center、zoom、pitch、yaw的值来控制。center用来确定当前地图中心的位置,zoom确定地图的层级,pitch确定地图的倾斜角度,yaw确定地图的旋转角度。

参数计算

center计算

鼠标左键的drag事件用来平移地图,即改变地图的中心位置center:

                                                             地图拖拽

由图中可以看到,不同的层级下,鼠标拖拽地图,鼠标位置始终是在“国贸”位置上。不同层级的分辨率不同,所以应当计算不同层级鼠标位置以“米”为单位的差值,作用到center上:

function normal(pos, containerWidth, containerHeigth) {
  const vec2 = new Vector2()
  vec2.setX((pos.x / containerWidth) * 2 -1)
  vec2.setY(-(pos.y / containerHeight) * 2 + 1)
  return vec2
}

function normalToWorld (vec2, camera) {
  raycaster.setFromCamera(vec2, camera)
  const intersect = raycaster.intersectObject(planeXOZ) // 计算与地图平面的交点

  if (intersect.length > 0) {
    return new Vector3(intersect[0].point.x, 0, intersect[0].point.z)
  }
}

// 计算以“米”为单位的偏移值
const start = normalToWorld(normal(mouseStart), canera)
const end = normalToWorld(normal(mouseEnd), canera)
const offset = [start.x - end.x, start.y - end.y]

newCenter[0] = center[0] + offset[0]
newCenter[1] = center[1] + offset[1]

newPosition[0] += center[0] + cameraPosition[0]
newPosition[1] = cameraPosition[1]
newPosition[2] += center[1] + cameraPosition[2]

camera.setTarget(newCenter)
camera.setPosition(newPosition)

zoom计算

当zoom由wheel事件触发时,可以由WheelEvent对象的deltaY属性值计算到此次zoom动作产生的zoomDelta。

zoomDelta = deltaY * scale // scale是一个系数,防止zoomDelta过大

当zoom由dblclick事件触发时,一般来说,每次dblclick下钻一个层级。

zoomDelta = 1

不同的层级透视相机距离center的距离不同,所以就能观察到不同尺寸的地图要素,并能根据透视相机视锥体与地图平面的交点确定下一层级瓦片的加载。

pitch和yaw计算

pitch和yaw是由鼠标右键触发地图drag事件的同时改变的。yaw值随着鼠标x方向坐标改变,pitch值随着鼠标y方向坐标改变。

                                                             pitch操作

pitch的变化可看做鼠标y坐标像素变化值deltaY在以半径R的圆上运动产生的弧度变化。可每隔一段时间取一次鼠标的y坐标,和上一次鼠标y坐标比较,计算出deltaY;R值可由地图容器宽高的最小值得出。

R = min(containerWidth, containerHeight)
deltaY = lastY - currentY
deltaPitch = deltaY / R // pitch变化的弧度值

同理,yaw可取鼠标两次变化的x的差值deltaX来计算变化的弧度。由上图可看出,和pitch不同的是,鼠标在地图容器上半部分和下半部分的操作是相反的,所以需要判断鼠标操作是在地图容器的上半部分还是下半部分。

                                                          yaw操作

计算完pitch和yaw就能应用到相机中改变地图姿态了。鼠标进行pitch和yaw操作时,是围绕中心center进行的,相机的position可看做是球面的一点,position到target的距离可看做一个球的半径,pitch、yaw变化可看做是position在球面上运动。根据pitch、yaw计算出新的position给相机,即可观察到地图新的姿态。

渲染边界优化

视窗范围确定

                                                           相机与瓦片加载

由上一篇《数据源与存储计算》中可知相机与瓦片加载之间的关系。如果相机倾斜角度过大,视野远处的瓦片也会被加载,在透视相机视野内很难看清楚远处的瓦片渲染内容,产生无意义

的渲染,耗费性能。经过调研和测试,发现将倾角pitch限制在[0, Math.PI / 3]之内是较为合理的。

渲染边界优化

将pitch设置到最大的角度Math.PI / 3,透视相机的视锥体与地图平面相交的四边形没有被地图瓦片充满,会有空白部分,如下图红框所示:

                                                              添加天空之前

所以需要加上天空色来覆盖产生的空白。天空增加之后如下图:

                                                             添加天空之后

为了保证天空不受地图姿态的影响,所以需要单独的scene和camera。天空是一个面,不会有透视效果,所以相机选择正交相机。在地图pitch变化时,动态改变天空mesh的y坐标即可;地图yaw变化时,由于高度不变,所以不需要改变天空的状态。

复杂动画

                                                              焦点转换

                                                                聚焦

                                                              相机拉远

地图库内部也提供了多种复杂动画,包括焦点转换、聚焦、相机拉远等。实现这些动画,需要维护一个参数数组,当数组有参数对象时,便会执行动画;前一个参数对象执行完毕后,接着执行下一个参数对象。

每一个参数对象由姿态控制参数center、zoom、pitch、yaw和动画控制参数delay、duration、easeFunc、repeat、callback组成,将姿态控制参数在动画控制参数时间内进行插值,可得不同的动画。

let animating = false
let animations = [param0, param1, param2 ...] // 动画参数

function animate() {
  const { stateParam, animaParma } = animations[0]
  const { duration, delay, repeat, easeFunc, callback } = animaParma
  const currentParam = getCurrentParam() // 获取当前地图姿态参数

  // 当前姿态参数向下一个姿态参数插值
  const tween = new TWEEN.Tween(currentParam)
    .to(stateParam, duration)
    .delay(delay)
    .repeat(repeat)
    .ease(easeFunc)
    .onStart(() => {
      animating = true
    })
    .onUpdate((param) => {
      updateMapCamera(param) // 更新地图透视相机参数
    })
    .onComplete() {
      animating = false
      animations.shift()
      callback()
    }

  tween .start()
}

function loop() {
  if (!animating && animations.length) {
    animate()
  }

  TWEEN.update()

  requestAnimationFrame(loop)
}

loop()

请期待下一期:《地图文字渲染》

往期回顾

《打造服务B端客户的酷炫3D地图可视化产品》

《数据源与存储计算》