RenderGraphTutorial
Render Graph Learning Repository
Install / Use
/learn @CyberAgentGameEntertainment/RenderGraphTutorialREADME
Unity 6 Render Graph入門~Render Graphの仕組みと簡単なポストプロセス実装~
1. Render Graphとは?
Render GraphはUnity 6から導入されたシステムでScriptable Render Pipelineで動作します。このシステムを利用することで、ユーザー独自のスクリプタブルレンダリングパスを追加でき、より柔軟にレンダリングパイプラインをカスタマイズできます。
Render GraphはHDRPには以前から存在しており、Unity 6からURPでも利用できるようになりました。
Render Graph以前
さて、Scriptable Render Pipeline自体はUnity 6以前から存在しており、ユーザー独自のレンダリングパスを追加することは元々できていました。<br/>
つまり、レンダリングの自由なカスタマイズは以前から行えていたことになります。</br>
なお、Unity 6.3まではCompatible Mode(互換性モード)が用意されており、このモードがオンになっているとRender Graph以前のカスタムレンダリングパスの処理を引き続き利用することができます。</br>
しかし、6.4からはCompatible Modeが完全に削除されて、Render Graphに完全に移行することが発表されています。</br>
これは破壊的な変更で、既存のカスタムレンダリングが完全に動作しなくなることを意味しています。
では、なぜUnityは破壊的な変更を推進するのでしょうか。これには現在のモバイルGPUアーキテクチャと密接にかかわる理由があります。 次の節では現在のGPUのアーキテクチャを紹介し、Render Graphを理解するための下地を学びます。
2. タイルベースレンダリング
現代のGPUは演算速度とメモリの読み書き速度の差がどんどん大きくなり、メモリアクセスが大きなボトルネックになっています。PCのディスクリートGPUではグラフィックス専用のメモリ帯域の広いグラフィックスメモリを搭載することでこの問題を軽減できます。しかし、多くのモバイルGPUでは発熱量、消費電力の問題で専用のグラフィックスメモリを持たず、CPUとGPUでメモリを共有しています。そのため、メモリの読み書き速度はPC以上に大きな問題となります。そこで、この問題を解決するためにTBRアーキテクチャのGPUが生まれました。
TBRアーキテクチャのGPUではタイルメモリという小さなキャッシュメモリをシェーダーコアに搭載し、そのメモリに対して書き込みと読み込みを行います。このタイルメモリは物理的距離もGPUコアから近く、メモリ帯域の問題を軽減できるため、TBRアーキテクチャのGPUが主流になってきました。 次の図はARM Mali-G71 GPUの公式資料から抜粋したシェーダーコアの設計図です。シェーダーコアにタイルメモリが接続されていることが分かります。
<p align="center"> <img width="60%" src="figs/001.png"><br> <font color="grey">ARM Mali-G71 シェーダーコア設計図</font> </p>TBRアーキテクチャのGPUでは次の図のように画面をタイルで分割し、シェーダーコアに接続されたタイルメモリに対してレンダリングします。
<p align="center"> <img width="60%" src="figs/002.png"><br> <font color="grey">タイルベースレンダリングのイメージ図-1</font> </p>レンダーバッファのロード/ストア
さて、モバイルのGPUではタイルメモリへのリードライトを間にはさむことによって、グラフィックスメモリへのアクセス回数を減らすことができ高速化を行えることがわかりました。しかし、最終的にはタイルメモリの内容をメモリにストアする操作が必要になるため、この回数が多くなるとやはりネックになってきます。
例えばG-Bufferを作成してライティングを行うディファードレンダリングを考えてみましょう。ディファードレンダリングの流れは次のようになります。
- G-Bufferの作成(アルベド、法線、メタリック/スムースマップへの書き込み)
- ディファードライティング(1で作成したG-Bufferをテクスチャとして利用する)
この処理をさらに詳細に見ていくと次のようになります。
- G-Bufferの作成(アルベド、法線、メタリックの情報をタイルメモリに書き込んでいく)
- アルベド、法線、メタリックの情報をメインメモリにストアする
- 2でストアされたG-Bufferをサンプリングしてディファードライティングを実行
このように、1で作成されたG-Bufferを3でテクスチャとして利用するためには一度メインメモリにストアする必要があります。しかし、ここで一つ疑問が生まれます。</br> 3のG-Bufferのサンプリングをテクスチャからではなく、直接タイルメモリから読み込むことはできないのでしょうか。</br> もしタイルメモリからの読み込みができれば次のような処理になりメインメモリへのストアを削減できます。
- G-Bufferの作成(アルベド、法線、メタリックの情報をタイルメモリに書き出し)
- タイルメモリからG-Bufferをサンプリングしてディファードライティングを実行
この機能はフレームバッファフェッチと呼ばれ、Unityでも利用できます。
3. GPUの並列動作
GPUは超並列演算機と呼ばれるほど、並列演算が得意です。GPUの演算性能を上げるためには、シェーダーコードのような下流の部分でのコードの書き方も重要になってくるのですが、もっと上流のレンダリングパイプラインの設計時のリソース依存性の考慮も重要になってきます。ここではリソース依存性に焦点をあててみていきます。
リソース依存性
ゲームの絵を完成させるまでの1フレームのリアルタイムの工程をレンダリングパイプラインといいます。また、このレンダリングパイプラインを構築している一つ一つの処理をレンダリングパスと呼びます。
レンダリングパスは内部でさらに細かいパスに分割されることがあります。
例えば、先ほどのGeometry Renderingパスでは次のようなパスの組み合わせでレンダリングパスが構築されています。
- 深度プリパスで深度バッファを作成
- G-Bufferに不透明オブジェクトを描画
- 深度バッファを使用
- 不透明バッファにディファードライティング
- G-Bufferと深度バッファを使用
- 半透明バッファに半透明オブジェクトを描画
- 深度バッファを使用
- 不透明バッファと半透明バッファを合成してフレームバッファに描画
ここで、Geometry Renderingパスの詳細を知る必要はありません。重要なのはリソース依存性です。このようなレンダリングパスが構築されているときに、リソース依存性をGPUに伝えられれば、GPUは処理を並列に実行できます。</br>
この例では、4番のパスは2番と3番のパスの描画結果を利用していないので、2、3を実行しているときに4を並列に処理できます。
<p align="center"> <img width="60%" src="figs/003.png"><br> <font color="grey">レンダリングパスの並列化</font> </p>このようなグラフのことを有向非巡回グラフ(Directed Acyclic Graph、DAG)と呼びます。
VulkanのRenderPass/SubPass
リソース依存性をGPUに伝えることで、GPUの並列動作性を高められることが分かりました。では、リソース依存性をGPUに伝えるにはどうすればいいのでしょうか?
現在のモバイルGPUで広く利用可能なVulkanやMetalなどのグラフィックスAPIにはRenderPass/SubPassという仕組みがあります。<br/>
この仕組みを利用することで、リソース依存性を定義できてGPUに教えることができます。
RenderPassはシャドウマップ描画パスや不透明描画パスなどをAPIレベルで定義するためのものです。またRenderPassには一つ以上のSubPassが含まれます。<br/> たとえば先ほどのGeometry RenderingをRenderPass/Subpassで表すと次のようになります。
- Geometry Renderingパス(RenderPass)
- 深度プリパス(Subpass)
- 不透明オブジェクトをG-Bufferに描画(Subpass)
- 不透明バッファにディファードライティング(Subpass)
- 半透明バッファに半透明オブジェクトを描画(Subpass)
- 不透明バッファと半透明バッファを合成してフレームバッファに描画(Subpass)
また、SubPassは入力アタッチメントと出力アタッチメントを定義できます。<br/>例えば、先ほどの例であれば次のようにリソースを定義できます。
- Geometry Renderingパス(RenderPass)
- 深度プリパス(Subpass)
- 出力アタッチメント: 深度バッファ
- 不透明オブジェクトをG-Bufferに描画(Subpass)
- 入力アタッチメント: 深度バッファ
- 出力アタッチメント: G-Buffer
- 不透明バッファにディファードライティング(Subpass)
- 入力アタッチメント: G-Buffer
- 出力アタッチメント: 不透明バッファ
- 半透明バッファに半透明オブジェクトを描画(Subpass)
- 入力アタッチメント: 深度バッファ
- 出力アタッチメント: 半透明バッファ
- 不透明バッファと半透明バッファを合成してフレームバッファに描画(Subpass)
- 入力アタッチメント: 不透明バッファ、半透明バッファ
- 出力アタッチメント: フレームバッファ
- 深度プリパス(Subpass)
入力アタッチメント/出力アタッチメントを定義することによって、SubPass間のリソースの依存性をGPUに教えることができます。そしてこの依存性を工夫することでレンダリングパスの並列実行も可能になります。
メモリーレス
先ほどのGeometry Renderingパスを見てみると、深度バッファ、G-Buffer、不透明バッファ、半透明バッファは計算用の中間バッファで、タイルメモリ上のリソースです。<br/>
もし、これらのリソースをテクスチャとして後続のパスで利用しない場合、メインメモリにストアせずに破棄できます。Vulkan/Metalではリソースにメモリーレスモードを指定でき、メモリーレスが指定されたリソースはストアされずに破棄されます。このモードを利用することでメモリ使用量を削減できます。
4. Unityとの関連
Unityも内部的にはVulkan/MetalなどのAPIを利用しています。そのため、RenderPass/Subpassを効果的に利用することでGPUの並列動作性を高めることが可能です。しかしながら、Render Graph以前のScriptable Render Pipelineでは典型的なコードを書いた場合は、RenderPassとSubpassが必ず1対1になってしまいます。
以前のシステムで実装される典型的なパスは、独立したパスになってしまい、パス間のリソース依存性を定義する方法がありません。そのため、RenderPassの中に複数Subpassがあるようなパスを構築することが難しかったです。<br/> (※VulkanのRenderPassとほぼほぼ同等のScriptableRenderContext.BeginRenderPass/BeginSubPassを利用することで同等のことを実現することはできます)<br/> また、リソース依存性が定義されていないので、メモリーレスモードを使用したい場合は、プログラマが注意を払ってメモリーレス対応を行う必要があります。
この問題がRender Graphでは解消されています。Render Graphを利用してRenderPassを追加する場合は、次のような情報をシステムに渡します。
- 出力アタッチメント
- 入力アタッチメント
- 使用するテクスチャ
- etc
この情報を元にRender GraphはNative pass(VulkanのRenderPassに相当)を構築します。そして、可能であればサブパスも構築してくれます。Render Graphではこれをパスをマージすると呼びます。<br/>
パスがマージされる主な条件は次のようなものになります。
- 出力アタッチメントの解像度が同じ
- 出力アタッチメントをテクスチャとして利用していない
- ※入力アタッチメントとテクスチャは別物
この条件を満たすときにパスがマージされ、複数のサブパスが一つのレンダーパスに含まれます。また、メモリーレス化もRender Graphが行ってくれます。これは後続するパスで中間リソースが利用されていない場合、自動的にメモリーレス化してくれます。
【ハンズオン演習】シーンをモノクロ化した画像とセピア化した画像を合成する
では、簡単なポストプロセスを実装していきましょう。Assets/Demo_00/Demo_00.unityシーンを開いてください。
このシーンを開くと次の画像のようなステージが表示されます。<br/>
今回は、このシーンに深度情報を元にモノクロ化とセピア化された画像を合成する処理を実装していきます。</br>
なお、この演習の完成版としてMonoChromeSepiaPass.cs.after、Monochrome-Sepia.shader.afterが用意されていますので、問題が起きたときはそちらのコードを参照してみてください。
step-1 モノクロ画像の描きこみ先のテクスチャを作成する
最初は、C#側のコードから実装していきます。Assets/Demo_00/Feature/MonoChromeSepiaPass.csを開いてください。
まずはモノクロ画像の描き込み先となるテクスチャを作成します。下記のコードを該当するコメントの箇所に入力してください。 このテクスチャは計算用の一時テクスチャで、このパス以降で利用しないので、メモリーレスモードを指定している点に注目してください。
// step-1 モノクロ画像の描きこみ先のテクスチャを作成する
var monochromeTextureDesc = renderGraph.GetTextureDesc(universalResourceData.cameraColor);
// メモリーレスを指定
monochromeTextureDesc.memoryless = RenderTextureMemoryless.Color;
monochromeTextureDesc.name = "Monochrome Texture";
var monochromeTextureHandle = renderGraph.CreateTexture(monochromeTextureDesc);
step-2 セピア画像の描きこみ先のテクスチャを作成する
続いて、セピア画像の描き込み先となるテクスチャを作成します。<br/> なお、モノクロ化とセピア化の画像はRender Graphの管理下のテクスチャとなっているため、不要になるとRender Graphが適切なタイミングでテクスチャを破棄してくれます。そのため、アプリケーション側がライフサイクルを管理する必要はありません。<br/>
(※ アプリケーション側でテクスチャのライフサイクルを管理したい場合はRTHandleなどを利用して、アプリ側で作成したテクスチャをRender GraphにImportする必要があります。)
では次のコードを入力してください。
var sepiaTextureDesc = renderGraph.GetTextureDesc(universalResourceData.cameraColor);
// メモリーレスを指定
sepiaTextureDesc.memoryless = RenderTextureMemoryless.Color;
sepiaTextureDesc.name = "Sepia Texture";
var sepiaTextureHandle = renderGraph.CreateTexture(sepiaTextureDesc);
step-3 モノクロ化のパスを作成する
リソースの準備ができたので、各種パスをRender Graphに追加していきます。まずはモノクロ化のパスです。<br/>
今回追加するのは最も基本となるRaster Render Passです。<br/>
RenderGraph::AddRasterRenderPassが返してくるIRasterRenderGraphBuilderを使って、出力先、使用するテクスチャなどをRenderGraphに設定します。これらを設定することによって、リソース依存性などを考慮して最適化されたNative Render Passが構築されます。
IRasterRenderGraphBuilder::SetRenderFuncで指定した処理が、Native Render Passから呼び出される描画コマンドを積んでいく処理です。
// step-3 モノクロ化のパスを作成する
using(var builder = renderGraph.AddRasterRenderPass<PassData>("Monochrome Pass", out var passData))
{
// 出力先としてmonochromeTextureHandleを指定する。
builder.SetRenderAttachment(monochromeTextureHandle, 0);
// カメラカラーをテクスチャとして使用申請する
builder.UseTexture(universalResourceData.cameraColor);
passData.BlitMaterial = _material;
passData.SourceTexture = cameraColor;
// ネイティブパス構築時に実際に呼び出されるメソッドを指定する
builder.SetRenderFunc(static (PassData passData, RasterGraphContext context) =>
{
var cmd = context.cmd;
// ソーステクスチャにカメラカラーを指定してBlitを行う
Blitter.BlitTexture(cmd, passData.SourceTexture, Vector2.one, passData.BlitMaterial, 0);
});
}
step-4 セピア化のパスを作成する
続いて、セピア化のパスを追加します。やっていることはモノクロ化とほぼ同じです。 次のコードを入力してください。
// step-4 セピア化のパスを作成する
using(var builder = renderGraph.AddRasterRenderPass<PassData>("Sepia Pass", out var passData))
{
builder.SetRenderAttachment(sepiaTextureHandle, 0);
// カメラカラーをテクスチャとして使用申請する
builder.UseTexture(universalResourceData.cameraColor);
passData.BlitMaterial = _material;
passData.SourceTexture = cameraColor;
builder.SetRenderFunc(static (PassData passData, RasterGraphContext context) =>
{
var cmd = context.cmd;
// ソーステクスチャにカメラカラーを指定してBlitを行う
Blitter.BlitTexture(cmd, passData.SourceTexture, Vector2.one, passData.BlitMaterial, 1);
});
}
step-5 最終合成のパスを作成する
C#側の最後の処理の最終合成パスを作成していきます。
IRasterRenderGraphBuilder::SetInputAttachmentを利用して、monochromeTextureHandleとsepiaTextureHandleを指定している点に注目してください。これはこれらのテクスチャをタイルメモリからロードするための指定です。
// step-5 最終合成のパスを作成する
using (var builder =
renderGraph.AddRasterRenderPass<PassData>("Combine Pass", out var passData))
{
builder.SetRenderAttachment(universalResourceData.cameraColor, 0
