Outbound のクリップシェーダーのアプローチ:リアルタイム環境のための正確な植生破棄

オープンワールドのバンライフゲームで、床に草が食い込むのを防ぐにはどうすればよいでしょうか。このゲスト記事では、Square Glade Games のプログラマー Tony Fial 氏と Michiel Procé 氏が、『Outbound』でカスタムシェーダークリップソリューションをどのように活用してこの問題を解決したのかを詳しくご紹介します。
Square Glade Games のチームの一員である Tony Fial 氏と Michiel Procé 氏は現在、同スタジオの最新タイトル『Outbound』の開発に取り組んでいます。このゲームは近未来のユートピアを舞台にしたオープンワールドアドベンチャーゲームです。プレイヤーは、何もないキャンパーバンから始めて、それを夢のモバイルホームに変え、思い通りに構築することができます。
乗り物は、自然の中を走り抜けながら、ゲームの大きな焦点となります。『Outbound』の世界は手作りで、植物や草がたくさん生えています。光沢があり、背が高く、豊かです。これらのアセットを使って美しい世界を作ることができましたが、そのような環境の中を走行する乗り物と組み合わせると、視覚的な問題が発生しました。
問題点
プレイヤーは、基本的にどのオープンエリアでも、自分のキャンパーバンを運転することができます。茂みと草は、このための妨げ物ではありません。バンが地面にかなり近いため、地形の草が車体の下部や側面を貫通してしまうことがよくありました。
また、花や茂みなど、背の高い植生にバンが到達できる場所もあります。下のスクリーンショットは、車両内で草と茂みが大きくクリッピングされているケースを示しています。これは、視覚的に魅力的でないだけでなく、インタラクションや重要な情報を視覚的に遮断するなど、ゲームプレイにさまざまな問題を引き起こします。

