すべてを支配する一つの属性
Mapbox におけるシンボル描画の再考:単一のフィーチャーインデックスが、大規模なフィーチャーごとのスタイリングを可能にした経緯と、「Appearances」の基盤について。
序曲
都心部のタイルには、ラベル、アイコン、盾のマーカーなど、一度に数百ものシンボルが表示されることがあり、それぞれが独自の色、不透明度、サイズでスタイリングされる可能性があります。これらすべてをGPU上で効率的にレンダリングすることは、リソース配分の問題であり、提供したいスタイリングの制御項目が増えるほど、その難易度は高まります。機能ごとに制御したいプロパティが増えるほど、GPUのリソース消費量も増大します。そして、やがて限界に達してしまうのです。
この記事では、私たちがどのようにその壁にぶつかり、それをどう乗り越え、そしてそれが何をもたらしたかについてお話しします。
第1章:手狭になってきた
本研究以前は、フィーチャーごとに異なる可能性のあるすべてのペイントプロパティ(文字色, icon-opacity, テキストのハロー幅、……)は、それぞれ独自の頂点属性スロットに格納されていました。1つのプロパティにつき1つのスロットです。WebGL2では、シェーダープログラムごとに16個の頂点属性スロットしか保証されておらず、シンボルレンダリングでは、そのうちの5つをすでにジオメトリデータ(位置、アンカー、テクスチャ座標、押し出しベクトル、および衝突判定用の投影位置)に使用しています。
これは、ペイントのプロパティが考慮される前の話です。データ駆動型の 文字色 表現、ハイライトされたPOIごとの不透明度、あるいはデータに基づいて幅が決定されるハローなど、それぞれの新しいプロパティが残り分のリソースを競い合います。
もう一つ問題がありました。特定のレイヤー上のすべてのレンダリング対象要素で共通する「定数ペイントプロパティ」は、単純なユニフォームとして渡されるため、実行時に簡単に更新できます。しかし、「データ駆動型プロパティ」の場合は事情が異なります。これらのプロパティの要素ごとの値は、タイルの解析時に頂点属性に焼き付けられます。 実行時にこれらを変更するには、影響を受けるすべてのタイルについて頂点バッファを再評価し、再アップロードする必要があります。これはCPU負荷の高い処理であり、フレーム落ちを引き起こす可能性があります。
フィーチャーごとのスタイル設定を多用すると、頂点属性モデルは2つの点で機能しなくなります。プロパティを追加するにつれてスロットが不足し、実行時にそれらのプロパティを変更する必要が生じた際、更新に多大なコストがかかるようになります。これら2つの問題には、同じ解決策が求められました。
第2章:すべてを見出す一つの属性
当初は、テクスチャを介して機能ごとのデータを渡すことを考えました。テクスチャは柔軟性が高く、幅広いサポートがあり、パフォーマンスも優れています。しかし、当社のデバイスラボで低性能なモバイル端末を用いてベンチマークテストを行ったところ、結果は異なっていました。まったく同じハードウェア環境において、テクスチャのサンプリングはUBOの読み取りに比べて明らかに遅かったのです。そのため、私たちはUBOを採用することにしました。
ユニフォーム・バッファ・オブジェクトとは、シェーダーが直接読み取ることができる固定サイズのGPUメモリブロックです。各頂点ごとにGPUパイプラインを通過する際に重複して生成される頂点属性に、機能ごとのプロパティをエンコードする代わりに、それらをコンパクトな構造化バッファに書き込み、一度アップロードして、描画時にバインドします。
その仕組みは単純です。各頂点には単一の整数型属性、つまりフィーチャーインデックスが割り当てられており、シェーダーはこのインデックスを使用してUBOからそのフィーチャーのプロパティを参照します。レイヤーにデータ駆動型プロパティが2つある場合でも、9つすべてある場合でも、属性にかかるコストは常に1スロットです。
N個の属性スロットを単一のインデックスと交換することは、単にスペースを節約する方法というだけでなく、機能ごとのプロパティ数がシェーダーの属性予算に影響しなくなる、根本的に異なるモデルなのです。
もう一つ、注目すべき内部構造があります。頂点ごとのインデックスはプロパティバッファに直接アクセスするのではなく、まず小さな間接参照テーブルを経由し、そこで見つかった値が実際のブロックへのアドレスとなります。この余分なステップによって、3つの利点が得られます。評価されたプロパティがたまたま同一であるフィーチャーは、単一のプロパティブロックを共有できるため、タイルに類似したラベルが多数含まれている場合でも、バッファはコンパクトなまま維持されます。 また、外観の切り替え時には、機能ごとに間接参照テーブルのエントリを1つ更新するだけで済みます。これに対し、直接アドレス指定モデルでは頂点ごとのインデックス書き込みが4回必要となるため、変更のたびにGPUへ送信されるデータ量が正確に4分の1に削減されます。さらに、この同じテーブルが、次に説明する機能ごとのアニメーション処理の基盤となります。
UBOにも制約があります。サイズは割り当て時に固定され、OpenGL ESではUBOあたり16KBしか保証されません。これは、当社が対応するすべてのプラットフォームにおける下限値です。この上限を超えるほど高密度なタイルは、複数のUBOに分割し、複数の描画呼び出しを行う必要があり、それにはGPUの状態変更に伴うコストがかかります。 機能ごとのリソース使用量を考慮すると、データ駆動型のプロパティが1つまたは2つの場合、この上限は1回の描画あたり約1,000機能となりますが、9つすべてがデータ駆動型になると約170まで低下します。また、異なるサイズで再割り当てを行うと、新しいレイアウトを認識するシェーダーバリアントが必要となり、これは私たちが避けたいと考えていたことの一つです。 現在のデフォルト設定は、テストの結果に基づいて採用したものです。ただし、測定結果によって状況が変わった場合は、戦略を変更する余地も残しています。
第3章:1つのレイアウト、4つのバックエンド
私たちが最初に下した設計上の決定の一つは、GL JS NativeGL JS UBOレイアウトを完全に統一する必要があるということでした。どちらのレンダラーも同じタイルを解析し、同じスタイル仕様に応答し、同じシェーダーを使用し、それらすべてから同じ出力を生成する必要があります。レイアウトが異なれば、相互変換が必要になったり、実装が異なったりすることになり、それは私たちが避けたいコストでした。
どちらの実装も、シンボル・バッチごとに同一の3つのバッファ構造を共有しています:
- ヘッダーバッファ:48バイトのコンパクトな記述子で、どのプロパティがデータ駆動型か、どのプロパティがズーム補間されるか、および各プロパティがフィーチャーごとのデータブロック内でどのバイト位置にあるかをエンコードしたものです。
- プロパティバッファ:フィーチャーごとのデータブロックが詰め込まれています。ここにはデータ駆動型のプロパティのみが表示されます。定数プロパティは単純なユニフォームとして渡されるため、フィーチャーごとのブロックは小さく保たれます。
- ブロックインデックスバッファ:フィーチャーインデックスとプロパティブロックの間に位置する間接参照層です。
このレイアウトは、WebGL2(GL JS)、Metal(iOSおよびmacOS)、Vulkan(Android)、OpenGL(旧式Android)において、バイト単位で完全に同一です。プラットフォームに関わらず、同じシェーダーロジックが同じメモリレイアウトからデータを読み込みます。
これを実現するには、それぞれの側で異なる課題を解決する必要がありました。GL Native側では、Metal、Vulkan、OpenGLという3つのレンダリングバックエンドをサポートする必要があります。それぞれ、ユニフォームバッファの作成、アップロード、バインドに異なるAPI呼び出しを必要とします。GL Nativeは、これら3つすべてをグラフィックス抽象化レイヤーでラップしているため、シンボルレンダリングコードは単一のインターフェースに対して動作します。GL JS 、メインスレッドをレンダリング用に確保しておくため、タイルの解析はWebワーカーで行われます。 UBOバインダーはそこで作成され、データが格納されますが、WebGLコンテキストはメインスレッド上のマップのキャンバスに紐付けられているため、GPUバッファの割り当ては、解析されたタイルがレンダラー側に戻ってくるまで待機する必要があります。つまり、ワーカーは合意されたレイアウトでプレーンな型付き配列を生成し、実際にUBOを作成してアップロードするのはメインスレッドの役割となります。
この投資は検証段階で実を結びました。GL JSWebGL2、iOS上のMetal、Android上のVulkanで同じレンダリングテストを実行したところ、ピクセル単位で全く同じ出力が得られました。実装段階では、この共通テストスイートにより、バックエンドがレイアウトを異なる方法で解釈していた複数のケースが、ユーザーに届く前に発見されました。レイアウトの修正は、4つのレンダラーすべてに同時に適用されました。
第4章:複数の役割を担うシンボル
UBOは、Appearancesを機能させるための基盤です。
「外観」機能を使用すると、スタイルデザイナーは一連の機能に対して条件付きのスタイルバリエーションを定義できます。POIレイヤーには、「選択時」の外観が設定されており、アイコンの変更、文字サイズの拡大、色の変更が行われる場合があります。電気自動車の充電ステーションのレイヤーでは、利用可能状況に応じてアイコンが変化する場合があります。各外観には条件式が設定されており、その式が真の場合、すべてのレイアウトおよび描画プロパティが即座に新しい値に切り替わります。
UBOsがなければ、フィーチャーごとのペイントプロパティのオーバーライドには、アクティブなアピアランスバリアントごとに個別のドローコールを行うか、アピアランスがアクティブになるたびに頂点バッファを再エンコードする必要があります。UBOsにより、3つ目の選択肢が可能になります。つまり、フィーチャーに対してアピアランスがアクティブになった際、プロパティバッファ内のそのブロックが更新されて再アップロードされるか、あるいは、新たに評価されたブロックがすでに存在している場合は、間接参照テーブルがそのブロックを指すように再設定されるだけです。 いずれの場合も、頂点バッファは変更されず、シェーダーは次のフレームで新しい値を読み取ります。
GL JS GL NativeGL JS どちらも同じ評価ロジックに従います。アピアランスがプロパティを定義すると、それはプロパティバッファに機能ごとのエントリとして追加され、そこでアピアランスの評価値が、機能ごとにベース値を上書きすることができます。
1つのラベルには複数のテキストセクションを含めることができ、それぞれ異なる色を設定できます。描画プロパティが同一のフィーチャーはプロパティブロックを共有できますが、フィーチャーの状態などによって変化するプロパティについては、それぞれ個別のエントリが必要です。これらは、この機能を動作させるために私たちが解決しなければならなかった複雑さのほんの一例に過ぎません。
UBOモデルでは、実行時のアピアランス切り替えによって頂点バッファは変更されません。つまり、処理負荷の高いジオメトリ部分は、解析時に一度読み込まれるだけで、その後はすべてのアピアランス状態において再利用されます。
結果:その成果
共有UBOレイアウトは、GL JS WebGL2)、Metal上のGL Native、Vulkan、およびOpenGL上で実行される一連のテストによって検証されています。4つのレンダラーに対して、期待される出力が同時に確認されます。実装の過程で、このテストスイートにより、プラットフォームによってレイアウトの解釈が異なるという複数のケースが、ユーザーに届く前に検出されました。そして、すべてのプラットフォームに対して同時に修正が適用されました。
現在のGPUハードウェアでは、一般的なレイヤーにおいて、UBOによる間接的な検索と頂点属性の直接読み取りとの間のスループットへの影響はほぼ相殺されます。つまり、GPUでの検索コストが属性読み取りの節約分を相殺してしまうのです。変化するのは上限です。シンボルレイヤーは、フィーチャーの数にかかわらず、データ駆動型の9つのペイントプロパティすべてを単一の属性スロットに格納できるようになりました。これこそが「Appearances」が存在するために必要だった余地であり、今後の取り組みの基盤となるものです。
エピローグ:最高の瞬間はまだこれからです
現時点では、UBOアプローチはペイントの特性のみを対象としており、シンボルレイヤーに限定されています。これら両方の課題は解決可能です。
すべてのレイヤータイプに外観を適用することは、ペイント面における次なる当然のステップであり、それはそれらのレイヤーをUBOモデルへ移行させることも意味します。副次的な効果として、旧来の属性スロットの上限を回避するためだけに存在していたいくつかのシェーダーバリエーションを統合できる見込みであり、それ自体が大きな成果となります。
その次のステップがレイアウトプロパティです。外観プロパティはすでにこれらを上書きできますが、現時点では変更を行うたびに、CPU側でジオメトリの再計算が必要となります。間接参照レイヤーはまさにこのケースを想定して設計されており、ペイントプロパティとレイアウトプロパティの重複排除を独立させています。そのため、この機能が実装されれば、レイアウトのみに影響する外観の切り替えは、現在のペイントの切り替えと同じくらい低コストで行えるようになります。
この間接参照レイヤーこそが、アニメーションに適した基盤となります。計画としては、固有のキーフレームをプロパティブロックとして保存し、間接参照テーブルで各要素の進行状況を追跡するようにします。これにより、フレームごとの処理は、プロパティデータの再計算ではなく、テーブルへのわずかな更新だけで済むようになります。
まだ実装していないオリジナル設計のもう一つの要素があります。それは、頂点ごとのインデックスを「フィーチャーインデックス」ではなく「表現インデックス」にすることです。現在、評価されたペイントプロパティのセットが一致する2つのフィーチャーがブロックを共有するのは、解析時の重複排除がたまたまそれを検知したからに過ぎません。完全版では、ブロックの共有を第一級の操作とします。つまり、フィーチャーは構造上、表現を指し示すようになり、フィーチャーを別の表現に切り替えるには、インデックスへの書き込みが1回行われるだけです。
見た目の違いはさておき、このメカニズムこそが、頂点属性スロットを消費することなく、機能ごとに独自のGPU状態を必要とするあらゆる機能にとって、適切な基盤となります。
この作業は、目的そのものではなく、そのための前提条件でした。GL JS GL Nativeの両方のレンダリングアーキテクチャは、以前よりも機能ごとの表現力を大幅に高めることができるようになりました。




