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

このケース スタディでは、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まで、すべてのグラフィックス カードで適切に実行されました。

Image that shows geometry clouds.

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

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

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

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

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

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

Diagram that shows particle shape and coverage.

次のシェーダー コードは、ディストリビューションを示しています。

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;
}

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

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

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

Image that shows fluffy clouds.

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

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

クラウドをレンダリングするためのピクセル数を減らすために、画面解像度の 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 つ目のカメラを作成しました。

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

Image that shows clouds blended into terrain.

エッジをシャープにする

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

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

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

Image showing the progression of rendering cloud edges.

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

カリング パーティクル

風の効果のために、計算シェーダーで長い三角形のストリップを生成し、世界中に多くの風を作り出しました。 狭いストリップのため、風効果は盛土率に重くありませんでした。 ただし、数十万個の頂点が頂点シェーダーの負荷を大きくしました。

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

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

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

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 では、レンダリングするピクセル数の半分が必要です。

Image showing 70% scale, with half the pixels.

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

このケース スタディでは、起動時のハードウェアのグラフィックス機能に基づいて、使用するクラウド手法を決定しました。 また、パフォーマンス測定のデータに基づいてこの決定を行い、システムが長時間低解像度にとどまらないようにすることもできます。

推奨事項

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

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

著者について

Picture of Robert Ferrese Robert Ferrese
ソフトウェア エンジニア @Microsoft
Picture of Dan Andersson Dan Andersson
ソフトウェア エンジニア @Microsoft

関連項目