RenderTargetとSV_Targetの数が異なる場合

# MultiRenderTargetについて

MultiRenderTarget（以降MRT）は、レンダリングの出力先に複数のバッファを指定することができるGPUの機能です。主にDeferredShadingなどを実現するために用いられます。

> ![DeferedShading.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280975/b3102353-9f25-d33d-5f4d-fcb501de7638.png)
> https://ja.wikipedia.org/wiki/%E9%81%85%E5%BB%B6%E3%82%B7%E3%82%A7%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0

上記の例では、出力先のバッファとして、`_Albedo`、`_Normal`の2枚をセットしています。

Vulkanでは、RenderPassのAttatchmentsに指定した上で、SubPassのColorAttachmentsに指定することで利用できます。DirectXでは、`ID3D12GraphicsCommandList::OMSetRenderTargets`に複数のRTを詰め込んだDescriptorを指定することで利用できます。

HLSLでは、PixelShader出力に`SV_TARGET{N}`セマンティックスを付けることで指定できます。

```hlsl
void FragmentMain(
    Varings varings,
    out albedo : SV_TARGET0,
    out normal : SV_TARGET1)
{
    float2 uv = varings.uv;
    albedo = varings.color * _AlbedoMap.Sample(sampler, uv);
    normal = varings.normal * _NormalMap.Sample(sampler, uv);;
}
```

# RenderTargetの指定数が矛盾する場合

上記例のDeferredShadingのように、すべてのオブジェクトがそれぞれのバッファへの出力を持っているべき設計の場合は問題ありません。
しかし、特定のShaderで描かれたピクセルのみを記録しておきたい場合など（StencilBufferのような用途）では、それ以外のShaderでは出力を省略できると便利です。
また、逆に特定のPostEffectが不要な場合CPU側でセットするRTの枚数を減らしてShaderは共用したいという需要もあります。

疑問はいくつかあります。

1. そもそも出来るのか、省略した場合どんな挙動になるのか
1. 不要なRTをセットした場合のオーバーヘッド
1. 不要な出力（`SV_TARGET{N}`）をした場合のオーバーヘッド

少し手抜きですが、Unity経由で確認してみます。

# 最小限のRenderPipline

URPやHDRPは、`ScriptableRenderFeature`APIや`CustomPass`APIを使うことで拡張することができます。しかし、パフォーマンスを測る上でノイズになるので、最小限のパイプラインを構築します。タイルベースレンダリングの場合、RTの扱い変わるため、RenderPass/SubPassによる実装としています。

以下の`RenderPipeline`及び、`RenderPipelineAsset`を作成し、Assetインスタンスファイルを`ProjectSettings/Quality/RenderPiplineAsset`にセットします。これがPixelShaderの出力の受け皿となる側ということです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280975/ccbe5494-93fc-5015-7cd6-c16b4f4de4f0.png)


