CORETECH ENGINEER BLOG

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

Unity6からRenderGraphを使いこなそう!応用実装編3「FrameBufferFetch」

はじめに

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

前回のRenderGraph記事からだいぶ時間が開いてしまいましたが、今回はいよいよFrameBufferFetchの内容に入ります。

今までの記事

執筆する時点の環境

  • Unity6 (6000.0.38f1)
  • URP 17.0.3
  • テスト端末:iphone14 ( iOS 18.3 )

FrameBufferFetchとは?

FrameBufferFetchは、フレームバッファを直接GPUのオンチップメモリから取得する機能です。これにより、メインメモリ(VRAM)への不要な読み書きを避け、レンダリング速度を向上させることができます。特に、Tile-BasedRendering GPUを搭載しているモバイル端末では、メモリ帯域幅の削減と処理時間の短縮に大きな効果があります。

従来のUnityでもそれができないわけではありませんが、自前で色々なことをする必要があるため実装は面倒でした。Unity6のRenderGraphが提供しているFrameBufferFetchの機能によって、より手早く実装できるようになりました。

連動記事

シェーダー最適化入門 第2回目 「タイルベースレンダリングのGPUとは?」

Tile-BasedRendering GPUの基本仕組みを解説し、従来のUnityおよびUnity6でフレームバッファへアクセスする方法を紹介しています。先に読んで頂くと本記事のことがより理解しやすくなると思いますので、是非併せて参考にしてみてください。

本記事はRenderGraphでFrameBufferFetchの実装方法と効果に注目して解説します。

FrameBufferFetchの実装方法

ここからはサンプルを見つつ実装コードの解説をします。

サンプル & 解説

パス構成

このサンプルでは、FrameBufferFetchの効果を見やすくするために、これを2回実行しています。

フルコード

C#コード

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.Universal;

public class FrameBufferFetchSampleRendererFeature : ScriptableRendererFeature
{
    [Flags]
    private enum UseFrameBufferFetchFlag
    {
        None = 0,
        Pass0 = 1 << 0,
        Pass1 = 1 << 1,
        Pass2 = 1 << 2,
    }

    [SerializeField] private RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing;
    [SerializeField] private Material material;
    [SerializeField] private UseFrameBufferFetchFlag useFrameBufferFetchFlag = UseFrameBufferFetchFlag.None;
    
    private BlogSampleRenderPass _blogSampleRenderPass;
    
    public override void Create()
    {
        _blogSampleRenderPass = new BlogSampleRenderPass();
    }
    
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (renderingData.cameraData.isPreviewCamera || !material)
        {
            return;
        }
        
