ケース スタディ - さまざまな機能を持つデバイス間でアプリをスケーリングする

このケース スタディでは、Windows Mixed Reality アプリケーションがさまざまなハードウェア機能を持つさまざまなプラットフォームを対象とする方法について説明します。 Datascape は、地形データの上に気象データを表示するWindows Mixed Realityアプリケーションです。 アプリケーションは、ホログラフィック データの視覚化でユーザーを囲みます。 ユーザーは、Mixed Reality でデータを検出することで、得られる独自の分析情報を探索できます。

Datascape アプリは、Microsoft HoloLens、Windows Mixed Realityイマーシブ ヘッドセット、低電力 PC、高性能 PC を対象とします。 メインの課題は、パフォーマンス能力が大きく異なるデバイスで高いフレーム レートで実行しながら、視覚的に魅力的なシーンをレンダリングすることでした。

このケース スタディでは、特に雲のような天気をレンダリングする、パフォーマンスの高いシステムをいくつか作成するために使用したプロセスと手法について説明します。 発生した問題とその克服方法について説明します。

Mixed Reality と Unity アプリのパフォーマンスに関する考慮事項の詳細については、次を参照してください。

ケース スタディの概要

Datascape アプリケーションと課題の背景を次に示します。

透明度とオーバードロー

透明性は高価になる可能性があるため、メインレンダリングの苦労は透明性に対処しました。

深度バッファーへの書き込み中に ソリッド ジオメトリ を前面から背面にレンダリングできます。これにより、そのピクセルの背後にある将来のピクセルのレンダリングが停止されます。 この操作により、非表示のピクセルがピクセル シェーダーを実行するのを防ぎ、レンダリングを大幅に高速化します。 ジオメトリを最適に並べ替えると、画面上の各ピクセルが 1 回だけ描画されます。

透明ジオメトリ は前面に並べ替える必要があり、ピクセル シェーダーの出力を画面上の現在のピクセルにブレンドする必要があります。 このプロセスにより、画面上の各ピクセルがフレームごとに複数回描画され、 オーバードローと呼ばれる場合があります。

HoloLens とメインストリーム PC の場合、画面を塗りつぶすことができるのは数回だけであり、透過的なレンダリングが問題になります。

Datascape シーン コンポーネント

Datascape シーンには、 UIマップ天気の 3 つの主要なコンポーネントがあります。 天気の影響には得られるすべてのパフォーマンスが必要になることがわかっているため、オーバードローを減らすために UI とマップを設計しました。

オーバードローの量を最小限に抑えるために、UI を数回修正しました。 光るボタンやマップの概要などのコンポーネントでは、透明なアートをオーバーレイするのではなく、より複雑なジオメトリを使用することを選択しました。

マップでは、 影や複雑な照明などの標準的な Unity 機能を取り除くカスタム シェーダーを使用しました。 カスタム シェーダーは、これらの機能を単純な単一の太陽照明モデルとカスタム 霧計算に置き換えました。 このシンプルなピクセル シェーダーによってパフォーマンスが向上しました。

UI とマップの両方を予算でレンダリングしたので、ハードウェアに依存する変更は必要ありませんでした。 天気の視覚化 (特にクラウド レンダリング) は、より困難でした。

クラウドのデータ

3 つの異なる 2D レイヤーで NOAA サーバー からダウンロードされたクラウド データ。 各レイヤーには、グリッドの各セルについて、クラウドの上下の高さとクラウドの密度が含まれています。 各コンポーネントをテクスチャの赤、緑、青のコンポーネントに格納したクラウド情報テクスチャにデータを処理しました。

ジオメトリ クラウドを作成する

低電力マシンがクラウドをレンダリングできるように、バックアップ アプローチではソリッド ジオメトリを使用してオーバードローを最小限に抑えています。