```cs:RenderPipeline
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    foreach (var camera in cameras)
    {
        Render(context, camera);
    }
    context.Submit();
}

void Render(ScriptableRenderContext context, Camera camera)
{
    camera.TryGetCullingParameters(false, out var cullingParameters);
    var cullResults = context.Cull(ref cullingParameters);
    var drawSettings = new DrawingSettings(m_ShaderTag, new SortingSettings(camera));
    var filterSettings = new FilteringSettings(RenderQueueRange.all);
    context.SetupCameraProperties(camera);

    // Alloc
    using (var cmd = new CommandsScope(context))
    {
        cmd.Cmd.GetTemporaryRT(_Depth, camera.pixelWidth, camera.pixelHeight, 24, FilterMode.Bilinear, GraphicsFormat.None);
        cmd.Cmd.GetTemporaryRT(_Color0, camera.pixelWidth, camera.pixelHeight, 0, FilterMode.Bilinear, GraphicsFormat.R8G8B8A8_UNorm);
        cmd.Cmd.GetTemporaryRT(_Color1, camera.pixelWidth, camera.pixelHeight, 0, FilterMode.Bilinear, GraphicsFormat.R8G8B8A8_UNorm);
        cmd.Cmd.GetTemporaryRT(_Color2, camera.pixelWidth, camera.pixelHeight, 0, FilterMode.Bilinear, GraphicsFormat.R8G8B8A8_UNorm);
        cmd.Cmd.Blit(Texture2D.whiteTexture, _Depth);
        cmd.Cmd.Blit(Texture2D.blackTexture, _Color0);
        cmd.Cmd.Blit(Texture2D.blackTexture, _Color1);
        cmd.Cmd.Blit(Texture2D.blackTexture, _Color2);
    }

    // Draw
    using var attachments = (stackalloc[]
    {
        new AttachmentDescriptor(GraphicsFormat.D24_UNorm)
        {
            loadStoreTarget = _Depth,
            loadAction = RenderBufferLoadAction.Clear, storeAction = RenderBufferStoreAction.Store, 
            clearColor = Color.clear, clearDepth = 1, clearStencil = 0,
        },
        new AttachmentDescriptor(GraphicsFormat.R8G8B8A8_UNorm)
        {
            loadStoreTarget = _Color0,
            loadAction = RenderBufferLoadAction.Clear, storeAction = RenderBufferStoreAction.Store,
            clearColor = Color.yellow, clearDepth = 1, clearStencil = 0,
        },
        new AttachmentDescriptor(GraphicsFormat.R8G8B8A8_UNorm)
        {
            loadStoreTarget = _Color1,
            loadAction = RenderBufferLoadAction.Clear, storeAction = RenderBufferStoreAction.Store,
            clearColor = Color.cyan, clearDepth = 1, clearStencil = 0,
        },
        new AttachmentDescriptor(GraphicsFormat.R8G8B8A8_UNorm)
        {
            loadStoreTarget = _Color2,
            loadAction = RenderBufferLoadAction.Clear, storeAction = RenderBufferStoreAction.Store,
            clearColor = Color.magenta, clearDepth = 1, clearStencil = 0,
        },
    }).ToNativeArray(Allocator.Temp);
    using (context.BeginScopedRenderPass(camera.pixelWidth, camera.pixelHeight, 1, attachments, 0))
    {
        Span<int> colorsOnStack = stackalloc int[0];
        switch (m_Asset.TargetsAndOutputsCount)
        {
            case TargetsAndOutputsCountMode.SomeCount:
                colorsOnStack = stackalloc[] { 1, 2, 3};
                Shader.SetKeyword(m_EnableMrtKeyword, true);
                break;
            case TargetsAndOutputsCountMode.TargetsIsFewThanOutputs:
                colorsOnStack = stackalloc[] { 1, };
                Shader.SetKeyword(m_EnableMrtKeyword, true);
                break;
            case TargetsAndOutputsCountMode.OutputsIsFewThanTargets:
                colorsOnStack = stackalloc[] { 1, 2, 3};
                Shader.SetKeyword(m_EnableMrtKeyword, false);
                break;
        }
        using var colors = colorsOnStack.ToNativeArray(Allocator.Temp);
        using (context.BeginScopedSubPass(colors))
        {
            using (var cmd = new CommandsScope(context)) { m_DrawSampler.Begin(cmd.Cmd); }
            
            context.DrawRenderers(cullResults, ref drawSettings, ref filterSettings);
            
            using (var cmd = new CommandsScope(context)) { m_DrawSampler.End(cmd.Cmd); }
        }
    }

    // FinalBlit
    using (var cmd = new CommandsScope(context))
    using (new ProfilingScope(cmd.Cmd, m_FinalBlitSampler))
    {
        if(camera.cameraType is CameraType.Game)
        {
            cmd.Cmd.SetRenderTarget(RenderTargetHandle.CameraTarget.Identifier());
            cmd.Cmd.SetGlobalTexture("_MainTex", _Color0);
            cmd.Cmd.DrawMesh(m_Mesh, Matrix4x4.TRS(new Vector3(-0.5f, +0.5f, 0), Quaternion.identity, Vector3.one * 0.5f), m_Material);
            cmd.Cmd.SetGlobalTexture("_MainTex", _Color1);
            cmd.Cmd.DrawMesh(m_Mesh, Matrix4x4.TRS(new Vector3(+0.5f, +0.5f, 0), Quaternion.identity, Vector3.one * 0.5f), m_Material);
            cmd.Cmd.SetGlobalTexture("_MainTex", _Color2);
            cmd.Cmd.DrawMesh(m_Mesh, Matrix4x4.TRS(new Vector3(-0.5f, -0.5f, 0), Quaternion.identity, Vector3.one * 0.5f), m_Material);
            cmd.Cmd.SetGlobalTexture("_MainTex", _Depth);
            cmd.Cmd.DrawMesh(m_Mesh, Matrix4x4.TRS(new Vector3(+0.5f, -0.5f, 0), Quaternion.identity, Vector3.one * 0.5f), m_Material);
        }
        else
        {
            cmd.Cmd.Blit(_Color0, RenderTargetHandle.CameraTarget.Identifier());
        }
    }

    // Free
    using (var cmd = new CommandsScope(context))
    {
        cmd.Cmd.ReleaseTemporaryRT(_Depth);
        cmd.Cmd.ReleaseTemporaryRT(_Color0);
        cmd.Cmd.ReleaseTemporaryRT(_Color1);
        cmd.Cmd.ReleaseTemporaryRT(_Color2);
        cmd.Cmd.SetRenderTarget(RenderTargetHandle.CameraTarget.Identifier());
    }
}
```

