ヒーロー画像:Cesium ionからストリーミングされ、新しいEvergine.Cesiumレンダラーで描画されたマンハッタンのビル群

数か月前、地球規模のデジタルアースをストリーミングで.NETアプリケーションに導入できる Evergine.Cesiumアドオンを発表 しました。それ以来、私たちは「高速化」というただ1つの目標に注力してきました。アドオンがCesiumのタイルをピクセルに変換する仕組みを再構築したことで、新しいレンダラーはよりスムーズにストリーミングを行い、同じCPU時間ではるかに多くのタイルを描画し、メモリ使用量を予測可能な状態に保ちます。

要点を言えば、タイルはもはやシーンエンティティではなくなりました。ジオメトリは1つの巨大な共有バッファに、画像は単一のテクスチャアトラスに格納されます。GPUが描画対象を決定し、すべての可視タイルを1回のインダイレクトドローコールでレンダリングします。その間、Cesium Nativeはフレーム処理と競合することなく独自のスレッドで処理を行います。本記事の残りの部分では、これらの変更を行った理由と、それらがどのように連携して機能するかを解説します。

この記事の内容

デジタルアースがレンダラーにとって高負荷である理由

3Dの地球儀データは、タイル単位でストリーミングされます。Cesium Nativeはカメラの位置を確認し、地形、画像、建物のどの部分が、どの詳細度(LOD)で必要かを計算します。都市の上空を飛行すれば、一度に何百ものタイルが画面上に表示されることもあります。カメラを移動させると、それらの多くが入れ替わります。ネットワークから新しいタイルが到着する一方で、古いタイルは破棄されます。

これは、一般的なゲームのレベル(ステージ)とは全く異なるワークロードです。通常のレベルでは、メッシュを一度ロードすれば、その後は何分間もそれらを描画し続けます。一方、地球儀の描画では、レンダリングを行いながら絶えずGPUリソースの生成と破棄を繰り返しており、到着したすべてのタイルをアップロードし、画面外に出たすべてのタイルを解放する必要があります。しかも、それらすべてをフレームレートに影響を与えることなく実行しなければなりません。つまり、本当に重要なコストは、タイルをどれだけ速く描画できるかだけでなく、タイルをどれだけ低負荷で読み込み、保持し、解放できるかという点にあります。

従来のアプローチが苦戦していた点

アドオンの最初のバージョンでは、最も直接的なアプローチを採り、各タイルを通常の Evergine モデルに変換していました。Cesium から glTF モデルとして渡されたタイルをメモリ上に GLB ファイルとして書き出し、それを Evergine のモデルローダーで再度パースしていたのです。ローダーは独自のメッシュとマテリアルを持つエンティティを構築してシーンに追加し、各衛生画像(イマジェリ)は個別のテクスチャとして後から適切なマテリアルにアタッチされました。Cesium 自体の更新や、エンティティの追加・削除を行うキューの処理もすべてメインスレッドで実行されていました。

この仕組みはシンプルで小規模なシーンではうまく機能しましたが、スケールしませんでした。新しいタイルが届くたびに GLB の変換処理と、エンティティ、メッシュ、マテリアル、テクスチャ一式の新規生成が発生するため、多数のタイルが同時に到着するとフレーム落ち(ヒッチ)が発生していました。また、各タイルはすべてのパスで個別に描画されていたため、画面上に数百のタイルが表示されると、CPU はフレームの大部分をドローコールの発行だけに費やし、シャドウの処理がその負荷をさらに何倍にも増大させていました。加えて、毎分何千もの小さなバッファやテクスチャが生成・破棄されるためメモリ使用量の予測が難しく、しかもこれらすべての処理がアプリケーションと同じスレッドで実行されていました。

新レンダラーの概要

新しいレンダラーでは、タイルをエンティティではなくデータとして扱います。タイルごとにリソースを生成するのではなく、少数の巨大なリソースを最初に一度だけ生成し、タイルの出現や消滅に合わせてその一部を割り当てます。