        // SceneViewCameraはFrameBufferFetch非対応なので使わないように
        var useFBFFlag = renderingData.cameraData.isSceneViewCamera 
                             ? UseFrameBufferFetchFlag.None 
                             : useFrameBufferFetchFlag;
        _blogSampleRenderPass.Setup(material, useFBFFlag);
        _blogSampleRenderPass.renderPassEvent = renderPassEvent;
        renderer.EnqueuePass(_blogSampleRenderPass);
    }
    
    private class BlogSampleRenderPass : ScriptableRenderPass
    {
        private Material _material;
        private UseFrameBufferFetchFlag _useFrameBufferFetchFlag;
        
        public void Setup(Material material, UseFrameBufferFetchFlag useFrameBufferFetchFlag)
        {
            _material = material;
            _useFrameBufferFetchFlag = useFrameBufferFetchFlag;
        }

        private class PassData
        {
            public List<TextureHandle> SourceList;
            public Material Material;
            public int PassIndex;
            public bool UseFBF;
        }
        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            var resourceData = frameData.Get<UniversalResourceData>();
            var cameraColorTarget = resourceData.activeColorTexture;
            
            // CameraColorTargetと同じフォーマットのRenderTexture作成
            var desc = renderGraph.GetTextureDesc(resourceData.activeColorTexture);
            desc.depthBufferBits = 0;
            desc.name = "_CustomBlitTempColor_0";
            var tempColorTarget0 = renderGraph.CreateTexture(in desc);
            desc.name = "_CustomBlitTempColor_1";
            var tempColorTarget1 = renderGraph.CreateTexture(in desc);
            
            // FrameBufferFetchの効果を見やすくするために2回回す
            for (var i = 0; i < 2; i++)
            {
                // Pass0
                AddRenderPass(renderGraph, _material, 0,
                              _useFrameBufferFetchFlag.HasFlag(UseFrameBufferFetchFlag.Pass0),
                              new List<TextureHandle>{cameraColorTarget}, tempColorTarget0,
                              "CustomBlitPass_0");

                // Pass1
                AddRenderPass(renderGraph, _material, 1,
                              _useFrameBufferFetchFlag.HasFlag(UseFrameBufferFetchFlag.Pass1),
                              new List<TextureHandle>{cameraColorTarget}, tempColorTarget1,
                              "CustomBlitPass_1");

                // Pass2
                AddRenderPass(renderGraph, _material, 2,
                              _useFrameBufferFetchFlag.HasFlag(UseFrameBufferFetchFlag.Pass2),
                              new List<TextureHandle>{tempColorTarget0, tempColorTarget1}, cameraColorTarget,
                              "CustomBlitPass_Merge");
            }

            return;

            static void AddRenderPass(RenderGraph renderGraph, Material material, int passIndex, bool useFBF, 
                                      in List<TextureHandle> sourceList, in TextureHandle target, string passName = "CustomBlit")
            {
                using (var builder = renderGraph.AddRasterRenderPass<PassData>(passName, out var passData))
                {
                    passData.SourceList = sourceList;
                    passData.Material = material;
                    passData.PassIndex = passIndex + (useFBF ? 3 : 0);
                    passData.UseFBF = useFBF;
                
                    builder.SetRenderAttachment(target, 0, AccessFlags.Write);
                    if (passData.UseFBF)
                    {
                        for (var i = 0; i < passData.SourceList.Count; i++)
                        {
                            builder.SetInputAttachment(passData.SourceList[i], i, AccessFlags.Read);
                        }
                    }
                    else
                    {
                        foreach (var source in passData.SourceList)
                        {
                            builder.UseTexture(source, AccessFlags.Read);
                        }
                    }
                    builder.SetRenderFunc(static (PassData data, RasterGraphContext ctx) =>
                    {
                        RenderPass(data, ctx);
                    });
                }
            }
            
            static void RenderPass(PassData data, RasterGraphContext ctx)
            {
                var scaleBias = new Vector4(1, 1, 0, 0);
                if (data.UseFBF)
                {
                    Blitter.BlitTexture(ctx.cmd, scaleBias, data.Material, data.PassIndex);
                }
                else
                {
                    for (var i = 1; i < data.SourceList.Count; i++)
                    {
                        data.Material.SetTexture($"_BlitTexture{i}", data.SourceList[i]);
                    }
                    Blitter.BlitTexture(ctx.cmd, data.SourceList[0], scaleBias, data.Material, data.PassIndex);
                }
            }
        }
    }
}

Shaderコード

