Unity 6では、Render Graphによるレンダリングパイプラインの構築時に、処理内容に応じてRasterRenderPass、ComputePass、UnsafePassを使い分けます。本章では、UnsafePassを使うべき場面を紹介します。
Render Graphには依存関係の解析やSubPassマージといった最適化があります。そのため、通常の描画にはRasterRenderPassを、Compute Shaderを使う処理にはComputePassを優先するのが基本です。
UnsafePassを選ぶべき場面は、主に次の4つです。
最初の3つは、UnsafePassを使用しなければ実現できない、または実装コストが大きい場合です。最後の1つは、必須ではないものの、Passを1つのUnsafePassにまとめることでCPU負荷の低減とRender Graph Viewerの見通しの改善が見込める場合です。
これから、これらの場面を順に解説していきます。
UnsafePassはC#のunsafe機能を意味するものではありません。Render Graphによる一部の自動管理や検証を抑え、CommandBufferに近いAPIを利用できるようにしたPassです。
RasterRenderPassやComputePassで使用するRasterCommandBuffer/ComputeCommandBufferは、CommandBufferのうち各Passの役割に必要なAPIだけを公開したラッパーであり、以下のようなAPIは利用できません。
これらのAPIは、UnsafePassのUnsafeCommandBufferで利用できます。さらに、CommandBufferHelpers.GetNativeCommandBufferでネイティブのCommandBufferを取得すれば、より高度なAPIも利用できるようになります。
エンジンのコールバック、IMGUI、SDKの描画処理など、外部コードがCommandBufferを受け取って任意のコマンドを記録する処理では、記録される内容が実行時まで分かりません。記録時点で処理内容を宣言するRasterRenderPassやComputePassには、このようなブラックボックス処理を組み込めません。UnsafePassでネイティブのCommandBufferを取得し、外部コードへ渡すことで、Render Graphに組み込めます。
Compatibility ModeでExecuteメソッドに記述していたCommandBufferの処理は、コマンド列をほぼそのままUnsafePassのSetRenderFunc内へ移せます。そのため、少ない工数でRender Graphへの移行と動作確認を進めたい場合に有効です。
ただし、これはあくまで素早く移行するための暫定的な手段です。時間を確保できる場合は、処理内容に応じてRasterRenderPassやComputePassへ分類して移植し、UnsafePassは宣言的なAPIで表現できない部分だけに限定することを推奨します。
連続してマージできないPassを1つのUnsafePassにまとめることでCPU負荷の低減とRender Graph Viewerの見通しを改善する効果があります。
これらの処理では、Pass毎に描画ターゲットを切り替えたり、直前Passの出力を周辺サンプリングしたりすることで、SubPassマージが成立しません。
RasterRenderPassを1つ登録するたびに、次の処理が発生します。
RasterRenderPassが増えるほど、CPU側のコストも増加します。マージできないPassチェーンをRasterRenderPassとして登録しても、マージの恩恵を得られないまま、登録や管理のコストが積み重なります。一方、それらを1つのUnsafePassにまとめると、Render Graph上では1つのPassとして登録されます。描画コマンドやレンダーターゲット切り替えのコストは変わりませんが、Render GraphがPass単位で行う登録/管理のコストを削減できます(図2.1)。
図2.1: マージできないPassチェーンにおけるRender Graph上のPass数と描画回数
本プロジェクトで実施したEditor上の測定では、256×256のRender Textureに対する4-tapのPing-pong Blurについて、Blur処理ごとにRasterRenderPassを登録する方式と、1つのUnsafePass内で一連のBlur処理を実行する方式を比較しました。測定条件は、Apple M4 Max(macOS 26.5)、Unity 6000.3.19f1、URP 17.3です。各組み合わせで500フレームを採取し、切り替え後の60フレームを除外した中央値を使用しました。測定値は、Render Graphの記録、ハッシュ計算、コンパイル、実行に関するCPU markerの合計です。
表2.1: Ping-pong BlurにおけるRender Graph関連CPU時間の比較
| Blur回数 | RasterRenderPass×N(µs) | UnsafePass×1(µs) | 差分(µs) |
|---|---|---|---|
| 5 | 286.4 | 280.0 | 6.4 |
| 10 | 334.8 | 313.6 | 21.2 |
| 20 | 427.8 | 389.1 | 38.7 |
| 50 | 675.9 | 592.9 | 83.0 |
| 100 | 1080.7 | 898.5 | 182.2 |
測定値はEditor環境における傾向確認用の値です。Blur回数が増えるほど、RasterRenderPass方式とUnsafePass方式の差が拡大しています。線形近似では、両方式の傾きの差、すなわちRasterRenderPassの追加固定コストは1Passあたり約1.8µsでした。この値はEditorでの測定値であり、実機での絶対値を示すものではありませんが、Blur回数に応じてCPU負荷の差が増えることを確認できます。この効果は、Render Graphの記録、実行、Profilingスコープ、レンダーターゲット設定など、Pass単位の登録/管理コストが削減されることによるものです。描画コマンドに差がないので、UnsafePass化によって描画負荷が減るわけではありません。
RasterRenderPassを多く登録すると、Render Graph Viewerには登録した数だけPassが表示されます。グラフが横方向に長くなるため、機能ごとの区切りが見づらくなり、リソースの依存関係も把握しにくくなります(図2.2)。
図2.2: Blur処理ごとにRasterRenderPassを登録した場合のRender Graph Viewer表示
機能内部のPassチェーンを1つのUnsafePassにまとめると、Viewerでは機能全体を表す1つのPassとして確認できます(図2.3)。これにより、機能ごとの区切りが明確になり、リソースの依存関係も把握しやすくなります。
図2.3: 一連のBlur処理を1つのUnsafePassにまとめた場合のRender Graph Viewer表示
CPU負荷の削減に加えて、プロファイリング時のノイズを減らせる点も大きな利点です。
ただし、Render Graph Viewerの見通しを改善するためだけに、異なる機能の処理やSubPassマージが成立する処理を無理にUnsafePassにまとめることは推奨されません。
RasterRenderPassでは記録時にSetRenderAttachmentでレンダーターゲットを宣言しますが、UnsafePassでは実行時にSetRenderTargetを呼び出して設定します。
UnsafePassを使用してもRender Graphから完全に切り離されるわけではありません。使用するテクスチャは、必ずUseTextureで読み書きの依存関係を宣言する必要があります。宣言が不足すると、リソースのライフタイム管理や同期が正しく行われません。
RasterRenderPassでは、レンダーターゲットの使用はSetRenderAttachmentで宣言されますが、UnsafePassではSetRenderTargetの呼び出しだけでは依存関係が登録されません。レンダーターゲットも必ずUseTextureで読み書きの依存関係を宣言する必要があります。
RenderBufferLoadActionはレンダーターゲットを有効にするときの既存内容の扱い、RenderBufferStoreActionは描画完了後の結果の扱いを指定します。Tile-Based GPUでは、不要なLoadやStoreがタイルメモリとメインメモリの転送を増やすため、適切な設定が重要です。
RasterRenderPassでは、宣言したAccessFlagsをもとにRender GraphがLoad/Store Actionを自動的に決定します。一方、UnsafePassでSetRenderTarget(target)のようにActionを省略すると、既定動作であるLoad/Storeが適用され、不要なメモリ転送によって性能が低下する可能性があります。
そのため、UnsafePassではSetRenderTargetの呼び出し時に、必ずLoad/Store Actionを明示します。
本節に実際のコード例を紹介します。なお、掲載するコードは要点を示すための抜粋であり、そのまま動作する完全なコードではありません。
2枚のテクスチャを交互に読み書きしながらBlurを連続して適用する例です。同じ処理をRasterRenderPassで実装した場合を示し、その後に1つのUnsafePassへまとめた場合を示します。descはBlur用のRender Texture記述子、blurMaterialはBlur用のマテリアルです。
RasterRenderPass方式では、リスト2.1 のように、ループ内でBlur処理ごとにAddRasterRenderPassを呼び出します。各Passの入力と出力は明確に宣言できますが、Blur回数が増えるほどRender Graph上のPass数も増加します。
リスト2.1: RasterRenderPassでPing-pong Blurを実装する
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | class BlurRasterPassData { internal TextureHandle source; internal TextureHandle target; internal Material material; } var ping = renderGraph.CreateTexture(desc); var pong = renderGraph.CreateTexture(desc); var current = resourceData.activeColorTexture; var writePing = true; for (var i = 0; i < blurIterations; i++) { var target = writePing ? ping : pong; using (var builder = renderGraph.AddRasterRenderPass<BlurRasterPassData>( "Ping-pong Blur Raster", out var passData)) { passData.source = current; passData.target = target; passData.material = blurMaterial; builder.UseTexture(passData.source, AccessFlags.Read); builder.SetRenderAttachment(passData.target, 0, AccessFlags.Write); builder.SetRenderFunc((BlurRasterPassData data, RasterGraphContext context) => { Blitter.BlitTexture( context.cmd, data.source, new Vector4(1, 1, 0, 0), data.material, 0); }); } current = target; writePing = !writePing; } |
UnsafePass方式では、リスト2.2 のように、Render Graphへの登録を1回にして、SetRenderFunc内でレンダーターゲットを切り替えます。Blur処理ごとの描画コマンドは残りますが、Pass単位の管理を1回にまとめられます。
リスト2.2: Ping-pong Blurを1つのUnsafePassにまとめる
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | class PingPongBlurPassData { internal TextureHandle source; internal TextureHandle ping; internal TextureHandle pong; internal int iterations; internal Material material; } var ping = renderGraph.CreateTexture(desc); var pong = renderGraph.CreateTexture(desc); var output = blurIterations == 0 ? resourceData.activeColorTexture : blurIterations % 2 == 0 ? ping : pong; using (var builder = renderGraph.AddUnsafePass<PingPongBlurPassData>( "Ping-pong Blur", out var passData)) { passData.source = resourceData.activeColorTexture; passData.ping = ping; passData.pong = pong; passData.iterations = blurIterations; passData.material = blurMaterial; builder.UseTexture(passData.source, AccessFlags.Read); builder.UseTexture(passData.ping, AccessFlags.ReadWrite); builder.UseTexture(passData.pong, AccessFlags.ReadWrite); builder.SetRenderFunc((PingPongBlurPassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); var current = data.source; var next = data.pong; var alternate = data.ping; for (var i = 0; i < data.iterations; i++) { cmd.SetRenderTarget( next, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); Blitter.BlitTexture( cmd, current, new Vector4(1, 1, 0, 0), data.material, 0); current = next; (next, alternate) = (alternate, next); } }); } |
本章では、Render GraphでUnsafePassを使うべき場面と、使用時の注意事項を紹介しました。
UnsafePassを選ぶべき場面は次の4つです。
マージできないPassチェーンを1つのUnsafePassにまとめると、Pass単位の登録/管理コストが削減され、Render Graph Viewerの見通しも改善します。本プロジェクトのEditor上の測定(表2.1)では、RasterRenderPass1つあたり約1.8µsの固定コストを削減できました。ただし、描画コマンド自体は変わらないため、GPU負荷の削減を目的とした手法ではない点に注意が必要です。
一方で、UnsafePassはRender Graphによる自動管理や検証の一部を手放すPassでもあります。使用する際は、UseTextureによる依存関係の宣言と、SetRenderTargetでのLoad/Store Actionの明示を忘れないようにしてください。
基本はRasterRenderPassとComputePassを優先し、UnsafePassは宣言的なAPIで表現できない処理や、マージできないPassチェーンの整理に限定して使うことで、Render Graphの最適化とCommandBufferの自由度を両立できます。