Mapbox Vector Tile の source-layer 数が描画速度に及ぼす影響


## はじめに

ベクトルタイルの一つである Mapbox Vector Tile には、source-layer という概念があります。これは、一つのタイルが、複数の source-layer に分割されており、それぞれの source-layer 内に地物が含まれています。

https://github.com/mapbox/vector-tile-spec

Mapbox GL JS 等でデータを表示する際は、この source-layer 単位でスタイルの設定をします。また、データの取り扱いにおいても、source-layer 単位でデータを作成したり、取り出したりすることはよくあります。

さて、ベクトルタイルを作成する際に、source-layer を分けようか、それとも一緒にしてしまおうか、悩むことがあります。そこで、source-layer の数によって、描画速度のパフォーマンスにどのような影響を与えるのか、簡単に実験してみることにしました。

## テスト用のデータを生成

まず、テスト用のデータを生成します。最終的にタイルを作成するので、一つのタイル座標に収まったほうが都合が良いと考えました。今回は、`10/908/403` のタイルの領域に収まるように、ランダムなラインデータを10000個含む GeoJSON を作成してみます。

```JavaScript
const fs = require('fs');
const child_process = require('child_process');

// 10/908/403 のタイルに収まるような領域を設定
const x1 = 35.470736;
const x2 = 35.735366;
const y1 = 139.227676;
const y2 = 139.557266;

const dx = x2 - x1;
const dy = y2 - y1

//領域内でランダムな点を発生
const mkRondomVertex = () => {
  const rdx = dx * Math.random();
  const rdy = dy * Math.random();
  return [ rdy + y1, rdx + x1 ];
}

const geojson = {
  "type": "FeatureCollection",
  "features": []
}

//ランダムな2点を持つラインデータを10000個作成
for(let i=0; i < 10000; i++){
  const f = {
    "type": "Feature",
    "properties": {
      "code": i+1 
      // 地物ごとに1～10000のいずれかの数字を code として持たせる
    },
    "geometry": {
      "type": "LineString",
      "coordinates": [
        // ランダムな点を2点生成してラインデータの頂点とする
        mkRondomVertex(),
        mkRondomVertex()
      ]
    }
  };
  geojson.features.push(f);
}

//ファイルに書き出し
fs.writeFileSync("sample.json", JSON.stringify(geojson, null, 2));
```

## source-layer 数を変えながらベクトルタイルへ変換

上記でランダムに生成した10000個の地物を source-layer の数を変えた複数セットのベクトルタイルへ変換します。ここでは、10000個を振り分けるので、source-layer 数が多くなるほど、一つの source-layer に含まれる地物数は少なくなります。各地物には、code という属性値を持たせて、1～10000の数字を順番に割り振ります。

今回は、source-layer の数を 1, 2, 10, 20, 100, 500, 1000, 10000 と変えて作成してみます。今回は、ベクトルタイル作成ツールである [tippecanoe](https://github.com/mapbox/tippecanoe) で `-l` オプションを渡さないと、入力されたファイル名（拡張子を除く）が source-layer 名になるという挙動を利用して、source-layer を一緒にしたい地物を一つの GeoJSON ファイルにまとめて出力し、それらを tippecanoe へ一括して渡すことにしました。中間ファイルが増える代わりに、コードが簡潔になります。

また、ついでに、表示用のスタイルも一緒に作成してしまいます。スタイル設定では、source-layer 名とfilter 条件を設定して、一つのスタイルレイヤを用いて一つの地物を絞り込んで表示したいので、一括で作成してしまった方が楽です。

```JavaScript
const fs = require('fs');
const child_process = require('child_process');

//ランダムに作成した10000個のラインデータを読み込む
const geojson = require('./sample.json');

//スタイルファイルのひな型
//フォントデータと Sprite ファイルは https://github.com/gsi-cyberjapan/optimal_bvmap を参照
const styletemp = (n) => {
  return {
  "version": 8,
  "name": "test",
  "glyphs": "https://gsi-cyberjapan.github.io/optimal_bvmap/glyphs/{fontstack}/{range}.pbf",
  "sprite": "https://gsi-cyberjapan.github.io/optimal_bvmap/sprite/std",
  "sources": {
    "v": {
      "type": "vector",
      "minzoom": 10,
      "maxzoom": 10,
      "tiles": [
        `http://localhost:8000/xyz/${n}/{z}/{x}/{y}.pbf`
      ],
      "attribution": "地理院地図Vector"
    }
  },
  "layers": []
  };
}

fs.mkdirSync(`./d/`);

