Cesium 导航罗盘

4,157 阅读5分钟

前言

之前在搞智慧城市,项目的3D地图页面是第三方提供的,产品经理老过来提3D地图的需求,搞得我三天两头就对接第三方,第三方以这没办法做、那性能不行等理由搪塞(还没给钱,不做)。没办法自己着手搞起了3D地图,使用了CesiumJS地图框架开发。Cesium加载大量Label实体时卡顿的一种解决方法 之前这篇文章就是在做3D地图遇到的算比较大的问题的一个解决方案。

最近产品经理不知道去哪里又看到3D地图有罗盘可以实现视角移动,就抄了下来,问我能不能实现。哈哈,当时直接回他说:项目太急,没法实现。WDNMD(唯独你没懂)

唉,说归说,做还是TM得做,谁叫我是酷毙(苦逼)的开发呢?

效果

ezgif-6-c6883e81ad2b.gif

导航罗盘功能

image.png

在实现导航罗盘之前,必须先搞明白怎样操控罗盘,场景相机对应的要怎么移动。

罗盘区域的操作

这里先解决一个问题: 相机移动是相机位置移动还是,相机视角移动?

按照展示某个物体的情况,应该是环物体360°拍摄

所以我们这个罗盘控制相机就确定为 以屏幕中心的所在的位置为点,绕该点进行移动

以罗盘区域中心点作为原点

  1. 鼠标向左右移动, 绕相机视角区域中心点左右移动
  2. 鼠标向上下移动, 绕相机视角区域中心点上下移动

功能的实现

鼠标按下事件

鼠标按下时,需要记录相机视角区域的中心位置,记录时间(为了后面相机移动时,画面平滑)

      let result = new Cartesian3();
      const { camera, scene } = this.$refs.map.viewer;
      let dom = e.target;
      const rayScratch = new Ray();
      rayScratch.origin = camera.positionWC;
      rayScratch.direction = camera.directionWC;
      result = scene.globe.pick(rayScratch, scene, result);
      result = camera.worldToCameraCoordinatesPoint(result, result);
      const newTransformScratch = new Matrix4();
      
/* Transforms.eastNorthUpToFixedFrame
计算一个4x4变换矩阵,该矩阵从一个以提供的原点为中心的东北向上轴的参考坐标系到提供的椭球体
的固定参考坐标系。局部轴定义为:
x轴指向本地的东方向。
y轴指向本地北向。
z轴指向穿过该位置的椭球面法线方向。
*/
// 怎么理解呢? 
// 简单的讲,应该是:当用这个4x4矩阵设置相机的位置,那么相机的旋转是以4x4矩阵提供的原点作为旋转中心进行旋转,
// 当这个4x4矩阵为单位矩阵时,相机的旋转是位置的平移
      let frame = Transforms.eastNorthUpToFixedFrame(
        result,
        scene.globe.ellipsoid,
        newTransformScratch
      );
      let time = getTimestamp();
      document.addEventListener('mousemove', mousemoveHandler);
      document.addEventListener('mouseup', mouseupHandler);
      this.$refs.map.viewer.clock.onTick.addEventListener(
        orbitTickFunction
      );

鼠标移动事件

鼠标移动时,计算鼠标所在位置相对于罗盘原点的坐标值。

  1. 计算罗盘元素的中心点相对于罗盘元素区域的坐标 x0 = (right - left) / 2 y0 = (bottom - top) / 2 image.png

  2. 鼠标位置相对于罗盘元素的坐标Tx = e.clientX - left Ty = e.clientY - top image.png

  3. 有了相对位置和原点坐标,我们就可以构建一个二维向量了(为了计算夹角),使用CesiumJSCartesian2.subtract计算出二维向量

// dom 是罗盘dom
let compassR = dom.getBoundingClientRect();

// 点击元素的中心点xy
let center = new Cesium.Cartesian2(
(compassR.right - Cesium.compassR.left) / 2,
(compassR.bottom - Cesium.compassR.top) / 2
);

// 鼠标位置到mousedown元素的距离Tx, Ty
let movePosition = new Cesium.Cartesian2(
e.clientX - compassR.left,
e.clientY - compassR.top
);

// 计算Tx,Ty与元素中心点的距离
let vector = Cesium.Cartesian2.subtract(movePosition, center, new Cesium.Cartesian2());

由于subtract是参数1-参数2,得到的vector.x分量是对的,但我们规定y轴上半轴为正轴,而页面是往下为y轴正半轴,所以vector.y实际使用得取相反数。 image.png

计算角度

