地図

Mapbox GLの10年

見出し

これはレイアウト確認用のダミーテキストです。

当社のWebマッピングライブラリの発明を振り返る

当社のWebマッピングライブラリの発明を振り返る

10年前、社内投稿でMapbox GLとなるものの始まりが発表されました。この革新的なマッピングライブラリは、世界が地図と対話する方法を変え、これまで想像もできなかったほど動的でインタラクティブな視覚化を可能にしました。Mapbox GLは、世界で最も有名な企業や報道機関のいくつかによって採用されています。スマートウォッチ、自動車、ケーブルテレビへと飛躍し、火星のマッピングから視覚障碍者のナビゲーション支援まで、あらゆることに利用されています。今日、Mapbox GLは何十億もの人々が利用する地図の基盤となっています。

以下は、2013年7月6日の社内ブログ投稿で、WebGL上に構築された新しい種類のオンデバイス地図レンダラーの最初のプロトタイプを紹介したものです。当時、Mapboxの地図は、Mapnikで事前に描画され、Leafletでレンダリングされたラスターイメージタイルで構成されていました。これらのプロジェクトはいずれもそれ自体強力ですが、私たちはそのアプローチの限界が見え始めていました。

  • すでにレンダリングされたタイルにデータを追加する手法には、欠点がありました。カスタムデータは常に侵入者のように見えました。 
  • 個別の地図をサーバー側でレンダリングするには、多くのリソースが必要でした。
  • ズームレベル間で地図を回転、傾斜、またはスムーズに拡大縮小できませんでした。
  • 高DPIの「Retina」ディスプレイの人気が高まっているため、地図タイルのサイズを2倍または3倍にし、帯域幅の要件が大幅に増加しました。

幸いなことに、新しいアプローチに適した時期でした。Mapbox GLの作成を可能にするいくつかの要素が整いました。

  • クライアントへの地理データの小さなチャンクの効率的な配信を可能にするMapboxベクタータイル形式を開発したばかりでした。過去10年間で、この形式は非常に成功し、現在では地理空間コミュニティ全体で使用されている業界標準となっています。
  • WebGLは、わずか2年前に標準化されたばかりで、広く利用可能になりつつありました。
  • 最初のプロトタイプの直後、Mapboxは最初の資金調達ラウンドを受け、私たちのチームはプロトタイプを、今日の業界で最も柔軟でフル機能を備えた地図レンダラーに進化させることができました。

最初のプロトタイプは、スムーズなズームが可能で、道路、公園、水、建物をレンダリングできました。しかし、11か月後に公開された最初のバージョンには、テキストレンダリングとラベリング、太線、回転、そしてブラウザからモバイルおよび組み込みデバイスにレンダラーをもたらしたC++ポートなど、多くの機能がまだありませんでした。それ以来、押し出しの構築、クライアント側のポリゴンテッセレーション、傾斜、データドリブンスタイリングヒートマップなどのサポートを追加しました。プロジェクトの成功に貢献した革新、最適化、そして優秀な人々の数は数え切れません。

すべてはこのデモから始まりました。10年前のMapbox GL JSの最初のプロトタイプを振り返ってみましょう。

WebGL地図

投稿者:@kkaefer、2013年7月6日

過去2週間にわたって、WebGLを使用したマップレンダリングを実装しました。これは、コンピューターに組み込まれているGPUに直接アクセスできる比較的新しいブラウザーAPIです。これは、JavaScriptインターフェースで実行するように適合されたOpenGL ES 2.0の実装です。

注意: このデモ は、S3バケットから事前に計算されたタイルをロードします。つまり、どこでもズームできるわけではありませんが、DC、サンフランシスコ、NYC、ベルリン、パリ、ロンドン、ケープタウンは動作するはずです。

OpenGL works quite differently from software rendering (like the 2D <canvas>, or Agg): Instead of calling draw operations like moveTo or lineTo, or using a scanline renderer that goes through every pixel and every line, the GPU renders everything at the same time (or almost the same time) on multiple cores within the GPU.

OpenGL ESは、一度に1つの頂点を送信するのではなく、シーンのレンダリングに必要なすべての情報を含む1つの大きなバッファーを作成する方がはるかに効率的に動作します。これには複数の理由がありますが、おそらく最大の理由は、CPU/RAMからGPUへのデータの移動が非常に遅く、個々の呼び出しを少なくするほど速度が向上するためです。

頂点シェーダーは、本質的に、“入力”座標を含むいくつかのパラメーターを受け取り、頂点の出力位置を生成するプログラムです。これは、カメラがシフトした場合に、バッファーを更新しなくてもGPUにオブジェクトの新しい位置を計算させることができるため、必要になります。

当社の頂点シェーダーは次のようになります:

attribute vec3 a_position;

uniform float uPointSize;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;

void main() {
    vec4 pos = uPMatrix * uMVMatrix * vec4(a_position.xy, step(32767.0, a_position.x), 1.0);
    gl_Position = pos;
    gl_PointSize = uPointSize;
}