各レイヤーに対してソリッドの高さマップ メッシュを生成することで、クラウドを生成しました。 頂点あたりのクラウド情報テクスチャの半径を使用して、図形を生成しました。 ジオメトリ シェーダーを使用して、クラウドの上部と下部に頂点を生成し、ソリッド クラウド図形を生成しました。 テクスチャの密度値を使用して、より濃い色で雲を色分けし、より高密度のクラウドに対して色を付けます。

次のシェーダー コードでは、頂点が作成されます。

v2g vert (appdata v)
{
    v2g o;
    o.height = tex2Dlod(_MainTex, float4(v.uv, 0, 0)).x;
    o.vertex = v.vertex;
    return o;
}
 
g2f GetOutput(v2g input, float heightDirection)
{
    g2f ret;
    float4 newBaseVert = input.vertex;
    newBaseVert.y += input.height * heightDirection * _HeigthScale;
    ret.vertex = UnityObjectToClipPos(newBaseVert);
    ret.height = input.height;
    return ret;
}
 
[maxvertexcount(6)]
void geo(triangle v2g p[3], inout TriangleStream<g2f> triStream)
{
    float heightTotal = p[0].height + p[1].height + p[2].height;
    if (heightTotal > 0)
    {
        triStream.Append(GetOutput(p[0], 1));
        triStream.Append(GetOutput(p[1], 1));
        triStream.Append(GetOutput(p[2], 1));
 
        triStream.RestartStrip();
 
        triStream.Append(GetOutput(p[2], -1));
        triStream.Append(GetOutput(p[1], -1));
        triStream.Append(GetOutput(p[0], -1));
    }
}
fixed4 frag (g2f i) : SV_Target
{
    clip(i.height - 0.1f);
 
    float3 finalColor = lerp(_LowColor, _HighColor, i.height);
    return float4(finalColor, 1);
}

実際のデータに加えて詳細を取得するために、小さなノイズパターンを導入しました。 丸い雲のエッジを生成するために、補間された半径値がしきい値に達したときにピクセル シェーダーのピクセルをクリッピングすることで、0 に近い値を破棄しました。

クラウドはソリッド ジオメトリであるため、地形がレンダリングされる前にレンダリングできます。 クラウドの下に高価なマップ ピクセルを非表示にすると、フレーム レートがさらに向上します。 ソリッド ジオメトリ レンダリングアプローチにより、このソリューションは、最小仕様からハイエンドのグラフィックス カード、HoloLens まで、すべてのグラフィックス カードでうまく動作しました。

ジオメトリ クラウドを示す画像。

ソリッド パーティクル クラウドを使用する

このソリューションでは、クラウド データの適切な表現が生成されましたが、少し不足していました。 クラウド レンダリングでは、ハイエンド マシンに求めるボリューム感が伝えられませんでした。 次のステップは、約 100,000 個の粒子で雲を表すことによって、より有機的で体積的な外観を生成することでした。

パーティクルが固体のままで、前面から背面に並べ替える場合でも、以前にレンダリングされたパーティクルの背後で深度バッファー カリングを行うと、オーバードローが減少します。 また、パーティクルベースのソリューションでは、異なるハードウェアをターゲットとするパーティクルの数を変更できます。 ただし、すべてのピクセルを深度テストする必要があるため、オーバーヘッドが増加します。

まず、スタートアップエクスペリエンスの中心点のあたりのパーティクルの位置を作成しました。 中心のあたりにはパーティクルをより高い密度で分布させ、離れた位置にはより低い密度で配布させました。 中央から背面まですべてのパーティクルを事前に並べ替えたので、最も近いパーティクルが最初にレンダリングされます。

コンピューティング シェーダーは、クラウド情報テクスチャをサンプリングして、各パーティクルを適切な高さに配置し、密度に基づいて色付けしました。 各パーティクルには、高さと半径の両方が含まれていました。 高さは、クラウド情報テクスチャからサンプリングされたクラウド データに基づいていました。 半径は、最も近い近隣への水平距離を計算して格納した初期分布に基づいていました。