//source-layer ごとの地物数（source-layer 数ではないので注意）
[1, 10, 20, 100, 500, 1000, 5000, 10000].forEach( n => {
  
  //地物数毎にフォルダを分離
  fs.mkdirSync(`./d/${n}`);
  
  const style = styletemp(n);
  let sl = "---";
  
  const g = {
    "type": "FeatureCollection",
    "features": []
  };
  
  geojson.features.forEach( f => {
    g.features.push(f);
    
    const np = Math.floor(f.properties.code / n);
    const rm = f.properties.code % n;
    
    if(!rm){
      
      console.log(np, rm);
      
      //地物データを GeoJSON へ書き出し
      fs.writeFileSync(`./d/${n}/${np}.json`, JSON.stringify(g, null, 2));
      
      //書き出した分の地物用のスタイルを設定
      g.features.forEach( ff => {
        style.layers.push({
          "id": `${ff.properties.code}`,
          "type": "line",
          "source": "v",
          "source-layer": `${np}`,
          "minzoom": 10,
          "maxzoom": 22,
          "filter": [ "all", [ "==", ["get", "code"], ff.properties.code ] ],
          "layout": {
            "visibility": "visible"
          },
          "paint": {
            "line-color": `rgba(${Math.floor(ff.properties.code/100)},${ff.properties.code%100},255,1)`,
            "line-width": 2
          }
        });
      });
      
      g.features.splice(0);
    }
  });
  
  //スタイルファイルを書き出し
  fs.writeFileSync(`./style/${n}.json`, JSON.stringify(style, null, 2));
  
  //作成した GeoJSON を tippecanoe でベクトルタイルへ変換
  const tipp = `tippecanoe ./d/${n}/*.json -e ./xyz/${n}/ --force `
             + ` --no-tile-size-limit --no-tile-compression --no-feature-limit`
             + ` --full-detail=10 --low-detail=10 --minimum-zoom=10 --maximum-zoom=10 --base-zoom=10`
             + ` --no-tiny-polygon-reduction --no-line-simplification`;
  child_process.execSync(`${tipp}`);
  
});
```

## 実際に表示させて計測

作成したベクトルタイルを実際に Mapbox GL JS を利用したサイトで表示させて、描画にかかった速度を計測します。計測は、Mapbox GL JS で `Map` オブジェクトの作成から、`idle` イベントが起きるまでとします。なお、`idle` イベントは、最後のレンダリングが終了して idle 状態に入る前に起きます。参考に `style.load` イベントと、`load` イベントも測定しています。


```JavaScript
//作成したタイルのセット
const set = [1, 10, 20, 100, 500, 1000, 5000, 10000];
const l = set.length;
const t = Math.floor(Math.random() * l);
const n = set[t];

const tm = {
  start : performance.now()
};

const map = new mapboxgl.Map({
  container: 'map', // container id
  hash: true, //add #position on URL
  style: `./style/${n}.json`, // stylesheet location
  center: [139.3938, 35.6046], // starting position [lng, lat]
  zoom: 10, // starting zoom
  maxZoom: 17.99,
  localIdeographFontFamily: ['MS Gothic', 'Hiragino Kaku Gothic Pro', 'sans-serif']
});

map.on('load', () => {
  tm.load = performance.now() - tm.start; 
});

map.on('style.load', () => { 
  tm.style_load = performance.now() - tm.start;
});

map.on('idle', () => { 
  tm.idle = performance.now() - tm.start;
  //ここで、console に出力される結果を集計する
  //2番目のカラム(${n})は、source-layer ごとの地物数
  //（source-layer 数ではないので注意）
  console.log(`idle,${n},${tm.start},${tm.load},${tm.style_load},${tm.idle}`);
  location.reload();
});
```

スタイル設定は、ベクトルタイルと一緒に作成したとおり、1レイヤごとに1地物を表示するように、filter 設定で絞り込みをかけています。
```
    {
      "id": "1",
      "type": "line",
      "source": "v",
      "source-layer": "1",
      "minzoom": 10,
      "maxzoom": 22,
      "filter": [
        "all",
        ["==", ["get", "code"], 1]
      ],
      "layout": {
        "visibility": "visible"
      },
      "paint": {
        "line-color": "rgba(0,1,255,1)",
        "line-width": 2
      }
    },