一方のShader側は以下のように、PixelShaderの出力として`SV_Target0`～`SV_Target2`の3つを出力します。わかりやすいように、それぞれ色を変えて出力しています。

```hlsl
class Target
{
    half4 color0 : SV_Target0;
    #if defined(ENABLE_MRT)
        half4 color1 : SV_Target1;
        half4 color2 : SV_Target2;
    #endif
};
void frag(float4 vertex : SV_Position, float2 uv : TEXCOORD0, half4 color : COLOR, out Target target)
{
    half3 tex = _MainTex.Sample(sampler_MainTex, uv).rgb * color.rgb * _Color.rgb;
    target.color0 = half4(tex, color.a * _Color.a);
    #if defined(ENABLE_MRT)
        target.color1 = half4(1 - tex, color.a * _Color.a);
        target.color2 = half4((tex.r + tex.g + tex.b).xxx / 3, color.a * _Color.a);
    #endif
}
```

# そもそも出来るのか、省略した場合どんな挙動になるのか

結論から言えば、どちらも「可能」でした。
ただし、ドキュメントに明示的な記述を見つけることはできなかったので、少しモヤっとする感じです。SRV（テクスチャ）なども余分にバインドされていても無視されるだけですから、当たり前のことなのかなという気もします。

## RTが1枚、`SV_Target`が0,1,2の場合（`RTs < SV_Targets`）

対応するRTが設定されていなかった出力（`SV_Target`）は無視されます。

また、これはおそらくUnity特有の挙動ですが、SubPassで使われなかったAttachmentはRenderPassに指定していてもVulkanAPIレベルではなかったことにされてしまうようです。そのため、colorsにBindしなかったRT1とRT2はLoadActionのClear色すら反映されていません。

// TODO: VulkanAPIを直接叩いた場合の挙動も確認

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280975/145db8d9-1069-d2c3-af9a-f62959e02779.png)

## RTが3枚、`SV_Target`が0だけの場合（`RTs > SV_Targets`）

`SV_Target`が無かったインデックスのRTには何も描画されず、LoadActionでClearしたままの状態になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280975/69a6f46d-8058-3d39-f435-4748066c0042.png)

# オーバーヘッドについて

