Cesium 一文优雅解决平滑插值/平滑移动/实时轨迹/历史轨迹等问题

2,623 阅读5分钟

思路解析

我们很容易发现,标题中所提到的四项:平滑插值、平滑移动、实时轨迹、历史轨迹, 本质上可以理解为某个值随时间发生变化。

  1. 平滑插值:在0ms时候值为0,在1ms时值为1,在10ms时值为10......利用微分思想,很容易发现只需要随着时间改变值,并且每次改变间隔短到人类难以感知,那么就能认为这是平滑的值。
  2. 平滑移动:和平滑插值相同,只是把的概念缩小到坐标
  3. 实时轨迹:随着时间推进,进行平滑插值。在上面平滑插值的概念中,我们的思想是值随着时间连续变化即为平滑,而在实时轨迹中,只是把时间确定为实时的。
  4. 历史轨迹:和实时轨迹类似,也是在平滑插值的基础上确定时间,只是这次把时间确定在一个区间内。

也就是说,只需要有这样一个黑盒,就可以实现上面所有问题:

一个将时间映射到值,并平滑过渡的黑盒。

模型参考:

时间注释
0a0时间没有单位,这里的时间是抽象概念
0 + 0.5a0 + (a1 - a0) * 0.5插入 a0 和 a1 后,黑盒内部生成了过渡的线性映射 a0.5
1 (单位1)a1
......
n-1 (n为整数)an-1
n-1 + dn (0<dn<1)an-1 + (an - an-1) * dn这一步很关键,意味着黑盒内部根据相邻映射,自动生成过渡的线性映射
nan

根据上面这个表格所展示的逻辑,你已经能完美实现一个这样的方法。幸运的是,Cesium已经为我们提供了现成的方法:SampledProperty

SampledProperty

文档 SampledProperty - Cesium Documentation

由于 SampledPropertyProperty 的一种,意味着你能在 Entity的大部分属性中使用他。

官方示例

过一下官方示例:

//Create a linearly interpolated Cartesian2
const property = new Cesium.SampledProperty(Cesium.Cartesian2);

//Populate it with data
property.addSample(Cesium.JulianDate.fromIso8601('2012-08-01T00:00:00.00Z'), new Cesium.Cartesian2(0, 0));
property.addSample(Cesium.JulianDate.fromIso8601('2012-08-02T00:00:00.00Z'), new Cesium.Cartesian2(4, 7));

//Retrieve an interpolated value
const result = property.getValue(Cesium.JulianDate.fromIso8601('2012-08-01T12:00:00.00Z'));

上文举了个时间映射到Cartesian2的例子,接下来我把他翻译为人话:

第二行 new Cesium.SampledProperty

在示例中,进行实例化时传入了Cesium.Cartesian2,这里传入了一个构造函数。我们很容易联想到在Vue中,我们也是这么做的:

defineProps({
  foo: Number,
  bar: String,
})

我们如果想将时间映射到数字,只需要 :

const property = new Cesium.SampledProperty(Number)

第五、六行 property.addSample

其传入的第一个参数为 JulianDate 用于代表时间, 第二个参数为实例化时构造函数所构造的值。也就是我所说的时间映射到值。我们如果想要将时间映射到数字,只需要 :

property.addSample(Cesium.JulianDate.fromDate(new Date('1995-12-17T03:24:00')), 0)
property.addSample(Cesium.JulianDate.fromDate(new Date('2024-12-17T03:24:00')), 10000)

第九行 property.getValue

这个函数传入一个 JulianDate,返回这个时间对应的值。

实践 - 平滑转动

利用上面内容,我们可以为 Entity 添加一个平滑的 orientation。首先找到其构造函数,在这里可以看到 Quaternion - Cesium Documentation

假设我们有这样一份数据,表示某时间戳的航向角度和位置:

[
  { time: 1000000000, heading: 0, lon:100, lat: 20 },
  { time: 2000000000, heading: 90, lon:100, lat: 20 },
  { time: 3000000000, heading: 180, lon:100, lat: 20 },
]

可以像这样实现一个实体平滑转动的效果:

const orientation = new Cesium.SampledProperty(Cesium.Quaternion)
// DATA是上面的数据
DATA.forEach(({ time, heading, lon, lat }) => {
  orientation.addSample(
    Cesium.JulianDate.fromDate(new Date(time)),
    Cesium.Transforms.headingPitchRollQuaternion(
      Cesium.Cartesian3.fromDegrees(lon, lat),
      new Cesium.HeadingPitchRoll(
        Cesium.Math.toRadians(heading),
        Cesium.Math.toRadians(0),
        Cesium.Math.toRadians(0),
      ),
    ),
  )
})

viewer.entities.add({
  // 其实你可以直接把SampledProperty赋值给entity
  orientation,
  // 也可以利用CallbackProperty
  // orientation: new Cesium.CallbackProperty(time => orientation.getValue(time), false)
})

Cesium.SampledPositionProperty

文档 SampledPositionProperty - Cesium Documentation

Cesium公开了一个专门用于位置的SampledProperty,实际上这个用法和上面相同,只是仅用于Cartesian3作为值的映射。这里就写一个示例,没有必要细说了。

const property = new Cesium.SampledPositionProperty();

property.addSample(
  Cesium.JulianDate.fromIso8601('2012-08-01T00:00:00.00Z'), 
  new Cesium.Cartesian3(0, 0, 0)
);
property.addSample(
  Cesium.JulianDate.fromIso8601('2012-08-02T00:00:00.00Z'), 
  new Cesium.Cartesian3(10000, 20000, 30000)
);

// 其实你可以直接把他赋给entity
viewer.entities.add({
  position: property
})

const result = property.getValue(Cesium.JulianDate.fromIso8601('2012-08-01T12:00:00.00Z'));

实战

这里使用到了 cesium-use 这个vue库的 useTimeline - CesiumUse 方法,用于方便控制时间。

假设我们会从WebSocket中获取这样的数据,表示某时间戳的航向角度和位置:

{ 
  id: '这是id!',
  time: 1000000000, 
  heading: 0, 
  lon:100, 
  lat: 20 
},

这段代码制作了一个实时定位的平滑移动,细节解释请查看注释:

import * as Cesium from 'cesium'
import { useTimeline, toCartesian3, useViewer } from 'cesium-use'
import { useWebSocket } from '@vueuse/core'

const {
  play,
  // ...
} = useTimeline()

play()

const map = new Map<string, Cesium.Entity>()

const { data: _data } = useWebSocket('ws://呀哈哈,你发现我了!')

watchEffect(() => {
  const { id, time, heading, lon, lat } = JSON.parse(_data.value)
  if(!id)
    return
  
  const e = map.get(id) ?? map.set(id, createEntity()).get(id)
  
  const t = Cesium.JulianDate.fromDate(new Date(time))
  const coordinate = toCartesian3(lon, lat))
  
  e.position.addSample(t, coordinate)
  e.orientation.addSample(
    t,
    Cesium.Transforms.headingPitchRollQuaternion(
      coordinate,
      new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(heading), 0, 0)
    )
  )
})

const viewer = useViewer()
function createEntity() {
  const position = new Cesium.SampledPositionProperty()
  const orientation = new Cesium.SampledProperty(Cesium.Quaternion)
  
  return viewer.entities.add({
    position,
    orientation
  })
}

就是这么简单。也可以直接看 useTimeline - CesiumUse 的demo,可以看到demo源码中也使用了类似的实现。

对于历史轨迹的情况,一般来说你可以隐藏实时定位的entity。

import { useViewer, syncEntityCollection } from 'cesium-use'

const viewer = useViewer()

const collection = syncEntityCollection(viewer.entities)

// ...

function createEntity() {
  const position = new Cesium.SampledPositionProperty()
  const orientation = new Cesium.SampledProperty(Cesium.Quaternion)
  
  return collection.add({
    position,
    orientation
  })
}

// 在这里!
function toggleShow() {
  collection.show = !collection.show
}

如果需要两者同时存在,只需要创建一个独立于viewer.clock的时间即可:

import * as Cesium from 'cesium'
import { useTimeline, useEventHandler, useViewer } from 'cesium-use'

const viewer = useViewer()

const clock = new Cesium.Clock()
// Cesium有一个坑点在于,对于新建的Clock,你必须手动调用tick()方法,否则时间是无法流动的
// 这里在preUpdate时调用
const onPreUpdate = useEventHandler(viewer.scene.preUpdate)
onPreUpdate(() => clock.tick())

// 然后正常使用
const { play } = useTimeline(clock)

// ...其他一样