私たちのコアな問題をまとめると、キャンパーのバンにはさまざまな種類の植物や草が生えていますが、これはビジュアルやゲームプレイの観点からは望ましくありません。
では これを解決しましょう
考えられる解決策のブレインストーミング
Square Glade Games では、ソリューションの開発に積極的に取り組む前に、最適な要件のリストを作成しておくと便利です。
このケースでは、以下のことを実現するソリューションが必要でした。
• 高いパフォーマンスOutbound では草が多いため、草や植物が多い地域では、最適化されていないソリューションは非常にコストがかかる可能性があります。
• 元のスタイルをそのまま維持します。現在、Outbound では主要な要素の外観を変更できない開発段階にあるため、理想的には、元の植生をできるだけ活用するソリューションです。
• クロスプラットフォームの互換性このゲームは複数のプラットフォームでの発売が予定されており、Windows、Nintendo Switch™、Xbox、および PlayStation® で動作する必要があります。
• 直感的に使用できる。チーム内のデザイナーとプログラマーの両方にとって、理想的には直感的に操作できるソリューションである必要があります。
• 複数の形状に適用できます。理想的には、複数の形状を使用して、車両の正確な形状で植生を切り取ることです。
では、この一連の要件を満たすソリューションを考えましょう。最初に考えたのは、すべての草の葉に共通する要素、つまりシェーダーです。
Outbound の植物のほとんどは、Terrain ツールを使用して Unity の地形に配置されています。そのうちかなりの大きさを占めるのが草で、デフォルトの Grass シェーダーを使用します。このシェーダーは GPU を使用して、草の平面を非常にパフォーマンスの高い方法で配置およびビルボード化します。上のスクリーンショットにある大きな茂みのような他の要素は、それぞれに割り当てられたマテリアルとシェーダーを使用して、ディテールメッシュとして配置されます。
これはもう 1 つ重要な詳細を提示しました。つまり、提案するソリューションは、まったく異なる複数のシェーダーで同じ方法で同時に作業できる必要があるということです。
提案するソリューション
以下に提案するすべてのソリューションには、1 つの大きな「入力」という共通点もあります。キャンパーバンの位置(より正確には、群葉を切り取るべき領域)。
ここに示した要件から、Square Glade チームの他のメンバーが直感的に使用できるソリューションにしたかったのです。私たちの経験では、エディターツールは直感的で簡単に使えるようになって初めて、チームメンバーによって使用されるようになります。そこで私たちは、拡大縮小、回転、操作が可能なビジュアル 3D キューブを作成して、車体のちょうどいい部分をクリップし、ちょうどいいように調整することにしました。キューブ内の植物はすべてクリップされますが、外側の植物はすべて同じ見た目になります。
ステンシルシェーダー
最初に試したのは、「ステンシルバッファ」と呼ばれるシェーダー要素の使用です。
シェーダープログラミングのこの部分は非常に魅力的ですが、頭を悩ませるのも少し難しいものです。つまるところ、これはレンダリングされたフレームのステンシルバッファに情報を書き込むように「クリッピング要素」(この場合はキューブ)に指示するということです。つまり、キューブがある画面上のどこにでも、1 の値が書き込まれます。「クリップされた」オブジェクト(ここでは草)は、そのバッファから読み取り、値がちょうど 1 に設定されているピクセルを破棄できます。
シェーダーコードでは、このようになります。
Clipping object 'Cube'
Stencil
{
Ref 1
Comp always
Pass replace
}
Clipped object 'Grass'
Stencil
{
Ref 1
Comp equal
}クリッピング オブジェクトは、行 Ref 1 で示される値 1 をバッファに書き込みます。これは常に実行されます。後でレンダリングされたステンシル値が一致するか、ステンシル比較に合格すると、このシェーダーの情報で置き換えられます。草にも同じような実装があります。また、参照 1 の値も検索され、「Comparison」がその参照値と等しい場合にのみチェックに合格します。
この実装は草のクリッピングを行うもので、レンダリングされたフレームのピクセルに対して機能し、特定のシーンの草の量に影響されないため、非常に効率的でした。しかし、この解決策には致命的な欠陥がありました。この実装には奥行きがないため、キューブの後ろにあるものも切り取られます。実際には、これはプレイヤーが車内に座っているとき、一人称視点の場合は画面全体が「クリップ」としてマークされるため、プレイヤーはどこにも草を見ないことを意味します。そのため、プレイヤーカメラが「クリッパー」オブジェクト内にあるときにも機能する他の方法をいくつか試す必要がありました。
手動クリッピング
簡単に説明した解決策は、自車位置の草を手動で除去し、地形自体から取り除くことでした。Unity がテレインに提供する「TerrainData.SetDetailLayer」関数を使用して、ゲーム内の他のパーツについてもこれを行いました。これにより、バンのすぐ下のピクセルのディテールレイヤーのグレースケールの色が 0 に設定され、その位置のディテールメッシュや草を削除するようにテレインに指示が出されます。
Outbound のマップはかなり大きいため、ディテールレイヤーの解像度が下側にあり、少しギザギザになります。これは草やその他のメッシュの通常のディテール配置ではまったく問題ありませんが、手動でパーツを切り離す場合、解像度が低いとバンのサイズに近づかない形状(小さすぎたり大きすぎたり)になります。
また、この解決策では、車両が 2 つのテレインディテールピクセルの境界にあるときにディテールのちらつきが発生します。これらの理由から、私たちはこのソリューションの実装を進めませんでした。私たちの旅は続きます!
クリップシェーダー
ステンシルバッファシェーダーを使って、バンの車体の外側の精度でピクセルを見えないようにレンダリングしたので、もう少しで完成すると思いました。キューブの深度を実際に使用しながら、別の方法があれば、解を知るのは基本的にバウンディングボックス内のピクセルのみをクリップします。
実は、これを行うメソッドがあります!HLSL シェーダーは、指定された値が 0 より小さい場合にピクセルを単純に破棄する、Mobility clip() 関数を提供します。これは、アルファクリッピングによく使用されるランダムシェーダーで見たことがあるかもしれません。
たとえば、Outbound の草は、草のテクスチャのアルファチャンネルが黒い場所であればどこでも「切り取る」ので、草の画像が付いた正方形の四角形ではなく、実際の草の房のように見えます。
このソリューションの最初のプロトタイプ/チェックを行ったとき、特定のワールド位置を超えてピクセルを見えなくすることができるので、この実装が機能するだろうという期待が寄せられました。擬似コードでは、関数は次のようになります。
// Return -1 when the Y position is above 0, and return 1 when it is not.
clip( worldPos.y > 0 ? -1 : 1 );解決策:クリップシェーダー
この時点で、簡単な例で、有望な解決策が示されていました。次のステップは、正確にクリップするために必要な情報をシェーダーに提供する関数を作成することでした。これには 2 つの部分が含まれていました。
• 基本的に「形状」の寸法や変形などを計算し、そのデータをシェーダーに提供する部分。
• シェーダーがこのデータを使用し、特定の点が形状内にあるかどうかをチェックして、必要に応じてそのピクセルを破棄する部分。
解決策の最初のステップとして、「GrassClipperShape」スクリプトを作成しました。これは、シーン内のオブジェクトにアタッチできる MonoBehaviour で、クリッピング領域を指定します。以下にその例を示します。エディタービューで OnDrawGizmos を使用したシェイプの領域が表示されます。

