mapbox 瓦片实现平滑播放

2,636 阅读5分钟

一、需求

我们在气象行业中有个常见的需求是实现气象数据(温、湿、风、压等)的连续播放以查看一段时间内的数据变化趋势

二、方案

解决方案

  1. GeoJSON:geojson数据,将所需的渲染数据处理成单个geojson数据(等值线和等值面算法处理)加载,使用 geojson source 来加载对应的数据,在加载下一帧数据前我们可以通过 ajax 来预请求数据,可以实现平滑播放。
  2. SingleImage:单张图片数据,将所需的数据直接渲染成单个的墨卡托投影的图片数据,使用 ImageSource 来加载,在下一帧数据渲染我们也可以使用图片预加载(使用 image load),同样地址的数据ImageSource会默认先走缓存 ,这样也可以实现图片的渲染的平滑播放。
  3. VectorTile:矢量瓦片方案,此方案是将第一种方案的 geojson 数据进行切片,使用 VectorTileSource来进行数据加载。

优缺点

方案优点缺点
GeoJSON控制简单,缓存简单,可以进行预加载单帧数据可能过大,有性能问题
SingleImage控制简单,缓存简单,可以进行预加载清晰度(渲染效果)问题,单帧数据大小问题
VectorTile单帧数据量小,加载速度高,渲染效果好控制复杂,目前无好的方案进行数据预加载

目前方案尝试

  1. 最早期方案是使用的 SingleImage 灰度图在前端插值渲染,但是无法实现等值线等值面渲染和标注,已废弃。

  2. 中期使用算法将格点数据生成等值面和等值面的 GeoJSON 数据加载渲染,但是测试时发现由于平滑度过高,文件大小超过10M,对网络传输有很大压力,单帧数据加载时间过长,最终废弃。

  3. 矢量瓦片方案,经过测试将 GeoJSON 数据切成瓦片后,可以做的单张瓦片大小 30kb 左右,然后每屏所需瓦片在 6 张左右,单屏数据大小在 180kb 左右,大大提高了加载性能,这是最终采用的方案;但是我们需要解决瓦片预加载问题和平滑替换渲染问题。

    image-20230308101629232

    image-20230308101540526

三、技术细节

在使用矢量瓦片有几个问题:

  1. 瓦片加载渲染问题

首先我们知道在mapbox-gl中矢量瓦片的加载渲染方式是每一张加载完就进入解析渲染,所以我们看到的将是一块一块的瓦片依次出现

无标题-2022-10-09-1000

如上图地图第一级瓦片共计 4 张(不考虑多世界),那么他渲染顺序可能 1=>2=>3=>4 (只是示意,不是完全按这个顺序)瓦片渲染结果依次出现,这样在切换瓦片时的视觉效果满足不了我们需求。

  1. mapbox 1.x并没有提供瓦片 tiles 的更新方法,在 mapbox 2.14.0中提供了 setTiles/setUrl 来更新瓦片链接,但是因为我们目前使用的是1.x版本,我们需要实现。

    目前针对 VectorTileSource 我们扩展了以下方法:

    // tiles 更新
    setTiles(tiles: Array<string>, clear = true) {
      // 取消 TileJSON 的请求,注意 2.x 后提供了 `cancelTileJSONRequest` 方法
      if (this._tileJSONRequest) {
        this._tileJSONRequest.cancel();
      }
    
      this._options.tiles = tiles;
      this.load();
    
      const sourceCache = this.map.style.sourceCaches[this.id];
      if (clear) {
        sourceCache.clearTiles();
      } else {
        sourceCache.reloadTiles();
        if (sourceCache.transform) {
          sourceCache.update(sourceCache.transform);
        }
        // 此处尝试过 `reload` 但是没有反应
        // sourceCache.reload();
        // if (sourceCache.transform) {
        //   sourceCache.update(sourceCache.transform);
        // }
      }
    
      return this;
    }
    
    // 判断当前瓦片是否已加载完成
    isTilesLoaded() {
      const sourceCache = this.map.style.sourceCaches[this.id];
      if (sourceCache) {
        return sourceCache.loaded();
      }
      return true;
    }
    

    针对 SourceCache我们做了以下更新:

    reloadTiles() {
      if (this._paused) {
        this._shouldReloadOnResume = true;
        return;
      }
      
      this._cache.reset();
    
      for (const id in this._tiles) {
        const tile = this._tiles[id];
        if (tile && tile.state !== 'errored') {
          this._reloadTile(id, 'expired');
        }
      }
    }
    

    注意上面扩展的类型,如果调用了 sourceCache.clearTiles() 会清空所有瓦片和 TileCache然后重新生成,但是这样会出现地图一闪一闪的;如果使用 sourceCache.reloadTiles() 会标识所有瓦片为过期并且重置TileCache 缓存,过期的瓦片内部会重新加载,但是持续的更新瓦片时,由于未知原因会出现错误的渲染(闪动),并且在 2.x中关联的 symbol 图层不会更新。

    并且需要注意的是 SourceCache 本身提供了 reload 方法,但是在尝试后发现瓦片数据并不会立即更新。