DrawProcedural を使用して、パーティクルごとにクワッドをレンダリングしました。 四角形では、このデータを使用して、高さによって角度を付けて、自身の向きを設定しました。 ユーザーがパーティクルを水平方向に見ると、高さが表示されます。 ユーザーがパーティクルを上から下に見ると、パーティクルとその隣接するパーティクルの間の領域がカバーされます。

パーティクルの形状とカバレッジを示す図。

次のシェーダー コードは、分布を示しています。

ComputeBuffer cloudPointBuffer = new ComputeBuffer(6, quadPointsStride);
cloudPointBuffer.SetData(new[]
{
    new Vector2(-.5f, .5f),
    new Vector2(.5f, .5f),
    new Vector2(.5f, -.5f),
    new Vector2(.5f, -.5f),
    new Vector2(-.5f, -.5f),
    new Vector2(-.5f, .5f)
});
 
StructuredBuffer<float2> quadPoints;
StructuredBuffer<float3> particlePositions;
v2f vert(uint id : SV_VertexID, uint inst : SV_InstanceID)
{
    // Find the center of the quad, from local to world space
    float4 centerPoint = mul(unity_ObjectToWorld, float4(particlePositions[inst], 1));
 
    // Calculate y offset for each quad point
    float3 cameraForward = normalize(centerPoint - _WorldSpaceCameraPos);
    float y = dot(quadPoints[id].xy, cameraForward.xz);
 
    // Read out the particle data
    float radius = ...;
    float height = ...;
 
    // Set the position of the vert
    float4 finalPos = centerPoint + float4(quadPoints[id].x, y * height, quadPoints[id].y, 0) * radius;
    o.pos = mul(UNITY_MATRIX_VP, float4(finalPos.xyz, 1));
    o.uv = quadPoints[id].xy + 0.5;
 
    return o;
}

パーティクルを前面から背面に並べ替え、ソリッド スタイルのシェーダーを使用して透明ピクセルをクリップし、ブレンドしました。 この手法では、低電力マシンでも多数のパーティクルを処理するため、コストのかかるオーバードローを回避できます。

透明なパーティクル クラウドを試す

固体粒子は、雲の形状に有機的な感覚を提供したが、まだ雲の柔らかいをキャプチャするために何かが必要でした。 私たちは、透明性を導入するハイエンドグラフィックスカード用のカスタムソリューションを試すことにしました。 パーティクルの最初の並べ替え順序を切り替え、テクスチャアルファを使用するようにシェーダーを変更しました。

ふんわりした雲を示す画像。

このソリューションは素晴らしく見えましたが、最も過酷なマシンでも重すぎることが証明されました。 各ピクセルは、画面に何百回もレンダリングする必要がありました。

解像度が低いオフスクリーンをレンダリングする

クラウドをレンダリングするためのピクセル数を減らすために、画面解像度の 4 分の 1 のバッファーにレンダリングしました。 すべてのパーティクルを描画した後、最終的な結果を画面に戻しました。

次のコードは、オフスクリーン レンダリングを示しています。

cloudBlendingCommand = new CommandBuffer();
Camera.main.AddCommandBuffer(whenToComposite, cloudBlendingCommand);
 
cloudCamera.CopyFrom(Camera.main);
cloudCamera.rect = new Rect(0, 0, 1, 1);    //Adaptive rendering can set the main camera to a smaller rect
cloudCamera.clearFlags = CameraClearFlags.Color;
cloudCamera.backgroundColor = new Color(0, 0, 0, 1);
 
currentCloudTexture = RenderTexture.GetTemporary(Camera.main.pixelWidth / 2, Camera.main.pixelHeight / 2, 0);
cloudCamera.targetTexture = currentCloudTexture;
 
// Render clouds to the offscreen buffer
cloudCamera.Render();
cloudCamera.targetTexture = null;
 
// Blend low-res clouds to the main target
cloudBlendingCommand.Blit(currentCloudTexture, new RenderTargetIdentifier(BuiltinRenderTextureType.CurrentActive), blitMaterial);

このソリューションは 4 倍の処理を高速化しましたが、いくつかの注意事項がありました。 まず、オフスクリーン バッファーにレンダリングすると、メイン シーンからすべての深度情報が失われます。 山の上にレンダリングされた山の背後にあるパーティクル。