図:以前は各タイルが個別のドローコールを持つ GLB、エンティティ、メッシュ、マテリアル、テクスチャになっていましたが、現在はすべてのタイルが 1 つのバッファと 1 つのアトラスを共有し、1 回のインダイレクトドローで描画されます

アプリケーション側から見た変更点はごくわずかです。これまで通りシーンに CesiumCoordinator を追加するだけで地球が表示されます。しかし、その内部構造はほぼすべて刷新されています。

新機能

すべてのジオメトリを 1 つのバッファに統合

すべてのタイルジオメトリは、起動時に一度だけ生成される単一の頂点バッファとインデックスバッファに格納されるようになりました。タイルが到着すると、アドオンは GLB への変換を介さずに、Cesium の glTF アクセサから位置、法線、テクスチャ座標、インデックスを直接読み取り、インターリーブされた頂点フォーマットにパックして、共有バッファ内のそのタイル専用の領域(スライス)へ直接コピーします。

このスライスの割り当ては、ストリーミングされるすべてのタイルに対して行われるため、高速である必要があります。そこで、まさにこの用途のために設計された Sebastian Aaltonen 氏の OffsetAllocator の C# 移植版を採用しました。空き領域をサイズごとのビン(区画)に分類するため、バッファが空の状態でもほぼ満杯の状態でも、ブロックの割り当てや解放が一定の処理時間で行われ、断片化も低く抑えられます。ジオメトリに加えて、各タイルは GPU 上のメタデータバッファに小さなエントリを持ち、頂点、インデックス、画像の格納場所やトランスフォーム情報を記録します。そのため、タイルの追加や削除時には自身のエントリのみが更新されます。

すべての衛星画像を 1 つのアトラスに統合

衛星写真などの画像は地形の上に貼り付けられますが、Cesium はこれを小さな断片として配信します。アドオンは断片ごとにテクスチャを生成する代わりに、それらすべてを 1 つの巨大なテクスチャアトラスにパックするようになりました。アトラスは行(シェルフ)単位で編成され、各画像は最もサイズが合うシェルフに配置されます。タイルが解放されると、その領域は次の画像によって再利用されます。

アトラスへのピクセルの書き込みは、GPU をストールさせることなく行われます。Cesium の読み込みスレッドは画像をステージングバッファに書き込みます。このバッファは 2 つ用意され交互に使用されるため、GPU が片方を読み取っている間に CPU はもう片方に書き込むことができます。フレームごとに 1 回、コンピュートシェーダーが保留中のすべての画像をアトラス内の所定の位置にコピーし、その過程で色空間を sRGB からリニアに変換します。また、各画像の周囲には端のピクセルを繰り返す 4 テクセルの境界が設けられるため、テクスチャフィルタリング時に隣接する画像の色が混ざることがなく、タイルの継ぎ目も目立ちません。さらに、アトラスの使用量がしきい値を超えると、アドオンは Cesium に対して不要になったタイルの解放を要求し、長時間のフライト中でも画像メモリの使用量を適切に維持します。

GPU によるドローリストの構築

これが最大の変更点です。従来のレンダラーでは、CPU がすべてのオブジェクトをループ処理してドローコールを記録していましたが、数百のタイルと複数のパスが存在する状況では、このループがすぐにボトルネックとなります。新しいレンダラーでは、この処理を GPU に移行しました。

Cesium が現在のビューに必要なタイルを選択した後、CPU の唯一の仕事はタイルごとに 1 ビットの可視性マスクを書き込むことです。GPU 上では、コンピュートシェーダーがタイルごとに 1 つのスレッドを実行してそのビットを読み取ります。可視状態にある各タイルはスロットを確保してインダイレクト引数バッファに描画コマンドを書き込みます。同時に同じシェーダーがタイルのワールド行列およびワールド・ビュー・プロジェクション行列も計算するため、頂点シェーダーで頂点ごとにその乗算を繰り返す必要がありません。その後、レンダラーは単一のインダイレクトドローを発行し、共有バッファからジオメトリを、アトラスから画像を読み取って、すべての可視タイルを一度に描画します。