Shader "FrameBufferFetchSample/CustomBlit"
{
    SubShader
    {
        ZTest Always
        ZWrite Off
        Cull Off
        
        HLSLINCLUDE
        #pragma vertex Vert
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

        // 画面左の2/3を赤とブランドする
        half3 ProcessColor_Left(half3 color, float2 uv)
        {
            if (uv.x < 0.67)
            {
                color = lerp(half3(1, 0, 0), color, 0.8);
            }
            return color;
        }

        // 画面右の2/3を青とブランドする
        half3 ProcessColor_Right(half3 color, float2 uv)
        {
            if (uv.x > 0.33)
            {
                color = lerp(half3(0, 0, 1), color, 0.8);
            }
            return color;
        }

        // 画面を合成する(上半分を左カラー、下半分を右カラー)
        half3 MergeLeftRightColor(half3 colorL, half3 colorR, float2 uv)
        {
            return uv.y < 0.5 ? colorL : colorR;
        }
        ENDHLSL
        
        Pass    // 0
        {
            Name "CustomBlit_0"
            HLSLPROGRAM
            #pragma fragment frag

            half4 frag (Varyings input) : SV_Target
            {
                half4 color = FragNearest(input);
                color.rgb = ProcessColor_Left(color.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 1
        {
            Name "CustomBlit_1"
            HLSLPROGRAM
            #pragma fragment frag
            
            half4 frag (Varyings input) : SV_Target
            {
                half4 color = FragNearest(input);
                color.rgb = ProcessColor_Right(color.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 2
        {
            Name "CustomBlit_Merge"
            HLSLPROGRAM
            #pragma fragment frag
            
            TEXTURE2D_X(_BlitTexture1);
            
            half4 frag (Varyings input) : SV_Target
            {
                half4 color0 = FragNearest(input);
                half4 color1 = SAMPLE_TEXTURE2D_X(_BlitTexture1, sampler_PointClamp, input.texcoord);
                half4 color = half4(0, 0, 0, 1);
                color.rgb = MergeLeftRightColor(color0.rgb, color1.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 3
        {
            Name "CustomBlit_0_FrameBufferFetch"
            HLSLPROGRAM
            #pragma fragment frag
            
            FRAMEBUFFER_INPUT_HALF(0);

            half4 frag (Varyings input) : SV_Target
            {
                // *3 FramBufferから直接ロードする
                half4 color = LOAD_FRAMEBUFFER_INPUT(0, input.positionCS.xy);
                color.rgb = ProcessColor_Left(color.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 4
        {
            Name "CustomBlit_1_FrameBufferFetch"
            HLSLPROGRAM
            #pragma fragment frag

            FRAMEBUFFER_INPUT_HALF(0);
            
            half4 frag (Varyings input) : SV_Target
            {
                half4 color = LOAD_FRAMEBUFFER_INPUT(0, input.positionCS.xy);
                color.rgb = ProcessColor_Right(color.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 5
        {
            Name "CustomBlit_Merge_FrameBufferFetch"
            HLSLPROGRAM
            #pragma fragment frag
            
            FRAMEBUFFER_INPUT_HALF(0);
            FRAMEBUFFER_INPUT_HALF(1);
            
            half4 frag (Varyings input) : SV_Target
            {
                half4 color0 = LOAD_FRAMEBUFFER_INPUT(0, input.positionCS.xy);
                half4 color1 = LOAD_FRAMEBUFFER_INPUT(1, input.positionCS.xy);
                half4 color = half4(0, 0, 0, 1);
                color.rgb = MergeLeftRightColor(color0.rgb, color1.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 6
        {
            Name "CustomBlit_0_FrameBufferFetch_MS"
            HLSLPROGRAM
            #pragma fragment frag
            #pragma target 4.5
            #pragma require msaatex
            
            FRAMEBUFFER_INPUT_HALF_MS(0);

            half4 frag (Varyings input, uint sampleID : SV_SampleIndex) : SV_Target
            {
                half4 color = LOAD_FRAMEBUFFER_INPUT_MS(0, sampleID, input.positionCS.xy);
                color.rgb = ProcessColor_Left(color.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 7
        {
            Name "CustomBlit_1_FrameBufferFetch_MS"
            HLSLPROGRAM
            #pragma fragment frag
            #pragma target 4.5
            #pragma require msaatex

            FRAMEBUFFER_INPUT_HALF_MS(0);
            
            half4 frag (Varyings input, uint sampleID : SV_SampleIndex) : SV_Target
            {
                half4 color = LOAD_FRAMEBUFFER_INPUT_MS(0, sampleID, input.positionCS.xy);
                color.rgb = ProcessColor_Right(color.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }

        Pass    // 8
        {
            Name "CustomBlit_Merge_FrameBufferFetch_MS"
            HLSLPROGRAM
            #pragma fragment frag
            #pragma target 4.5
            #pragma require msaatex
            
            FRAMEBUFFER_INPUT_HALF_MS(0);
            FRAMEBUFFER_INPUT_HALF_MS(1);
            
            half4 frag (Varyings input, uint sampleID : SV_SampleIndex) : SV_Target
            {
                half4 color0 = LOAD_FRAMEBUFFER_INPUT_MS(0, sampleID, input.positionCS.xy);
                half4 color1 = LOAD_FRAMEBUFFER_INPUT_MS(1, sampleID, input.positionCS.xy);
                half4 color = half4(0, 0, 0, 1);
                color.rgb = MergeLeftRightColor(color0.rgb, color1.rgb, input.texcoord);
                return color;
            }
            ENDHLSL
        }
    }
}

C#側に必要な実装

// FrameBufferとして入力するTextureの使用申請
builder.SetInputAttachment(inputTex, index, accessFlags);

通常のRenderTexture使用と同様に、builderを介して事前に使用申請をする必要があります。ただし、builder.UseTexture の代わりに、builder.SetInputAttachment を使います。

  • inputTex : FrameBuffeリストに入れるTexture
  • index : FrameBufferリストに入れるIndex、0 ~ 7に指定可能
  • accessFlags : 他の使用申請と同様にRead/Write指定可能

注意事項:

  • FrameBufferリストは描画前に詰められるため、0番が空いていると、1番にセットされているTextureが実際には0番になる
  • SetInputAttachmentできるのは該当PassのRender Target(SetRenderAttachmentの対象)と解像度が同じのTextureに限る
  • 該当PassですでにSetRenderAttachment、UseTextureで使用申請されたTextureはSetInputAttachmentできない
  • 該当PassですでにセットされたIndexに異なるTextureをセットしようとすることはできない(上書きではなく、エラーが出る)

Shaderコードの解説

// FRAMEBUFFERの変数宣言マクロ
FRAMEBUFFER_INPUT_HALF(index)
// MSAA処理が必要な場合は_MS版を使う
FRAMEBUFFER_INPUT_HALF_MS(index)

// FRAMEBUFFERからロードするマクロ
LOAD_FRAMEBUFFER_INPUT(index, pixelPos)
// MSAA処理が必要な場合は_MS版を使う
LOAD_FRAMEBUFFER_INPUT_MS(index, sampleIdx, pixelPos)
  • 変数宣言マクロはデータタイプ別で、以下のバリエーションがある
    • FRAMEBUFFER_INPUT_(HALF | FLOAT | INT | UINT)
    • MSAA処理が必要な場合は最後に_MSをつける
  • ロード時に使うマクロ
    • index:読み込もうとするTextureのindex、C#側でセットする時に使ったindex
    • pixelPos:uv座標ではなく、フラグメントのpostionCS.xyを直接使うべき
    • MS版にはsampleIdxを渡す必要がある

FrameBufferFetch使用前後の比較

UnityのRenderGraphViewerXCodeMetal debuggerで比較します。

その2つのツールがまだ知らない方には以下の記事がおすすめです。

解説に入る前に少し補足:

  • パスの実行タイミングがAfterRenderingPostProcessingに設定されています
  • Passのマージ、MemoryLess化などまだ触れたことがない言葉が出てきますが、次回の記事で解説する予定ですので、ここでは処理効率が良くなる、メモリが節約されるという意味で捉えて頂ければと思います

RenderGraphViewer

前画像
CustomBlit_0CustomBlit_1がマージされ、CustomBlit_Mergeとマージされていません。処理中に使われた一時TextureのTempColor_0とTempColor_1がStoreされます。

後画像
2回のCustomBlit_0、CustomBlit_1、CustomBlit_Mergeが全部直前のBlitPostProcessingパスにマージされています。Textureを読み込むブロックにFの記号が付いています。TempColor_0とTempColor_1がStoreされず、MemoryLess化されています。

Metal debugger

前画像

後画像

重要な変化を抜粋します。

  • GPU Time:6.02ms → 5.66ms
  • Textureのメモリ使用量:117.03MiB → 92.78MiB
  • メモリ帯域幅使用(処理中一番多いところ):
    • Read:23.26 GiB/s → 2.35 GiB/s
    • Write:11.52 GiB/s → 2.17 GiB/s

まとめ

Texture読み込みする際にFrameBufferFetchを使用することでより多くのPassがマージ可能になり、一時TextureがMemoryLess化されることで得られるメリットは以下になります。

  • GPU処理時間が減る
  • Textureのメモリ使用量削減
  • メモリ帯域幅の使用が大幅に節約される

特にデメリットはありませんが、以下のケースでは使用できない制限があります。

  • 読み込もうとするTextureがPassのRenderTargetと解像度が異なる
  • 読み込もうとするPixel位置が現在のFragmentと異なる

最後に

今回はFrameBufferFetchの基本概念、使い方、メリットを解説しました。その中でも特にメモリ帯域幅の節約効果が嬉しいので、皆さんも是非FrameBufferFetchを使ってみてください。

次回予告

次回はその裏の仕組みについて解説していきたいと思います。主にRenderGraphのNativeRenderPass/SubPass、MemoryLessなどの内容になります。