渲染流程追踪

整体流程:

diagram-11354439768745413320

矢量瓦片加载渲染流程:

diagram-18183349332433811895

问题排查

排查了相关瓦片计算逻辑和瓦片缓存逻辑,似乎都没有问题,但是最终渲染还是会抖动,那就只有可能是渲染层有问题,我们在

draw_fill.js 断点一下,执行 VectorTileSource.setTiles看一下触发几次渲染:

image-20230308163136707

image-20230308163708831

发现在setTiles 的时候TileJSON 的加载触发了多次重绘,但是实际上我们在图层创建后一般不需要由此再触发重绘,所以我们在 setTiles的时候添加一个条件,避免触发data事件:

load(fireEvent = true) {
  this._loaded = false;
  if (fireEvent) {
    this.fire(new Event('dataloading', {dataType: 'source'}));
  }
  this._tileJSONRequest = loadTileJSON(this._options, this.map._requestManager, (err, tileJSON) => {
    this._tileJSONRequest = null;
    this._loaded = true;
    if (err) {
      this.fire(new ErrorEvent(err));
    } else if (tileJSON) {
      extend(this, tileJSON);
      if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom);
      postTurnstileEvent(tileJSON.tiles, this.map._requestManager._customAccessToken);
      postMapLoadEvent(tileJSON.tiles, this.map._getMapId(), this.map._requestManager._skuToken, this.map._requestManager._customAccessToken);

      // `content` is included here to prevent a race condition where `Style#_updateSources` is called
      // before the TileJSON arrives. this makes sure the tiles needed are loaded once TileJSON arrives
      // ref: https://github.com/mapbox/mapbox-gl-js/pull/4347#discussion_r104418088
      if (fireEvent) {
        this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
        this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
      }
    }
  });
}

VectorTileSource.setTiles方法的 load 添加一个参数,标识是否触发事件:

this.load(clear);

此时我们再次执行 VectorTileSource.setTiles观察渲染结果发现抖动基本消失,猜测就是由于多次的错误触发重绘取出了缓存的瓦片数据造成渲染错误。

还有一种方案就是将多个时刻的序列数据压到同一个瓦片中,通过 filter过滤数据,这样多个时刻只会加载一次数据,播放会更流畅,但是相应的因为单张瓦片数据量大体积也会相应的变大:

map.addSource('weather', {
  type: 'vector',
  tiles: ['http://cdn.weather.com/data/temp/{z}/{x}/{y}.pbf']
});

const colorRamp = [
  [-2, 'rgb(0, 187, 255)'],
  [-1, 'rgb(0, 221, 255)'],
  [0, 'rgb(0, 255, 255)'],
  [5, 'rgb(0, 255, 128)'],
  [10, 'rgb(0, 255, 0)'],
  [20, 'rgb(255, 255, 0)'],
  [30, 'rgb(255, 0, 0)'],
];

map.addLayer({
  id: 'weather',
  type: 'fill',
  source: 'weather',
  'source-layer': 'temp',
  paint: {
    'fill-color': [
      'interpolate',
      ['linear'],
      ['to-number', ['get', 'value']],
      ...colorRamp.flat(),
    ],
    'fill-opacity': 1,
  },
});

function play(hour) {
  map.setFilter('weather', ['==', 'time', hour]);
}

play(0);

四、参考资料

github.com/mapbox/mapb…

github.com/mapbox/mapb…

github.com/mapbox/mapb…

github.com/mapbox/mapb…

github.com/mapbox/mapb…

github.com/maplibre/ma…