1つの3Dベクトルを入力として受け取ります(これらの座標のうち2つのみを使用します)。これは、この関数のすべての呼び出しに対して変化します。他の3つ(uPointSizeuMVMatrixuPMatrix)は「ユニフォーム」であり、これは OpenGL の定数バージョンです。

唯一興味深い行は、頂点にモデル/ビューと投影行列を掛けて最終的な位置を受け取る最初の行です。z座標はstep()関数によって決定されます。これが何であるか、そしてなぜこれが必要なのかについては後で説明します。

投影

OpenGL は 3D API であるため、レンダリングに実際には必要ない多くのものをサポートしています。たとえば、遠近法投影(ほとんどのコンピューターゲームで使用されている)のサポートがあります。ただし、2D ベクターグラフィックスを描画するため、正射影を使用しています。

私たちの行列は非常に簡単です。派手な回転を行う必要がないため、モデル/ビュー行列は本質的に単位行列であり、投影行列は両軸で0から4095までの投影領域を開きます。これは、ビューポートに0から4095までの座標が含まれることを意味します。これは、タイルのデフォルトの範囲とまったく同じです(精度が低いまたは高いタイルでは、これを動的に変更できます)。

投影行列は、近平面を 1、遠平面を 10 とも定義します。これは、1 より近いもの、または 10 単位より遠いものはすべて「カリング」されることを意味します。これは、OpenGL で「レンダリングされない」ことを意味します。シェーダーのステップ関数とともに、これは後で興味深いものになります。

Tiling

OpenGLのタイル処理を実装するには、大きく分けて2つの方法があります。1つは、すべてのタイルが独自のキャンバスを持つLeafletスタイルのキャンバス、もう1つはWebGLコンテキストです。ポジショニングはCSS変換で行われます。これには、タイルを一度だけレンダリングすればよく、レンダリングされたビットマップ以外のすべてを破棄できるという利点があります。また、このセットアップでは、コンポジタが基本的にCSSレイヤーの座標をシフトするだけでよく、ほとんどの場合GPUでコンポジットが行われるため、パンも高速です。

ただし、私はタイリングの異なる実装方法を選択しました。タイルごとにキャンバスを用意するのではなく、地図領域全体に対して1つの大きなキャンバスを用意します。ユーザーがパンまたはズームするたびに、新しい場所で地図全体を再レンダリングします。これは、シームレスなズームをサポートするためです。地図ビューには、スナップする固定のズームレベルはありません。必要なサイズですべてをレンダリングするため、考えられるすべてのズームレベルで鮮明な画像が得られます。

前の画像は、タイルシームの視覚化を示しています。この画像は少し誇張されています。ダウンロードする必要のあるタイルの数を減らすために、すべてのタイルを少なくとも 512px x 512px でレンダリングします。実際には、これはほとんどの地図ビュー (大きなものでも) が 2 ~ 4 個の地図タイルでカバーできることを意味し、場合によっては 1 つだけで済みます。もちろん、Retina 画面では、同じタイルを 2 倍の大きさでレンダリングするため、最小タイルサイズは 1024x1024 ピクセル (16 個の PNG タイルに相当) になります。

個々のタイルをレンダリングするには、まずアクティブなビューポートを視覚化に表示される白い正方形の1つに限定し、すべてのタイルに同じMVPマトリックスを使用して、そのビューポートだけにレンダリングし、そのバウンディングボックスにクリップします。このプロセスは、キャンバスのビューポートに表示されるすべてのタイルに対して繰り返されます。これらのタイルをすべて個別にレンダリングしても、かなり高速です。私のマシンでは、フレームあたり1ms未満です。60fpsでスムーズなズームとパンを実現するには、約16msあります。したがって、まだ余裕があります。

ズームインまたはズームアウトするときは、まず必要なタイルすべてのリストを生成します。まだロードされていない場合は、レンダリングする必要があるタイルのリストに既存の親タイルまたは子タイルを保持します。実際​​のレンダリングステップでは、このリストをズームレベルでソートし、最初に低いズームレベルのタイルをレンダリングしてから、利用可能な高いズームレベルのタイルを上にレンダリングします。適切な解像度ですべてを新たにレンダリングしているため、低いズームレベルのタイルをレンダリングした場合でも、LeafletやGoogle Mapsのようなバイリニアスケーリングアーティファクトはありません。ここでの唯一の違いは、表示される詳細が少ないことです。

バッファ

前に述べたように、すべての頂点を 1 つのバッファに格納することが、OpenGL に頂点座標を提供するための最も効率的な方法です。バッファに格納された線を描画するには、そのバッファを使用してgl.drawArrayを呼び出すだけです。WebGL はこの呼び出しにオフセットと長さを提供しますが、描画したい線ごとにdrawArrayを呼び出すのは非常に非効率的です。OpenGL には 2 つのオプションがあります。連結された線 (LINE_STRIP) を描画するのではなく、個々の線 (LINES) を描画できますが、それはほとんどすべての座標を複製する必要があることを意味します。ただし、1 つのスタイル (たとえば、すべての道路) を共有するすべての線を 1 回の描画呼び出しでレンダリングしたいのですが、それらはバッファ内で連結されています。ここで、視錐台 (近/遠平面) が登場します。バッファを作成するときに、近/遠平面の外側にある 3D 座標を挿入して、それらの 2 点間の線がカリングされるようにします。基本的に、表示したいすべての線は同じ 2D 平面にあり、線ストリップ間の接続はその平面から突き出てカリングされます。