これらのクリッパーを複数使用することが理想的であるため、利用可能なすべてのクリッパーを処理するための包括的なスクリプト(「マネージャー」)が必要です。各クリッパーは、「GrassClipperManager」という名前のこのスクリプトに以下のプロパティを提供します。
• Shape:Shape のタイプ。このバージョンはキューブとスフィアの両方で動作するようにしたかったので、「cube」または「sphere」に設定されたシンプルな列挙型にします。
• Vector3:シーン内のオブジェクトのサイズ
• Matrix4x4:ワールド空間で計算された回転オブジェクト
GrassClipperManager は、シーン内に 1 つだけ存在し、フレームごとにクリッパーからこの情報を取得し、以下のようにシェーダーに送信します。
Shader.SetGlobalInteger("_ShapeCount", count);
Shader.SetGlobalMatrixArray("_ShapeInvMatrix", inv);
Shader.SetGlobalVectorArray("_ShapeParams", size);
Shader.SetGlobalFloatArray("_ShapeType", type);上の行はグローバルシェーダーの値を設定します。簡単に説明すると、これらの正確な名前と型を持つシェーダー値を使用でき、どのシェーダーでも使用できるということです。
クリッピングを複数の異なるシェーダーで行いたいので、クリッパーの影響を受ける必要があるシェーダーに含めるために、HLSL スクリプトを別に作成しました。このスクリプトは、「ApplyClipVolumeSDF」という名前のカスタム関数を公開します。入力されたグローバルシェーダーの値の情報を使用して、ピクセルがいずれかの範囲内にあるかどうかを計算します。
inline void ApplyClipVolumeSDF(float3 worldPos)
{
float clipVal = GetClipFade(worldPos);
if (clipVal <= 0.0)
clip(-1);
}上記のように、ピクセルが破棄されることになっていると、「clip(-1)」関数が呼び出され、破棄されたピクセルが返されます。そうでない場合は、シェーダーの残りの部分を通じて通常どおり進行します。
クリップシェーダーの実装
クリッピング機能の作成と必要なデータの提供が完了したので、シェーダーへの実装に取り掛かりました。
まず、ディテールのメッシュでこれを行う方法をご説明します。オリジナルのコピーを作成して編集できます。シェーダーの最上部で、以下のようにカスタムスクリプトを参照する必要があります。
#include "Assets/Shaders/ClipVolume.hlsl"そして、実際に関数を使用したい場合は、以下のようにシェーダーのフラグメント部分内で関数を呼び出すだけです。
float3 worldPos = mul(unity_ObjectToWorld, float4(input.positionOS, 1.0)).xyz;
ApplyClipVolumeSDF(worldPos);この例では、これを含める必要があるのは 2 つのシェーダー(Unity の草が使用するデフォルトシェーダーと、その他のすべての群葉にディテールメッシュとしてレンダリングされるカスタムシェーダー)だけでした。これで完成です。必要に応じて他のシェーダーにも簡単に実装できます。
しかし、私たちの旅は終わりませんでした。最後の壁が目の前に立ちはだかったのです。では、デフォルトの草シェーダーに加えられた変更を実際に編集して保持するにはどうすればよいのでしょうか。Unity では、草のレンダリングに特定のビルトインシェーダー(ここでは「WavingGrassビルボードシェーダー」)を使用しています。このシェーダーはすべての草に自動的に適用され、カスタムバリアントを提供するオプションはありません。これは、カスタムの「ApplyClip」関数を呼び出して不要なピクセルを破棄できるようにするために、そのシェーダーに接続する必要があったため、私たちのソリューションを機能させるために不可欠でした。
いくつかの解決策を試した後、仲間のチームメンバーである Michiel Procé 氏は、デフォルトの草のシェーダーへの変更を確実に編集して保持する方法を見つけました。ビルド中およびエディター内で以下のコードを実行することで、カスタムシェーダーがデフォルトの URP シェーダーに置き換わります。
string replacementShaderName = "Hidden/TerrainEngine/Details/UniversalPipeline/BillboardWavingDoublePass_Clipped";
if (GraphicsSettings.TryGetRenderPipelineSettings<UniversalRenderPipelineRuntimeShaders>(out var shadersResources))
{
if (shadersResources.terrainDetailGrassBillboardShader.name != replacementShaderName)
{
Shader replacementShader = Shader.Find(replacementShaderName);
shadersResources.terrainDetailGrassBillboardShader = replacementShader;
}
}これは WavingGrassビルボードのシェーダーを置き換えるだけですが、他のシェーダーにも実装するのと似ています。
結び
クリップシェーダーを使用する最終的なソリューションは私たちの目的に適しており、結果に非常に満足しています。下のスクリーンショットは、長方形のキューブが中の草を切り取る解のビジュアライゼーションです。ボックスは上から見上げた状態で、クリップされたものを最適に見せるために地形を通して配置されています。

