CORETECH ENGINEER BLOG

株式会社サイバーエージェント SGEコア技術本部 技術ブログ

Unity6からRenderGraphを使いこなそう!応用実装編4「SubPass/NativeRenderPassの仕組み」

はじめに

皆さんこんにちは、SGEコアテクのチャン ユービンです。

前回の記事では、RenderGraphにおけるFrameBufferFetchの使い方と、そのメリットについて紹介しました。

今回は「Unity6からRenderGraphを使いこなそうー実装応用編」シリーズの締めくくりとして、FrameBufferFetchの仕組みを支えているSubPassとNativeRenderPassについて解説していきます。

なお、本記事の内容はURPのコードと実際の挙動を観察しながら考察したものであり、必ずしも正確であるとは限りません。あらかじめご了承ください。

今までの記事

執筆する時点の環境

  • 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構築

NativePassCompiler.cs

登録されたSubPassから必要な情報をまとめ、次のような処理が行われます。

  • 最終出力に影響しないSubPassはCullingされます。
  • SubPassのマージを試み、NativeRenderPassが作成されます。
  • リソースの使用申請状況をもとにライフサイクルを決定します。
  • ライフサイクルが1つのNativeRenderPass内に限定されるリソースはMemoryLess化されます。
  • Read/Write/MemoryLessなどのフラグに応じて、各リソースのLoad/StoreActionが決定されます。

描画コマンド実行

登録された順に、各SubPassに設定された描画関数(builder.SetRenderFunc)が実行されます。

SubPassマージの仕組み

登録されてるSubPassを1つずつチェックし、次の手順で処理を進めます。

  1. 次のSubPassを基にNativeRenderPassを1つ生成し、マージ対象としてセットします。
  2. 次のSubPassをチェックします。
    • マージ可能な場合は、マージ対象のNativeRenderPassに追加し、2の処理を繰り返します。
    • マージ不可の場合は、1の処理に戻ります。
      流れ図

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をマージできます。

    • 9つ以上条件が合うSubPassを並べても、最初の8つだけがマージされ、残りは別のNativeRenderPassに分割されます

      NativePassCompiler.cs
      RenderGraphViewer

    • ただし、描画を実行しないSubPassについては、この上限を超えてマージできる場合があります(詳細は後述)

      PassInfo
      RenderGraphViewer

  • 1つのNativeRenderPassには最大8つまでのAttachmentをセットできます

    • AttachmentはマージされたSubPassからまとめられたもの
      • そのうち、DepthAttachmentは最大1つまで
    • SubPassの数が8以下であっても、Attachment数が制限を超える場合はマージできない
      FixedAttachmentArray.cs
  • NativeRenderPass内では、各Attachmentとのやり取りにタイルメモリをフル活用します

    • 処理終了後、必要であればリソースをStoreし、不要であればStoreせず破棄します(Don't care)
      • 「必要」とは、後続のNativeRenderPassがそのリソースを使用申請している場合を指します(Record時にSetRenderAttachment、SetInputAttachment、UseTextureなど)
    • ライフサイクルが1つのNativeRenderPass内に限定されるリソースは、自動的にMemoryLessに設定され、メインメモリ上に確保されません
  • 同じNativeRenderPass内で、データ依存のないSubPass同士は並行実行される場合があります

    • ただし、実際どのように並行実行されてるかはその時の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や、遭遇した不具合とその解決・回避方法などについて紹介していきたいと思います。