図:Cesiumスレッドがビジビリティマスクを書き込み、コンピュートシェーダーがそれをドローコマンドとトランスフォームに変換し、1回のインダイレクトドローで表示されるすべてのタイルをレンダリングします

その結果、画面上のタイル数が増えてもCPUの負荷が増加しなくなりました。タイルが50個でも500個でも、小さなビットマスクを書き込み、コンピュートシェーダーを1回ディスパッチし、パスごとに1回のドローコールを発行するだけです。結果の比較やドライバーの問題を調査する必要がある場合は、UseCPUDrawCallsオプションを使用することで、共有バッファとアトラスを引き続き使用しながら、CPU側でドローコマンドを構築する方式に戻すことができます。

Cesium専用スレッドの導入

Cesium Nativeは専用スレッドで動作するようになりました。毎フレーム、メインスレッドが最新のカメラ情報を渡し、Cesiumスレッドがタイルの選択、ロード処理のディスパッチ、新しいビジビリティマスクの生成を行います。各更新の最後に両スレッドがマスクをスワップするため、レンダラーは常に完全で一貫性のあるタイルセットを描画します。

Cesiumスレッドは低い優先度で動作するため、高負荷な環境でもフレーム描画が優先されます。更新に1フレーム以上かかる場合、Cesiumの処理が追いつくまで、レンダラーは直前の完全なタイルセットを描画し続けます。地形の高さクエリも同じスレッドを経由して処理され、その結果はシーンの更新時に返されます。

Evergineの標準機能とのシームレスな統合

独自方式でタイルをレンダリングするからといって、エンジンの標準機能から外れるわけではありません。タイルはEvergineの標準ライティングをベースに構築された専用の都市エフェクトで描画され、通常の各パスにも組み込まれます。シャドウの投影と受け取りの両方に対応しているため、新しいシャドウシステムや時間とともに移動する太陽とも連動し、シーン内の他の不透明ジオメトリと同様にデプスプリパスにも参加します。パイプライン全体はDirectX 11、DirectX 12、およびVulkanで動作します。

GPUリソース使用状況の把握

固定サイズのバッファを扱う上では、その使用率を把握することが重要です。CesiumCoordinator.Diagnosticsは、各タイルセットで表示されているタイル数とメモリに常駐している総タイル数に加え、頂点バッファ、インデックスバッファ、テクスチャアトラスの容量と使用量をレポートするようになりました。これらのカウンターを使えば独自のプロファイリングパネルを簡単に構築できます。また、アトラス自体はTextureAtlasを通じて公開されているため、画面に表示して画像がリアルタイムにパッキングされる様子を確認することも可能です。

パフォーマンス結果

これらすべての改善効果を検証するため、同一条件下でアドオンの旧バージョンと新バージョンを比較しました。どちらも同じCesium設定を使用し、Intel Core i7-14700KとNVIDIA GeForce RTX 4080 SUPERを搭載した環境で、マドリード市街地全体を4Kディスプレイに収めるカメラアングルで計測しました。タイルレンダラー自体の比較に焦点を当てるため、デプスプリパス、Gバッファパス、ポストプロセスは無効化し、CapFrameXでフレームタイムを記録しました。

その差は私たちの予想を上回るものでした。平均フレームレートは40.4 FPSから332.4 FPSへと向上し、1フレームあたりの処理時間は約25 msから約3 msへと短縮されました。つまり、約8.2倍の高速化を実現しています。

指標以前のバージョン新レンダラー
平均40.4 FPS (24.75 ms)332.4 FPS (3.01 ms)
P9544.1 FPS (22.68 ms)355.5 FPS (2.81 ms)
P537.8 FPS (26.46 ms)290.1 FPS (3.45 ms)
P135.9 FPS (27.86 ms)281.2 FPS (3.56 ms)
1% Low平均33.8 FPS (29.59 ms)274.9 FPS (3.64 ms)
0.1% Low平均30.7 FPS (32.57 ms)257.4 FPS (3.89 ms)

