はじめに
皆さんこんにちは、SGEコアテクのチャン ユービンです。
前回の記事では、RenderGraphにおけるFrameBufferFetchの使い方と、そのメリットについて紹介しました。
今回は「Unity6からRenderGraphを使いこなそうー実装応用編」シリーズの締めくくりとして、FrameBufferFetchの仕組みを支えているSubPassとNativeRenderPassについて解説していきます。
なお、本記事の内容はURPのコードと実際の挙動を観察しながら考察したものであり、必ずしも正確であるとは限りません。あらかじめご了承ください。
今までの記事
- Unity6からRenderGraphを使いこなそう ー 基本機能編
- Unity6からRenderGraphを使いこなそう ー 実装応用編 その1
- Unity6からRenderGraphを使いこなそう ー 実装応用編 その2
- Unity6からRenderGraphを使いこなそう ー 実装応用編 その3
- Unity6からRenderGraphを使いこなそう ー 実装応用編 その4← 今はここ
執筆する時点の環境
- Unity6 (6000.0.44f1)
- URP 17.0.4
SubPass/NativeRenderPassとは?
SubPassは、RenderGraphに登録される最小単位の処理ユニットです。
- 基本的には、入力、出力、描画関数の三要素から構成されています。
NativeRenderPassは、1つまたは複数のSubPassをまとめた実行グループです。
- タイルメモリを活用することで、SubPassを効率的に実行する仕組みとなっています。
- 同様に三要素を持っており、これはマージされたSubPassの情報をもとに構成されています。
NativeRenderPassが実行されるまでの流れ
SubPass登録
SubPassは、ScriptableRenderPassのRecordRenderGraph関数から登録します。
- RenderGraph.Add[Type]RenderPassを呼び出すことで、1つのSubPassが登録されます。
- Typeは「Raster」「Compute」「Unsafe」のいずれかです。
ScriptableRenderPassはSubPass登録の仲介役を担い、RenderPassEvent順にUniversalRendererから呼び出されます。そのため、SubPassの登録順序も制御されています。
SubPassは登録順に呼び出され、順番は以下のルールに従います。
- 登録元ScriptableRenderPassのRenderPassEvent順
- 同じRenderPassEvent内では、ScriptableRenderPassのEnqueue順
- 同じScriptableRenderPass内では、SubPassのAdd順
NativeRenderPass構築
登録されたSubPassから必要な情報をまとめ、次のような処理が行われます。
- 最終出力に影響しないSubPassはCullingされます。
- SubPassのマージを試み、NativeRenderPassが作成されます。
- リソースの使用申請状況をもとにライフサイクルを決定します。
- ライフサイクルが1つのNativeRenderPass内に限定されるリソースはMemoryLess化されます。
- Read/Write/MemoryLessなどのフラグに応じて、各リソースのLoad/StoreActionが決定されます。
描画コマンド実行
登録された順に、各SubPassに設定された描画関数(builder.SetRenderFunc)が実行されます。
SubPassマージの仕組み
登録されてるSubPassを1つずつチェックし、次の手順で処理を進めます。
- 次のSubPassを基にNativeRenderPassを1つ生成し、マージ対象としてセットします。
- 次のSubPassをチェックします。
SubPassがマージできる条件
- RasterRenderPassであること(UnsafeRenderPassやComputeRenderPassはマージ対象外です)
- ColorAttachmentについては以下の条件のいずれかを満たすこと。
- ColorAttachmentがセットされていない(SetGlobalParamやSetGlobalKeywordなど、設定コマンドだけを実行するSubPass)
- ColorAttachmentがセットされていて、リソース情報がマージ対象のNativeRenderPassと一致していること
- サイズ(width, height, volumeDepth)
- Samples(MSAA設定)
- MRT一致(出力Attachment数が一致していること)
- DepthAttachmentについては以下の条件のいずれかを満たすこと(SubPassとマージ対象のNativeRenderPass)
- 両方セットされてない
- 片方のみセットされてる
- 両方セットされて、同じリソースである
- FoveatedRendering(VR時に使用)の有無が一致していること
- マージ対象のNativeRenderPassの出力となるRenderTextureを、通常の読み込みではなくFrameBufferFetchで読み込んでいること
- NativeRenderPass側の制限を超えていないこと(詳細は後述)
NativeRenderPassの特徴/制限
1つのNativeRenderPassには最大8個までのSubPassをマージできます。
1つのNativeRenderPassには最大8つまでのAttachmentをセットできます
NativeRenderPass内では、各Attachmentとのやり取りにタイルメモリをフル活用します
- 処理終了後、必要であればリソースをStoreし、不要であればStoreせず破棄します(Don't care)
- 「必要」とは、後続のNativeRenderPassがそのリソースを使用申請している場合を指します(Record時にSetRenderAttachment、SetInputAttachment、UseTextureなど)
- ライフサイクルが1つのNativeRenderPass内に限定されるリソースは、自動的にMemoryLessに設定され、メインメモリ上に確保されません
- 処理終了後、必要であればリソースをStoreし、不要であればStoreせず破棄します(Don't care)
同じNativeRenderPass内で、データ依存のないSubPass同士は並行実行される場合があります
- ただし、実際どのように並行実行されてるかはその時のGPU実行状況による
並行実行のイメージ図
- ただし、実際どのように並行実行されてるかはその時のGPU実行状況による
各NativeRenderPassで使用されるリソースのLoad/StoreActionは、RenderGraphが全体の使用状況をもとに自動で決定します。
描画処理を含まないSubPassのマージについて
描画コマンドを持たないSubPassは、直前のNativeRenderPassに無条件でマージされます。この際、通常SubPassに課せられる最大8つまでの制限は無視され、いくつでもマージが可能です。
ここで言う「描画処理を含まないSubPass」とは、Drawコマンドの実行がなく、Materialやグローバルのパラメータ、キーワードの設定のみを行うSubPassを指します。
マージされたSubPassの番号は、直前のSubPassと同じになります(内部的には同一のSubPassとして扱われるようです)。
ただし、描画を行わないSubPassも、NativeRenderPassにマージされた1つのSubPassとしてカウントされる点に注意が必要です。そのため、描画を行わないSubPassの数が8つを超えると、その後続に描画処理を行うSubPassがマージ可能であったとしても、上限超過のためにマージされず、別のNativeRenderPassとして処理されることになります。
具体例の解説
SubPassマージ成功の例
AfterRenderingTransparentsのタイミングで、カメラターゲットのカラースペースをGammaに変換し、カスタムUIを描画してから再びLinearに戻す処理を行っています。
C#側のコード(Pass追加処理、マテリアル割り当て処理は省略)
// ActiveCameraColor -sRGBに変換-> TempTexture -> CustomUI描画 -Linearに変換-> ActiveCameraColor public class CustomRenderPass : ScriptableRenderPass { private enum Pass { Convert = 0, Revert, } private Material _material; public void Setup(Material material) { _material = material; } public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { var resourceData = frameData.Get<UniversalResourceData>(); var activeColorTexture = resourceData.activeColorTexture; var tempDesc = activeColorTexture.GetDescriptor(renderGraph); tempDesc.name = "_TempTexture"; var tempTexture = renderGraph.CreateTexture(tempDesc); // Blit: LinearToSRGB CustomBlit(renderGraph, resourceData.activeColorTexture, tempTexture, _material, (int)Pass.ConvertUsingFrameBufferFetch, "LinearToSRGB"); // カスタムUI描画 DrawCustomUI(renderGraph, frameData, tempTexture); // Blit: SRGBToLinear CustomBlit(renderGraph, tempTexture, resourceData.activeColorTexture, _material, (int)Pass.RevertUsingFrameBufferFetch, "SRGBToLinear"); } private class CustomPassData { public Material Material; public int PassIndex; public Vector4 ScaleBias; } private void CustomBlit(RenderGraph renderGraph, in TextureHandle source, in TextureHandle destination, Material material, int passIndex, string name = "CustomBlit") { using (var builder = renderGraph.AddRasterRenderPass<CustomPassData>(name, out var passData)) { passData.Material = material; passData.PassIndex = passIndex; passData.ScaleBias = new Vector4(1, 1, 0, 0); builder.SetRenderAttachment(destination, 0, AccessFlags.Write); builder.SetInputAttachment(source, 0, AccessFlags.Read); builder.SetRenderFunc(static (CustomPassData data, RasterGraphContext ctx) => { Blitter.BlitTexture(ctx.cmd, data.ScaleBias, data.Material, data.PassIndex); }); } } private class DrawCustomUIPassData { public RendererListHandle RendererList; } private void DrawCustomUI(RenderGraph renderGraph, ContextContainer frameData, in TextureHandle renderTarget) { var cameraData = frameData.Get<UniversalCameraData>(); var renderingData = frameData.Get<UniversalRenderingData>(); var lightData = frameData.Get<UniversalLightData>(); var resourceData = frameData.Get<UniversalResourceData>(); using (var builder = renderGraph.AddRasterRenderPass<DrawCustomUIPassData>("Draw CustomUI", out var passData)) { var shaderTagIds = new List<ShaderTagId> { new("SRPDefaultUnlit") }; var sortingCriteria = SortingCriteria.CommonTransparent; var drawingSettings = RenderingUtils.CreateDrawingSettings(shaderTagIds, renderingData, cameraData, lightData, sortingCriteria); var layerMask = LayerMask.GetMask("CustomUI"); var filteringSettings = new FilteringSettings(RenderQueueRange.transparent, layerMask); var rendererListParams = new RendererListParams(renderingData.cullResults, drawingSettings, filteringSettings); passData.RendererList = renderGraph.CreateRendererList(rendererListParams); builder.SetRenderAttachment(renderTarget, 0, AccessFlags.Write); // BugTest2 Problem // Depthセットしなければ、FrameBuffer Index問題が発生しない builder.SetRenderAttachmentDepth(resourceData.activeDepthTexture, AccessFlags.Write); builder.UseRendererList(passData.RendererList); builder.SetRenderFunc(static (DrawCustomUIPassData data, RasterGraphContext ctx) => { ctx.cmd.DrawRendererList(data.RendererList); }); } } }
Shaderコード(FrameBufferFetchを使用するBlit操作)
Shader "Hidden/ConvertColorSpace_FBF" { SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline"} ZTest Off ZWrite Off Cull Off LOD 100 Pass // 0 { Name "Linear to sRGB using FrameBufferFetch" HLSLPROGRAM #pragma vertex Vert #pragma fragment Fragment #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" FRAMEBUFFER_INPUT_HALF(0) half4 Fragment(Varyings input) : SV_TARGET { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); half4 color = LOAD_FRAMEBUFFER_INPUT(0, input.positionCS.xy); color.rgb = FastLinearToSRGB(color.rgb); return color; } ENDHLSL } Pass // 1 { Name "sRGB to Linear using FrameBufferFetch" HLSLPROGRAM #pragma vertex Vert #pragma fragment Fragment #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" FRAMEBUFFER_INPUT_HALF(0) half4 Fragment(Varyings input) : SV_TARGET { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); half4 color = LOAD_FRAMEBUFFER_INPUT(0, input.positionCS.xy); color.rgb = FastSRGBToLinear(color.rgb); return color; } ENDHLSL } } }
SubPassのマージ結果は以下のようになります。
上記の条件がすべて満たされるため、CustomRenderPass内で追加された各SubPassを含め、合計7つのSubPassが1つのNativeRenderPassにマージされました。そして、ライフサイクルが1つのNativeRenderPassに限定された_TempTextureはMemoryLessとなり、MainMemory 上に確保されません。
上限超えでマージできなくなる例
成功例のC#コードに以下の部分を追加します。通常の処理の最後に、Blit操作を行うSubPassを10個追加します。
追加コード(変更のない部分は省略)
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { // 前略 // 10回のBlit操作を挿入 LoopBlit(renderGraph, resourceData.activeColorTexture, tempTexture, _material, 5); } private void LoopBlit(RenderGraph renderGraph, TextureHandle src, TextureHandle dest, Material material, int loopCount) { for (var i = 0; i < loopCount; i++) { CustomBlit(renderGraph, src, dest, material, (int)Pass.Convert, $"LinearToSRGB_{i.ToString()}"); CustomBlit(renderGraph, dest, src, _material, (int)Pass.Revert, $"SRGBToLinear_{i.ToString()}"); } }
結果は以下の図のようになります。
8つまでのSubPassしかマージできず、残りのSubPassは別のNativeRenderPassにマージされることになりました。その結果、元々ライフサイクルが1つのNativeRenderPassに限定されていた_TempTexture
が2つのNativeRenderPassにまたがることになり、MemoryLessではなくなり、メインメモリ上に確保されることになります。
また、SubPassの数が上限を超えていなくても、設定されたAttachmentの数が8つを超えると、同様にマージできなくなります。特にMultiRenderTargetを多用する場合は注意が必要です。
上限を超えてもマージできる例
成功例のC#コードに以下を追加します。通常の処理の最後に、パラメータ設定のみを行うSubPassを10個追加します。
追加コード(変更のない部分は省略)
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { // 前略 // 末尾に10回SetParamsのSubを挿入 LoopSetParams(renderGraph, 10); } private void LoopSetParams(RenderGraph renderGraph, int loopCount) { for (int i = 0; i < loopCount; i++) { using (var builder = renderGraph.AddRasterRenderPass<CustomPassData>($"LoopSetParams_{i.ToString()}", out var passData)) { passData.ScaleBias = new Vector4(i, i, i, i); builder.AllowGlobalStateModification(true); builder.SetRenderFunc(static (CustomPassData data, RasterGraphContext ctx) => { // 適当に値をセット ctx.cmd.SetGlobalVector("_BlitScaleBias", data.ScaleBias); }); } } }
結果は以下の図のようになります。
なんと17個ものSubPassがマージされています。前述のように、描画処理を含まないSubPassは上限を無視して直前のNativeRenderPassにマージされます。
そして、これらのSubPassの番号はすべて直前の描画処理を行うSubPassと同じであり、いくつ追加されても同じSubPassとみなされるため、SubPass切り替えによるオーバーヘッドは発生しません。
言い換えると、パラメータを設定する目的でRasterRenderPassを追加しても、基本的に余計なオーバーヘッドは発生しません。
したがって、次の点に注意すれば、コードの可読性やメンテナンス性を向上させるために、パラメータを設定するためのRasterRenderPassを作成することは問題ありません。
描画しないSubPassによって上限に達し、マージできなくなる例
前述のように、描画処理を含まないSubPassは上限を無視してマージされますが、NativeRenderPassにマージされたSubPassの数としてカウントされます。そのため、その後、マージ可能なSubPassが来ても、上限超過のためにマージされなくなることがあります。
これを再現するために、前の例で最後に追加したSubPass群を通常の処理の間に追加します。
追加コード(変更のない部分は省略)
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { // 前略 // Blit: LinearToSRGB CustomBlit(renderGraph, resourceData.activeColorTexture, tempTexture, _material, (int)Pass.Convert, "LinearToSRGB"); // 途中に10回SetParamsのSubを挿入 LoopSetParams(renderGraph, 10); // カスタムUI描画 DrawCustomUI(renderGraph, frameData, tempTexture); // Blit: SRGBToLinear CustomBlit(renderGraph, tempTexture, resourceData.activeColorTexture, _material, (int)Pass.Revert, "SRGBToLinear"); }
以下の図のように、元々マージされるはずだった後続のSubPassが分断されました。
NativeRenderPassが分断されたことは、Pass切り替えによるオーバーヘッドが発生し、不要なメモリ消費量の増加につながる可能性があります。
したがって、パラメータを設定するためのRasterRenderPassを作成すること自体は問題ありませんが、できる限り少ないRasterRenderPassに集約する方が良いでしょう。
そして、意図せずマージされない状況にならないように、RenderGraphViewerで実際のマージ状況を随時確認することを推奨します。
次回予告
『RenderGraphを使いこなそうー 実装応用編』シリーズは今回で終了となりますが、Unityグラフィックス開発に関する情報はまだまだたくさんあります。
次回からは、実際の開発現場で役立つTipsや、遭遇した不具合とその解決・回避方法などについて紹介していきたいと思います。