上面我们已经得到向量vector了, 下面我们需要计算出夹角的值了,使用Math.atan2(y,x)方法,可以计算出原点到坐标点的线与x轴正半轴的夹角弧度值。 但实际上,当我们鼠标沿y轴正半轴移动时,计算出来的夹角是90°,我们希望y轴正半轴指向的是正北(0°),逆时针方向为正方向,所以方位角应该是Math.atan2(y,x) - Math.PI / 2。区间[0, 2π]。 计算这个角度的作用是为了扇形的旋转。

image.png

// zeroToTwoPi 返回0~2π区间内的值
const angle = Cesium.Math.zeroToTwoPi(Math.atan2(-vector.y, vector.x) - Math.PI / 2);

计算扇区透明度

我们希望鼠标在罗盘中心时,扇区透明度50%,鼠标远离中心,扇区透明度逐渐增加,到达罗盘边缘时透明度为0(不透明)。

// 计算上面求出来向量vector,利用Cartesian2.magnitude 可以计算出长度
const distance = Cesium.Cartesian2.magnitude(vector);
// 中心点到罗盘边缘距离,半径
const maxDistance = compassR.width;
// 距离中心点的长度比,最大为1
const distanceF = Math.min(distance / maxDistance, 1.0);
// 使用ease缓动算法 t^2 * 系数 (这里系数0.5,因为扇区默认50%透明度)
let easedOpacity = 0.5 * distanceF * distanceF + 0.5;

让相机移动

前面我们已经计算出角度透明度,现在可以利用它们让相机旋转了

// 这部分代码是在Cesium的clock的tick时间帧里面执行的,
// 由于单线程原因,执行的时间间隔不一定是相等的,
// 所以每次相机每次旋转的幅度应该与时间间隔相关,否则在你旋转的时候,会有停顿感
const tempstamp = getTimestamp();
const deltaT = tempstamp - this.time;
// easedOpacity 是通过鼠标距离罗盘中心的长度比算出来的,
// 所以可以直接用easedOpacity计算得到旋转的速率。 
// 2.5 1000 这两个系数可以随便调整,这里参考了cesium-navigation
const rate = ((easedOpacity - 0.5) * 2.5) / 1000;
// 计算出移动的距离
const distance = deltaT * rate;

// 我们要利用这个角度,算出相机是往哪个方向旋转的。
// 这里要加上90°,是因为上面我们计算这个角度是线与y轴正半轴的夹角,
// 而Math.sin、Math.cos传入的角度是与x轴正半轴的夹角。
const angle = this.cursorAngle + CesiumMath.PI_OVER_TWO;
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
// lookAtTransform 设置相机视角朝向哪里
viewer.camera.lookAtTransform(frame);
viewer.camera.rotateLeft(x);
viewer.camera.rotateUp(y);
// Matrix4.IDENTITY 4x4单位矩阵,设置这个的作用,在上面`鼠标按下事件`部分讲了
viewer.camera.lookAtTransform(Matrix4.IDENTITY);

结尾

到这里,罗盘的功能就完成了, 本文参考了 cesium-navigation-es6cesium-navigation 实际上可以说上面代码的思路都来自于这两位作者,开源万岁!

完整代码

<template>
  <div class="home">
    <Map ref="map"/>
    <div class="ng" @mousedown="handleMousedown">
      <div
        class="shan"
        :style="{
          opacity: easedOpacity,
          transform: `rotate(${-cursorAngle + Math.PI / 4}rad)`,
        }"
      ></div>
      <div class="ng-box"></div>
    </div>
  </div>
</template>