```

本実験では、`idle` イベント時に `location.reload()` を実行することで、何度も地図の描画を繰り返します。Chrome のデベロッパーツールで、console のログをページ読み込み後も残すように設定すると、待っているだけでログがたまります。

また、そのたびに表示するスタイルの種類（異なる source-layer 数のパターン）をランダムに変更します。ランダムに表示するので、ネットワークや PC の調子などの影響が生じる誤差をなるべく防いでいます。


![スクリーンショット 2022-09-25 200644.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/3084eee1-bf26-e8ad-b3b0-c25e5b719afd.png)

### 実験条件
* Chrome のデベロッパーツールで、キャッシュを無効化して行いました。
* タイルは localhost （python による簡易サーバでホスト）から取得しています。
* Mapbox GL JS のライブラリ（js、css、map）も localhost から取得しています。
	* ただし、フォントデータとアイコン用の Sprite ファイルは、[国土地理院のレポジトリ](https://github.com/gsi-cyberjapan/optimal_bvmap)の GitHub Pages から取得しています。
* 画面には、`10/908/403` のタイルが含まれるようにしましたが、周囲の8タイルの領域も画面に含まれていたので、404 エラー処理が描画速度に影響を与えた可能性があります。
* なお、私は、実験中に別ブラウザ（Firfox）上のYouTubeで VTuber のスプラトゥーン実況を見ていたので、それが影響を及ぼした可能性があります。（実況動画は割とカクカクになりますので、不安になります。）
* ランダムに表示させている関係上、各 source-layer 数ごとののサンプル数はばらついています。

以上の実験用レポジトリは以下の通り

https://github.com/mghs15/mvt-source-layer-performance-20220927

（結果は、「実験結果」ディレクトリに生データCSVを格納しています。）

## 結果

* 10000個の地物を描画する場合は、source-layer 数を1, 2, 10と増やしていくと、描画速度が向上していくのがわかりますが、10以上は頭打ちとなりました。
* `style.load` イベントは、source-layer 数にかかわらず、ほとんど同じでした。また、`load` と `idle` はほとんど同じタイミングでした。

![スクリーンショット 2022-09-26 232229.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/ae6b856e-d990-cc3f-07ed-effc0b042a85.png)

※100秒以上かかったものもありましたが、外れ値として除外しております（計2点）。

* なお、地物数を1000に絞り、source-layer 数を1, 2, 10, 50, 100, 1000のパターンで実験してみましたが、source-layer 数の違いは大きくはなさそうです。（微妙に右肩下がりな気がしておりますので、統計解析をすれば何らか傾向は出そうな気もしますが。）

![スクリーンショット 2022-09-27 002045.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/e843ef12-cd9c-b554-866c-7f325e5ee923.png)

* また、source-layer 数が大きくなると、タイルの容量が大きく増えていました。以下は、地物が10000のタイルですが、作成したタイルの中では、わずかですが、source-layer 数が100のときに極小になっているように見えます。（source-layer 数に対するタイル容量の変化を見るため、上記の描画速度の実験に用いたタイルとは別にタイルを作成し直しています。）

|source-layer 数 | size |
| --: | :--: |
| 1          | 251K |
| 2          | 251K |
| 10         | 250K |
| 50         | 246K |
| 100        | 243K |
| 500        | 251K |
| 1000       | 260K |
| 5000       | 333K |
| 10000      | 426K |

## 考察

統計分析はしておりませんが、あくまで見てわかるような効果があるかどうかで考察を行います。

* source-layer を探すコストと対象地物を探すコストが同じなら、source-layer 数が100（100 地物/source-layer）あたりが最もコストが小さくなりそうです。しかし、今回の検証では、source-layer 数が10以上になると、コストの変動がほとんど無くなります。つまり、source-layer の中から対象のものを探すコストよりも、地物の filter にコストがかかっている可能性が高いと思われます。
	* source-layer を探すコストと対象地物を探すコストが同じなら、(source-layer 数) + (source-layer 当たりの地物数) が描画にかかる計算量の目安となると考えられます。地物数が10000で、均等に各 source-layer に分けると source-layer 数が100の時に最もコストが小さくなります。
 
* 大量の地物を描画しなければならないときは、source-layer を分けると効果があるかもしれません。
	* ただし、今回の10000個の地物を描画する場合は、source-layer 数が1の時に平均37秒、10の時に平均21秒かかっており、致命的な遅さなので、そもそも地物数を減らす努力をするべきかと思います。

* source-layer の数が100を超えると効果が頭打ちとなったことや、そもそも地物が少ない場合は source-layer 数を増やしたことによる効果の差が小さいことから、描画速度を気にするよりは、データの構造としての効率性等を重視した方が良いと考えられます。

* あまりに細かく source-layer を分けすぎると、タイルのデータ量の増加を招く可能性があります。
	* source-layer のメタデータなどのオーバーヘッドが増えるほか、Protocol Buffer の key と value は source-layer 内でのみ共有されますので、source-layer を分けすぎると、その分重複が増える可能性があります。

## 最後に

今回の検証からは、**描画速度向上のために source-layer の数を気にするのよりは、データそのものの削減やデータ構造の効率性等に労力をかけるべき**ではないかと思います。

ただし、今回の検証は、あくまで簡易的に行ったものですので、ご了承ください。

ほかの条件、たとえば、

* 点や面のデータではどうなのか？
* 頂点数の変化の影響はどうなのか？
* 極端に地物数の多い source-layre があるとどうなのか？
* スタイル設定（たとえば、data-driven な設定）はどうなのか？
* ブラウザキャッシュを生かしたらどうなるのか？

といった部分には興味があります。