ビューフラスタムの外側にあるz座標を指定すると、バッファ内のすべての頂点に3次元を追加する必要があります。もちろん、これにより必要なメモリストレージが50%増加します。代わりに、2D(2成分)頂点を引き続き格納します。接続頂点には、xの値として32767を使用します。頂点シェーダーでは、step(32767.0, a_position.x)を使用して頂点のz値を決定します。この関数は、2番目の値が最初の値より小さい場合は0を返し、それ以外の場合は1を返します。

これは次のように書くことができました。

if (a_position.x == 32767.0) {
    a_position.z = 1.0;
} else {
    a_position.z = 0.0;
}

ただし、GPUコードはCPUコードとは大きく異なります。GPUは基本的に両方の分岐を実行してから実行を再度同期する必要があるため、分岐は非常にコストがかかります。通常は、“より多くの作業”を行う方が効率的ですが、このように分岐を回避します。これは基本的にステップ関数が行うことです。

ポリゴンを塗りつぶすには、別の工夫が必要です。ポリゴンをテッセレーションした後(これについては別のブログ記事で説明します)、三角形ストリップを取得します。

座標 ABCDEF は、わずか 6 つの座標で 4 つの三角形を形成します。ただし、複数のポリゴンを 1 つのバッファに詰め込み、一度にレンダリングしたいと考えています。そのため、実際に三角形を塗りつぶすことなく、ある三角形ストリップの終わりから次のストリップの始まりに到達する方法が必要です。これは「縮退」三角形を使用することで実現できます。最初の三角形ストリップの最後の座標と、次の三角形ストリップの最初の座標を単純に繰り返します。

これにより、2つの点が一致する2つの三角形が生成され、三角形の面積がゼロになります。GPUは、これらの縮退した三角形の除去に非常に優れています。

エレメントバッファ

すべての線を1つのバッファに詰め込むと言いましたが、実は本当のことを言っていませんでした。線をレンダリングするために、実際には2つのバッファを使用しています。1つは座標だけを格納するバッファ(重複する座標は重複排除)、もう1つは最初の(頂点)バッファ内の座標へのインデックスを格納する「要素配列」と呼ばれるバッファです。

これにはいくつかの利点があります。同じオブジェクトをアウトラインと塗りつぶされたポリゴンとしてレンダリングする場合、頂点を一度だけ繰り返せばよく、両方のバッファーから参照できます。テッセレーションを行っても、縮退三角形のためにテッセレーションされたポリゴンには頂点が繰り返し現れることがよくあります。これは、縮退三角形を行う方が、実際の頂点(4バイト)ではなくインデックス(2バイト)のみを格納すればよいため、メモリの点で大幅に安価になることを意味します。

要素配列からのレンダリングも高速です。GPUはパイプラインを介して(より小さい)インデックスのみをフェッチする必要があり、最適化された頂点キャッシュを使用して実際の座標をルックアップできるためです(これはGPUによって異なる場合があります)。

ライン結合

OpenGLで高品質の線を描画するのはトリッキーです。昨年の夏、私は線の結合を試しました。今回は、Googleがあまり気にしていないのと同じように、当面はこの問題をほぼ無視することにしました。

ほとんどの道路は、実際には別の道路に接続せずに終わることはありません。線の解像度が十分に高い場合、接続されたセグメント間の角度はカーブで十分に低いため、これは大きな問題ではありません。ただし、場合によっては、欠落している線の結合が見られることがあります。

線の結合部に点を描くだけで、線のマイターを非常によく偽装できます。

その結果:

さらに滑らかにするために、長方形のポイントに円形のテクスチャを適用できます。

OpenGLのLINES機能の最大の問題は、ハードウェアによって10ピクセルに制限されていることです(私のマシン上)。Retina解像度でレンダリングする場合、これは5視覚ピクセルに低下しますが、もちろん十分ではありません。

次のステップ

これはWebGLマップデモに含まれるすべての要素ではありませんが、このブログ投稿をまとめるには十分です。データ形式の更新やポリゴンのテッセレーションなど、より具体的なトピックに関する別の投稿を書きます。

初期の問題の多くは解決されましたが、もちろん、まだ多くの課題があります。ラベリング、線、端とキャップ、サポートされていないマシンでのアンチエイリアシング、キャンバスのサイズ変更のサポート、Web Workersへの処理の移行、オーバズーム/タイル保持の改善などです。

2023年からの最後の注記:Mapbox GL JSがどれだけ進化したかを実際に確認できます。最新バージョンのMapbox GL JSを試してみたい場合は、ガイドをご覧ください、 無料アカウントにサインアップして、今すぐ構築を始めましょう。

これはレイアウト確認用のダミーテキストです。

これはレイアウト確認用のダミーテキストです。

関連記事