<script>
import {
  Cartographic,
  Cartesian3,
  Matrix4,
  Cartesian2,
  Math as CesiumMath,
  Transforms,
  getTimestamp,
  Ray,
} from 'cesium/Build/Cesium/Cesium';
import Map from '@/components/Map.vue';
export default {
  components: {
    Map,
  },
  data() {
    return {
      vector2: undefined,
      cursorAngle: 0,
      easedOpacity: 0,
    };
  },
  created() {
    this.time = undefined;
    this.frame = undefined;
    this.dom = undefined;
  },
  methods: {
    handleMousedown(e) {
      let result = new Cartesian3();
      const { camera, scene } = this.$refs.map.viewer;
      this.dom = e.target;
      const rayScratch = new Ray();
      rayScratch.origin = camera.positionWC;
      rayScratch.direction = camera.directionWC;
      result = scene.globe.pick(rayScratch, scene, result);
      result = camera.worldToCameraCoordinatesPoint(result, result);
      const newTransformScratch = new Matrix4();
      this.frame = Transforms.eastNorthUpToFixedFrame(
        result,
        scene.globe.ellipsoid,
        newTransformScratch
      );
      this.time = getTimestamp();
      document.addEventListener('mousemove', this.mousemoveHandler);
      document.addEventListener('mouseup', this.mouseupHandler);
      this.$refs.map.viewer.clock.onTick.addEventListener(
        this.orbitTickFunction
      );
    },
    mouseupHandler() {
      document.removeEventListener('mousemove', this.mousemoveHandler);
      document.removeEventListener('mouseup', this.mouseupHandler);
      this.$refs.map.viewer.clock.onTick.removeEventListener(
        this.orbitTickFunction
      );
      this.easedOpacity = 0;
    },
    mousemoveHandler(e) {
      const rect = this.dom.getBoundingClientRect();
      const center = new Cartesian2(
        (rect.right - rect.left) / 2,
        (rect.bottom - rect.top) / 2
      );
      const movePosition = new Cartesian2(
        e.clientX - rect.left,
        e.clientY - rect.top
      );
      const vector2 = Cartesian2.subtract(
        movePosition,
        center,
        new Cartesian2()
      );
      this.computeAngleAndOpacity(vector2, rect.width);
    },
    computeAngleAndOpacity(vector2, domWidth) {
      const angle = Math.atan2(-vector2.y, vector2.x);
      this.cursorAngle = CesiumMath.zeroToTwoPi(angle - CesiumMath.PI_OVER_TWO);

      const distance = Cartesian2.magnitude(vector2);
      const maxDistance = domWidth / 2;
      const distanceF = Math.min(distance / maxDistance, 1.0);
      this.easedOpacity = 0.5 * distanceF * distanceF + 0.5;
    },
    orbitTickFunction() {
      const tempstamp = getTimestamp();
      const deltaT = tempstamp - this.time;
      const rate = ((this.easedOpacity - 0.5) * 2.5) / 1000;
      const distance = deltaT * rate;
      const angle = this.cursorAngle + CesiumMath.PI_OVER_TWO;
      const x = Math.cos(angle) * distance;
      const y = Math.sin(angle) * distance;
      this.$refs.map.viewer.camera.lookAtTransform(this.frame);
      this.$refs.map.viewer.camera.rotateLeft(x);
      this.$refs.map.viewer.camera.rotateUp(y);
      this.$refs.map.viewer.camera.lookAtTransform(Matrix4.IDENTITY);
      this.time = tempstamp;
    },
  },
};
</script>

<style lang="scss" scoped>
.home {
  width: 100%;
  height: 100%;
  position: relative;
}
.option {
  position: absolute;
  top: 40px;
  left: 40px;
  .switch {
    border: 1px solid;
    background-color: transparent;
    text-transform: uppercase;
    font-size: 14px;
    padding: 10px 40px;
    font-weight: 300;
    cursor: pointer;
    border-radius: 4px;
  }
  .open {
    background-color: #4cc9f0;
    color: #fff;
    -webkit-box-shadow: 10px 10px 99px 6px rgba(76, 201, 240, 1);
    -moz-box-shadow: 10px 10px 99px 6px rgba(76, 201, 240, 1);
    box-shadow: 10px 10px 99px 6px rgba(76, 201, 240, 1);
    &:hover {
      background-color: unset;
      color: #4cc9f0;
      -webkit-box-shadow: 10px 10px 99px 6px rgb(21, 22, 22);
      -moz-box-shadow: 10px 10px 99px 6px rgb(21, 22, 22);
      box-shadow: 10px 10px 99px 6px rgb(21, 22, 22);
    }
  }
  .close {
    color: #4cc9f0;
    &:hover {
      background-color: #4cc9f0;
      color: #fff;
      -webkit-box-shadow: 10px 10px 99px 6px rgba(76, 201, 240, 1);
      -moz-box-shadow: 10px 10px 99px 6px rgba(76, 201, 240, 1);
      box-shadow: 10px 10px 99px 6px rgba(76, 201, 240, 1);
    }
  }
}
.label {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  margin: 20px auto 0;
  font-size: 24px;
  color: #fff;
}
.change-mode-btn {
  position: absolute;
  right: 10px;
  top: 10px;
  width: 150px;
  height: 40px;
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 4px;
  border: 1px solid #fff;
  color: #fff;
  background-color: #26354a;
  cursor: pointer;
}
.ng {
  position: absolute;
  bottom: 30px;
  right: 30px;
  width: 200px;
  height: 200px;
  .ng-box {
    position: absolute;
    width: 100%;
    height: 100%;
    background-image: url('~@/assets/image/ng.png');
    background-size: 100% 100%;
    pointer-events: none;
  }
  .shan {
    position: absolute;
    width: 35%;
    height: 35%;
    right: 50%;
    bottom: 50%;
    transform-origin: 100% 100%;
    border-top-left-radius: 70%;
    background-color: #4cc9f0;
    pointer-events: none;
  }
}
</style>