次に、バッファーをストレッチすると、クラウドの端にアーティファクトが発生し、解像度の変化が顕著になりました。 次の 2 つのセクションでは、これらの問題を解決する方法について説明します。

パーティクル深度バッファーを使用する

パーティクルをワールド ジオメトリと共存させる必要があります。このジオメトリの背後には、山やオブジェクトがパーティクルを覆いました。 そこで、オフスクリーン バッファーに、メイン シーン ジオメトリを含む深度バッファーを設定しました。 深度バッファーを生成するために、シーンのソリッド ジオメトリと深度のみをレンダリングする 2 つ目のカメラを作成しました。

クラウド ピクセル シェーダーの新しいテクスチャを使用して、ピクセルを隠しました。 同じテクスチャを使用して、クラウドのピクセルの背後にあるジオメトリへの距離を計算しました。 その距離を使用し、ピクセルのアルファに適用することで、地形に近づくにつれて雲がフェードアウトする効果を実現しました。 この効果により、パーティクルと地形が出会うハード カットが削除されます。

地形にブレンドされた雲を示す画像。

エッジをシャープにする

引き伸ばされた雲は、粒子の中心にある通常のサイズの雲とほぼ同じように見えましたが、重なり合っていますが、雲の端にいくつかのアーティファクトが表示されました。 シャープなエッジがぼやけて表示され、カメラモーションによってエイリアス効果が導入されました。

この問題を解決するには、次の手順を実行します。

  1. オフスクリーン バッファーで単純なシェーダーを実行して、コントラストの大きな変更が発生した場所を特定しました。
  2. 大きな変更を加えたピクセルを新しいステンシル バッファーに配置します。
  3. オフスクリーン バッファーを画面に戻すときに、ステンシル バッファーを使用してこれらのハイ コントラスト領域をマスクし、雲の内外に穴が開きます。
  4. ステンシル バッファーを使用してエッジ以外のすべてをマスクして、すべてのパーティクルを全画面表示モードで再びレンダリングし、その結果、最小限のピクセル セットがタッチされました。 パーティクルをレンダリングするコマンド バッファーが既に作成されているため、新しいカメラにもう一度レンダリングするだけです。

クラウド エッジのレンダリングの進行を示す画像。

最終的な結果は、クラウドの安価なセンターセクションで鋭いエッジでした。 このソリューションは、すべてのパーティクルを全画面表示にするよりもはるかに高速ですが、ステンシル バッファーに対してピクセルをテストするコストは依然として発生します。 大量のオーバードローはまだ高価です。

カリングパーティクル

風の効果のために、計算シェーダーで長い三角形のストリップを生成し、世界で多くの風を作り出しました。 風の影響は、狭いストリップのために、充填率に重くありませんでした。 ただし、数十万個の頂点が頂点シェーダーの負荷が高くなっています。

負荷を軽減するために、描画する風帯のサブセットをフィードするために、コンピューティング シェーダーに追加バッファーを導入しました。 コンピューティング シェーダーで単純な ビューの錐台カリング ロジックを 使用して、ストリップがカメラ ビューの外部にあるかどうかを判断し、それらのストリップがプッシュ バッファーに追加されるのを防ぎました。 このプロセスにより、ストリップの数が大幅に減り、パフォーマンスが向上しました。

次のコードは、追加バッファーを示しています。

コンピューティング シェーダー:

AppendStructuredBuffer<int> culledParticleIdx;
 
if (show)
    culledParticleIdx.Append(id.x);

C# コード:

protected void Awake() 
{
    // Create an append buffer, setting the maximum size and the contents stride length
    culledParticlesIdxBuffer = new ComputeBuffer(ParticleCount, sizeof(int), ComputeBufferType.Append);
 
    // Set up Args Buffer for Draw Procedural Indirect
    argsBuffer = new ComputeBuffer(4, sizeof(int), ComputeBufferType.IndirectArguments);
    argsBuffer.SetData(new int[] { DataVertCount, 0, 0, 0 });
}
 