草刈りソリューションの要件リストを見直したところ、すべてに準拠していることがわかりました。
• クリッピングの計算に使用される関数が非常に安価であるため、ソリューションは高性能です。ピクセルを丸ごと破棄するので実装上不要な処理は行われません
• すでに使用していたシェーダーの上に構築されているため、Outbound のオリジナルスタイルが維持されます。
• clip() 関数自体がプラットフォームを問わない実装である。
• チームの他のメンバーにとって直感的なソリューションデザイナーは複数の形状を作成して使用でき、それらを互いに交差させることもできます。
上記のような機能は、創造性を発揮するだけでなく、後から奇妙なバグが発生することを防ぐためにも非常に重要であると考えています。
サンプルプロジェクト
このソリューションをコミュニティと共有するために、上記で詳しく説明したテクニックを使用してサンプルプロジェクトを作成しました。ぜひご自分でお試しください。GitHub でご確認ください。
ゲスト記事を読んでいただきありがとうございます。私たちと同じ問題に直面している他の多くの開発者の助けになれば幸いです。
Outbound は現在クローズドベータテスト中です。Steam でゲームをフォローして最新情報をご確認ください。Steamキュレーター ページでMade with Unityのゲームをもっと詳しく見たり、リソース ハブでUnity開発者のストーリーをチェックしたりできます。
Nintendo Switch™ は任天堂の商標です。