平均値は結果の一部に過ぎません。最も遅い1%および0.1%のフレームの平均を示すLow指標は、最も負荷が高い瞬間におけるレンダラーの挙動を表しており、実際にカクつき(スタッター)として体感されるのはこれらのフレームです。ここでも改善効果は顕著に表れており、最も遅いフレームの処理時間は32 ms以上から4 ms未満へと短縮されました。新レンダラーの最悪フレームでさえ旧レンダラーの最良フレームより5倍以上高速であり、高速なフレーム(P95)と最も遅いフレーム(0.1% Low)の差も約10 msから約1 msへと縮小しています。つまり、地球儀の描画が単に速くなっただけでなく、より安定したフレームレートで動作するようになりました。

はじめに

すでにこのアドオンを使用している場合は、Evergine.Cesium NuGetパッケージを更新するだけで新しいレンダラーを利用できます。新規プロジェクトの場合は、ストリーミングしたいタイルセットとともにCesiumCoordinatorをシーンに追加してください。

using Evergine.Cesium;
using Evergine.Cesium.Core;
using Evergine.Cesium.Utils;
using Evergine.Framework;

public class MyScene : Scene
{
    public override void RegisterManagers()
    {
        base.RegisterManagers();

        // The scene must contain an entity tagged as "MainCamera" with a Camera3D component.
        var cesium = new CesiumCoordinator()
        {
            AccessToken = "<YOUR_CESIUM_ION_TOKEN>",
            GeocodingService = new AzureMapsGeocodingService("<YOUR_OPTIONAL_AZURE_MAPS_KEY>"),
            EntityManager = this.Managers.EntityManager,
        };

        // Cesium World Terrain with Bing Maps imagery, plus OpenStreetMap buildings
        cesium.AddOverlayedTileset(1, RasterOverlayProvider.BingAerial, "Terrain");
        cesium.AddTileset(96188, "Buildings");

        this.Managers.AddManager(cesium);
    }
}

以前のバージョンからのアップグレード

新しいレンダラーではいくつかのAPI変更があります。タイルセットは明示的に追加する方式になりました。AddOverlayedTilesetは衛星画像などを投影する地形用であり、AddTilesetは建物などのジオメトリ用です。必要な数だけ追加でき、後から名前やアセットIDを指定して削除することも可能です。

ジオコーディングもプラグイン可能な設計になりました。AzureMapsKeyプロパティはGeocodingServiceに置き換えられ、IGeocodingServiceの任意の実装を受け入れるようになりました。アドオンにはAzureMapsGeocodingServiceと新しいGoogleMapsGeocodingServiceが同梱されているほか、独自の実装を作成することも可能です。

タイルはエンティティではなくなったため、シーン内でタイルエンティティを検索していた従来のコードではタイルを見つけられなくなります。代わりに、ロード状況の確認にはDiagnosticsを、地形のサンプリングにはQueryTerrainMinHeightを使用してください。最後に、新しいレンダラーはアドオンのアセットに同梱されているコンピュートシェーダーとエフェクトに依存しているため、アセンブリとアセットのバージョンが一致していることを必ず確認してください。

ぜひお試しいただき、ご意見をお聞かせください

さらに高速な体験の準備はできましたか?Evergineを更新し、最新の Evergine.Cesium パッケージを追加して、お気に入りの都市をストリーミングしてみましょう。このアドオンを初めて使用する場合は、Evergine.Cesiumの概要から始めるのがおすすめです。完全なリファレンスは Evergineドキュメントでご確認いただけます。問題を見つけた場合やアイデアがある場合は、Evergineフィードバックリポジトリまでお知らせください。

皆様がどのような作品を作られるのか、楽しみにしております。

José Antonio Collado Araque
著者
José Antonio Collado Araque
グラフィックスエンジニア - リサーチャー