protected void Update()
{
    // Reset the append buffer, and dispatch the compute shader normally
    culledParticlesIdxBuffer.SetCounterValue(0);
 
    computer.Dispatch(...)
 
    // Copy the append buffer count into the args buffer used by the Draw Procedural Indirect call
    ComputeBuffer.CopyCount(culledParticlesIdxBuffer, argsBuffer, dstOffset: 1);
    ribbonRenderCommand.DrawProceduralIndirect(Matrix4x4.identity, renderMaterial, 0, MeshTopology.Triangles, dataBuffer);
}

クラウド パーティクルに対してこの手法を試し、それらをコンピューティング シェーダーでカリングし、レンダリングされる可視のパーティクルのみをプッシュしました。 しかし、頂点を計算するコストではなく、画面上にレンダリングするクラウド ピクセルの数が最大のボトルネックであったため、処理をあまり節約できませんでした。

もう 1 つの問題は、パーティクルの並列計算により、追加バッファーがランダムな順序で設定されていることです。 並べ替えられたパーティクルが並べ替えられていないため、雲のパーティクルがちらつくようになりました。 プッシュ バッファーを並べ替える手法がありますが、パーティクルのカリングによるパフォーマンスの向上量は、おそらく別の並べ替えによってオフセットされる可能性があります。 クラウド パーティクルに対してこの最適化を追求しないことにしました。

アダプティブ レンダリングを使用する

クラウドビューとクリア ビューなど、レンダリング条件が異なるアプリで安定したフレーム レートを確保するために、アダプティブ レンダリングを導入しました。

アダプティブ レンダリングの最初の手順は、パフォーマンスを測定することです。 レンダリングされたフレームの先頭と末尾のコマンド バッファーにカスタム コードを挿入し、左右の目の画面時間の両方をキャプチャしました。

レンダリング時間を目的のリフレッシュ レートと比較して、フレームのドロップにどれだけ近づくかを示します。 フレームのドロップに近づくと、レンダリングをより高速に調整できます。

レンダリングを調整する簡単な方法の 1 つは、レンダリングに必要なピクセル数を減らすために画面ビューポートのサイズを変更することです。 システムは UnityEngine.XR.XRSettings.renderViewportScale を使用して対象のビューポートを縮小し、結果を画面に合わせて自動的に拡大します。 スケールの小さな変更は、ワールド ジオメトリではほとんど目立ちません。スケール係数 0.7 では、レンダリングするピクセル数の半分が必要です。

ピクセルの半分を含む 70% のスケールを示す画像。

フレームをドロップしようとしていることが検出されると、スケールを固定比で下げ、もう一度十分に速く実行しているときに復元します。

このケース スタディでは、起動時のハードウェアのグラフィックス機能に基づいて、どのクラウド手法を使用するかを決定しました。 また、パフォーマンス測定のデータに基づいてこの決定を行い、システムが長時間低解像度に留まるのを防ぐのに役立つ場合もあります。

Recommendations

さまざまなハードウェア機能をターゲットにすることは困難であり、計画が必要です。 以下に、推奨事項をいくつか示します。

  • 問題領域を理解するために、低電力マシンのターゲット設定を開始します。
  • すべてのマシンで実行されるバックアップ ソリューションを開発します。 その後、ハイエンド マシンの複雑さを増やしたり、バックアップ ソリューションの解決を強化したりできます。
  • ピクセルは最も貴重なリソースであるため、フィル レートを念頭に置いてソリューションを設計します。
  • 透明度の高いターゲットソリッドジオメトリ。
  • 最悪のシナリオ向けに設計し、負荷の高い状況でアダプティブ レンダリングを使用することを検討します。

著者について

ロバート・フェレーゼの写真 Robert Ferrese
ソフトウェア エンジニア @Microsoft
ダン・アンダーソンの写真 Dan Andersson
ソフトウェア エンジニア @Microsoft

関連項目