ZTestを無効にした状態（オーバードロー）でチェッカーテクスチャを貼ったQuadを描画しました。環境はSH-M09（Adreno630）で、解像度が2280ｘ1080なので、1Quad当たり、1000x1000Pixel程度の描画になります。グラフでは左から、`RTs = SV_Targets`、`RTs > SV_Targets`、`RTs < SV_Targets`の順です。

- 60個のQuadを描画した場合（約60Mpx）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280975/e18e416c-558c-0cd6-416c-1c407f39f100.png)

- 1個のQuadを描画した場合（約1Mpx）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280975/cf1e660d-f592-4f98-e329-4b8b20fd96d7.png)

| | `RTs = SV_Targets` | `RTs < SV_Targetss` | `RTs > SV_Targets` |
|:-|-:|-:|-:|
| 60Quad（$Q_{60}$）                           | 19.3   ms | 17.8   ms | 18.7   ms |
| 1Quad（$Q_1$）                               |  3.0   ms |  1.7   ms |  3.0   ms |
| 賞味の1Quad（$Q=\frac{Q_{60}-Q_1}{59}$）      |  0.276 ms |  0.273 ms |  0.266 ms |
| StoreOverhead（$S=Q_1-Q$）                   |  2.724 ms |  1.427 ms |  2.734 ms |
| 1byte当たりのStoreOverhead（$s=\frac{S}{B}$） |  0.170 ms |  0.178 ms |  0.171 ms |
| 1px当たりのRTsのbyte数（$B$）                  |  16 byte |    8 byte |    16 byte |

`RTs < SV_Targetss`がやや軽く、他2つがほぼ同じぐらいとなりました。

ここで、内部的に以下のような処理がされていることが想像できます。

1. PixelShader内で計算をして`SV_Target`のレジスタに書き込むまで（X）
1. `SV_Target`のレジスタからGMEMにコピーする（Y）
1. GMEMからRTにStoreする（Z）

`RTs = SV_Targets`と`RTs < SV_Targetss`に関しては、そもそも同じShaderプログラムが実行されるわけです。`RTs > SV_Targets`も単純な加減乗除と高速なレジスタへの書き込みの有無の差にすぎませんので、Xの処理はほとんど変わらないと予想できます。また、Zの処理に関しては、1px当たりのRTsのbyte数に比例すると予想できます。

問題はYの処理ですが、これが非常に小さいと仮定して$Q$、$s$を計算すると、どちらもほぼ一定で前述の予想と整合性が取れます。
「RTと`SV_Target`の数が異なっていても余分なオーバーヘッド（Yの処理）は無視できるほど小さい」という結論を、この実験を以て断定するまではできないかもしれませんが、実用レベルではほぼ問題としなくて良さそうです。

# 応用する際の注意点

先述に「特定のShaderで描かれたピクセルのみを記録しておきたい場合（StencilBufferのような用途）」を挙げました。このケースについて考えてみます。特定のShaderでは`SV_Target1`を出力し、それ以外のShaderでは出力しないようにします。

まず、計算結果の整合性としては`SV_Target1`のないShaderを実行しても、何も描かれないだけですので問題ないと考えられます。オーバーヘッドについても特定外のShaderでは`SV_Target1`のための計算は含まれていないはずですから、X、Yの処理のオーバーヘッドは無視できます。問題はZの処理（RTのStore）のオーバーヘッドです。特定Shaderを1枚も描画しなかった場合、完全に無駄なRTをStoreする羽目になってしまいます。

一例ではありますが、SH-M09の場合だと、1chのRTで0.18msという無視できるようなできないようなレベルなので、0.1msでも節約したい場合はCPU側で余分なRTをBindするか否の分岐が必要かもしれません。或いは、RenderPassの最後で圧縮したり、他のRTに反映と合成してGMEMに閉じ込めて最終的なStore量を減らしてしまうという戦略もあり得ると思います。

# コード全文

https://github.com/wallstudio/MRTTest/blob/master/Assets/App/MyRenderPipelineAsset.cs