CORETECH ENGINEER BLOG

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

Unity6からRenderGraphを使いこなそう!応用実装編1「RenderPassの構築」

はじめに

みなさんこんにちは、CyberAgent SGEコアテク所属のチャンユービンです。

前回の記事:Unity6からRenderGraphを使いこなそう ー 基本機能編

前回はRenderGraphの基本概念と便利機能についてを紹介しました。今回からは具体的にRenderGraphをどう扱うかを解説する応用編に入りたいと思います。

今回はRenderGraphと旧システムの実装方法の変更点からはじめ、Blit操作の実装コードを見ながら、RenderGraphの実装方法について紹介していきたいと思います。

執筆する時点の環境

  • Unity 6 (6000.0.9f1)
  • URP 17.0.3

RenderGraphと従来のシステムの実装変更点

従来のシステムから一番変更が大きいこと

従来のシステムでは

  • ConfigureOnCameraSetup関数で描画パラメータを設定する
  • Execute関数でCommandBufferを通して描画を実行する

などなど用途別の関数がありましたが、

RenderGraphでは

  • RecordRenderGraph関数に描画設定、描画実行など一連の操作が全部集約される

実装するのはRecordRenderGraph のみになり、分かりやすくなっています。

従来のシステムからほぼ変更がないこと

以下の2点はほぼ変更がないため、RenderGraphでもそのまま使うことができます。

ScriptableRenderPassを描画隊列に追加する方法は変わっていない

簡単に言いますと、以下の2ステップになります

  1. ScriptableRenderPassを継承し、MyPassクラスを作る
  2. ScriptableRendererActiveRenderPassQueueMyPassを追加する

Step 2では、ScriptableRenderFeatureを介して追加してもいいし、MonoBehaviorでCameraのRendererにアクセスして追加してもOKです。

描画用Shaderはほぼ変わっていない

RenderGraphの一部機能*1を除き、特殊なShader書き方を求めないので、基本的に今まで使ってきたShaderをそのまま使うことができます。

その特殊機能ついては今後の記事で解説する予定です。

(*1 MemoryLessのRenderTexture:前Passで描画した結果をメモリに書き出さず、GPUのTileMemory上に留めて次のPassに使わせる機能)

Blit操作の実装例

色を反転して出力する簡単なShader

    Shader "Hidden/Sample/Negative"
    {
       SubShader
       {
           Tags { "RenderType"="Opaque" "RenderPipeline" = "UniversalPipeline"}
           ZTest Off ZWrite Off Cull Off
           Pass
           {
               Name "Negative"
    
               HLSLPROGRAM
               #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
               #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
    
               #pragma vertex Vert
               #pragma fragment Frag
    
               half4 Frag(Varyings input) : SV_Target0
               {
                   float2 uv = input.texcoord.xy;
                   half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
                   half4 negative = half4(1 - color.rgb, color.a);
                   return negative;
               }
               ENDHLSL
           }
       }
    }

NegativeRenderPassおよびRendererFeature

    using UnityEngine;
    using UnityEngine.Rendering;
    using UnityEngine.Rendering.RenderGraphModule;
    using UnityEngine.Rendering.RenderGraphModule.Util;
    using UnityEngine.Rendering.Universal;
    
    public class NegativeRenderPass : ScriptableRenderPass
    {
        private const string ShaderPath = "Hidden/Sample/Negative";
        private Material _material;
        private Material Material
        {
            get
            {
                if (_material == null)
                {
                    _material = CoreUtils.CreateEngineMaterial(ShaderPath);
                }
                return _material;
            }
        }
    
        public void Cleanup()
        {
            // ランタイム時生成したMaterialは手動で破棄する必要がある
            // これを忘れるとメモリリークが発生する
            CoreUtils.Destroy(_material);
        }
    
        private class PassData
        {
            public Material Material;
            public TextureHandle SourceTexture;
        }
    
        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            // 解説 *1
            // frameDataからURP内蔵のリソースデータを取得
            var resourceData = frameData.Get<UniversalResourceData>();
    
            // 解説 *2
            // 入力とするテクスチャをresourceDataから取得
            var sourceTextureHandle = resourceData.activeColorTexture;  // activeColorTextureはカメラが描画したメインのカラーバッファ
    
            // 解説 *3
            // 出力用のテクスチャのDescriptorを作成
            var negativeDescriptor = renderGraph.GetTextureDesc(sourceTextureHandle);    // 入力テクスチャのDescriptorをコピー
            negativeDescriptor.name = "NegativeTexture";                                 // テクスチャの名前を設定
            negativeDescriptor.clearBuffer = false;                                      // クリア不要
            negativeDescriptor.msaaSamples = MSAASamples.None;                           // MSAA不要
            negativeDescriptor.depthBufferBits = 0;                                      // 深度バッファ不要
    
            // 解説 *4
            // Descriptorを用いて色反転テクスチャを作成
            var negativeTextureHandle = renderGraph.CreateTexture(negativeDescriptor);
    
            // 解説 *5
            // カメラカラーを反転し、出力用のテクスチャに描画するRasterRenderPassを作成し、RenderGraphに追加
            using (var builder = renderGraph.AddRasterRenderPass<PassData>("NegativeRenderPass", out var passData))
            {
                // passDataに必要なデータを入れる
                passData.Material = Material;
                passData.SourceTexture = sourceTextureHandle;
    
                // 解説 *6
                // builderを通してRenderGraphPassに対して各種設定を行う
                // なお、描画ターゲットや他使用されるテクスチャは必ずこの段階で設定する必要がある
                builder.SetRenderAttachment(negativeTextureHandle, 0, AccessFlags.Write);    // 描画ターゲットに出力用のテクスチャを設定
                builder.UseTexture(sourceTextureHandle, AccessFlags.Read);                      // 入力テクスチャの使用を宣言する
    
                // 解説 *7
                // 実際の描画関数を設定する(static関数が推奨されてる)
                builder.SetRenderFunc(static (PassData data, RasterGraphContext context) =>
                {
                    // 解説 *8
                    // contextからCommandBufferを取得
                    var cmd = context.cmd;
                    var material = data.Material;
                    var source = data.SourceTexture;
                    // Blitterの便利関数を使ってBlit実行
                    Blitter.BlitTexture(cmd, source, Vector2.one, material, 0);
                });
            }
    
            // 解説 *9
            // 色反転テクスチャをカメラカラーにBlitする
            // 単純なBlitなら、RenderGraphは便利な関数を提供しているので、それを使う
            renderGraph.AddBlitPass(negativeTextureHandle, sourceTextureHandle, Vector2.one, Vector2.zero, passName: "BlitNegativeTextureToCameraColor");
        }
    }
    
    public class NegativeRendererFeature : ScriptableRendererFeature
    {
        private NegativeRenderPass _pass;
    
        public override void Create()
        {
            _pass = new NegativeRenderPass
            {
                renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing
            };
        }
    
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            renderer.EnqueuePass(_pass);
        }
    
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                // ここでPassの破棄処理を呼び出す
                _pass.Cleanup();
            }
        }
    }

上記の NegativeRenderPass.RecordRenderGraphの中身が

カメラターゲットテクスチャ → 色反転テクスチャ → カメラターゲットテクスチャ

この2回のBlit操作をRenderGraphで実装するコードになります。

色反転

実装コード解説

上記NegativeRenderPassをご覧になっていただいて、

ここからは実装コードの詳細について解説していきたいと思います。

解説 *1

RecordRenderGraph関数の引数として、RenderGraphのインスタンスの他に、ContextContainer型のframeDataが入ってきます。

ContextContainerは型名のように描画に必要なデータを格納するためのコンテナーになります。汎用的な設計になっており、ContextItemクラスを継承しとけば、どんなデータも入れることができます。

そして、URPは描画に使う各種データのクラスを用意しており、その中に入れています。

取得するにはContextContainer.Get<Type>()

最も使われるのは以下の4つであり、描画に必要なデータはほとんどその中から取得することができます。

// カメラ関連の情報:変換行列、カメラの設定など
var cameraData = frameData.Get<UniversalCameraData>();
// リソース関連の情報:カメラカラーバッファ、カメラ深度バッファなど
var resourceData = frameData.Get<UniversalResourceData>();
// レンダリング関連の情報:RenderCullingResults、RenderLayerMaskなど
var renderingData = frameData.Get<UniversalRenderingData>();
// ライト関連の情報:MainLight、AdditionalLightsの情報など
var lightData = frameData.Get<UniversalLightData>();

解説 *2

UniversalResourceDataの中からURPが用意してくれる描画に必要なテクスチャリソースを取得できます。

そして、取得するテクスチャはみんなTextureHandle タイプになります。

RenderGraphが扱うRenderTextureデータタイプは従来のRTHandleではなく、新しいTextureHandleになります。

TextureHandle は以下の特徴があります。

  • クラス内部に実際のリソースを直接保持してなく、リソースIDだけを保持している
  • リソース確保解放などはRenderGraphが全部管理してくれてるため、扱いがとても楽(RenderGraphに管理させない方法もある。補足*2
    • 必要なリソース(アクセス方式なども)は描画段階の前に必ず申請する必要がある
    • 申請されたリソースは描画段階に入ってから初めて確保する(描画段階の前にリソースへのアクセスができない)
    • 申請されたリソースは描画処理に使われてる分だけ確保する(無駄な確保が防げる)
    • 手動でリソース解放する必要がない

解説 *3

TextureHandle を作るには従来のように、まずテクスチャの属性を指定するDescriptorを用意する必要があります。

しかし、TextureHandle を作るためのDescriptorタイプは従来のRenderTextureDescriptorではなく、新しいTextureDescになります。

とはいえ、テクスチャの属性を指定する役割は同じなので、従来のRenderTextureDescriptorTextureHandle を作る方法もURPが用意してくれました。

また、RenderGraphが管理しているテクスチャリソースであれば、以下の関数でTextureDesc を取得することができます。

// テクスチャの属性を確認したい時に
var desc = renderGraph.GetTextureDesc(textureHandle);

解説 *4

RenderGraphに新規テクスチャリソースを申請するには、以下の2つの方法があります。

(前述のように、申請した時点では、リソースはまだ確保されません)

// 新しいTextureDescを使う場合
var textureHandle1 = renderGraph.CreateTexture(textureDesc);
// 従来のRenderTextureDescriptorを使う場合
var textureHandle2 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, renderTextureDescriptor,
                                                textureName, clearFlag, filterMode, textureWrapMode);

従来のRenderTextureDescriptor で申請する場合は、textureName, clearFlag, filterMode, textureWrapModeを足す必要がありまして、コードが長くなっちゃいます。

内部にもRenderTextureDescriptorTextureDesc に変換してrenderGraph.CreateTexture()を実行しているだけです。

直接新しいTextureDesc を使った方が便利だと思います。

従来のRTHandleをRenderGraphにインポートして使うこともできます。

// RTHandleシステムですでに管理されているリソースは、RenderGraphにインポートする
var textureHandle = renderGraph.ImportTexture(rtHandle);

RenderGraphはRTHandle を直接扱うことができないため、インポートしてtextureHandleに変換してから使う形になります。

補足*2 :

RenderGraphにインポートしたRTHandleはRenderGraphに直接管理されません。描画終了後に自分でRTHandleを解放する必要があります。

なぜRenderGraphに管理させず、わざわざ従来のRTHandleシステムのリソースをインポートするのでしょうか?

RenderGraphに管理されるリソースは、RenderGraphがそれが使われなくなったと判断した時点に解放されます。また、1フレーム描画終了時に、全てのリソースが解放されます。

そのため、RenderGraphに管理されるリソースはフレームを渡って保持することができないんです。

TAA、モーションブラなど、テンポラルの処理が必要な際に、そのリソースを従来のRTHandleシステムに管理させる必要があります。

解説 *5

RenderGraphに描画させるには、RenderGraphPass を追加する必要があります。

RenderGraphでの描画実行単位は従来のScriptableRenderPassから、RenderGraphPassに変わりました。

従来のScriptableRenderPass は現在、RenderGraphPass を作ってRenderGraphに追加するための仲介者的な存在になっています。

そして、RenderGraphPass を追加する関数を呼び出す際に、<クラス型>を指定する必要があります。

class PassData  // クラス名は任意OK
{
    // 描画実行時に必要なデータ
    // 描画に使うMaterial
    // MaterialにセットするTextureHandle
    // など...
}

// 前略
using (var builder = renderGraph.AddRasterRenderPass<PassData>(PassName, out var passData))
{ ... }

RenderGraphには用途別で3種類のPassが用意され、それぞれのAdd関数も用意されています。

// **RasterRenderPass**:通常描画、Materialにパラメタ設定するなどに使う
renderGraph.AddRasterRenderPass();
// **ComputeRenderPass**:Compute Shaderを実行するに使う
renderGraph.AddComputePass();
// **UnsafeRenderPass**:従来システムのようにCommand Bufferを自由に扱いたい時に使う
renderGraph.AddUnsafePass();

この例ではBlit操作を行うため、RasterRenderPassを使っています。

ComputeRenderPassとUnsafeRenderPassの扱い方ついてはまた今後の記事で紹介したいと思います。

解説 *6

RenderGraphPassはパスを追加する際に取得したbuilderを通して各種設定を行います。

各種設定の中、描画ターゲットの指定とテクスチャの使用申請が最も重要で、必要不可欠な設定項目になります。 (その他の設定項目については、また今後の記事で解説する予定です)

描画ターゲットの指定について:

// 描画のColorTarget設定
// 引数: 1.ターゲットテクスチャ 2.出力スロット 3.アクセス権限
// 出力スロット: シングルターゲットの場合は0に固定、マルチターゲットの場合は0から順に番号を振る
builder.SetRenderAttachment(colorTextureHandle, 0, AccessFlags.Write);
// 描画のDepthTarget設定
// 引数: 1.ターゲットテクスチャ 2.アクセス権限
builder.SetRenderAttachmentDepth(depthTextureHandle, AccessFlags.Write);  

ここに注意するべきこと:

  • SetRenderAttachmentSetRenderAttachmentDepthには、必ずタイプに合うフォーマットのテクスチャを指定する必要がある
    • SetRenderAttachmentDepthフォーマットの、SetRenderAttachmentDepthColorフォーマットのテクスチャを指定するとエラーが出る

使用テクスチャの使用申請について:

描画ターゲットの他に使われるテクスチャがあれば、それらの使用申請をする必要があります。

// テクスチャの使用申請
// 引数: 1.使用テクスチャ 2.アクセス権限
builder.UseTexture(textureHandle, AccessFlags.Read);

ここに注意するべきこと:

  • RenderAttachmentやRenderAttachmentDepthに設定された対象にUseTextureする必要はない
    • やろうとすると、むしろ重複申請でエラーが出る

アクセス権限について:

描画ターゲットの指定にも、使用テクスチャの使用申請にも、それぞれアクセス権限を設定する必要があります。

アクセス権限は以下のEnumから選びます

public enum AccessFlags
{
    ///<summary>The pass does not access the resource at all. Calling Use* functions with none has no effect.</summary>
    None = 0,

    ///<summary>This pass will read data the resource. Data in the resource should never be written unless one of the write flags is also present. Writing to a read-only resource may lead to undefined results, significant performance penaties, and GPU crashes.</summary>
    Read = 1 << 0,

    ///<summary>This pass will at least write some data to the resource. Data in the resource should never be read unless one of the read flags is also present. Reading from a write-only resource may lead to undefined results, significant performance penaties, and GPU crashes.</summary>
    Write = 1 << 1,

    ///<summary>Previous data in the resource is not preserved. The resource will contain undefined data at the beginning of the pass.</summary>
    Discard = 1 << 2,

    ///<summary>All data in the resource will be written by this pass. Data in the resource should never be read.</summary>
    WriteAll = Write | Discard,

    ///<summary> Shortcut for Read | Write</summary>
    ReadWrite = Read | Write
}

一般的な描画処理に対して、

  • 描画ターゲットはWrite
    • 読み込む必要もある場合はReadWrite
  • 他に使用されるテクスチャはRead

ここに注意するべきこと:

  • 間違った権限を振っても、RenderGraph側からエラーを吐かないが、正しく設定することを強くお勧めする
    • RenderGraphはShader側に実際行われる行為を事前検知できないため、事前にエラーを吐くことができない
    • エラーは出ないが、期待する描画結果になれない可能性が高い

解説 *7

ここからはようやく描画を実行する関数になります。

// 実際の描画関数を設定する(static関数が推奨されてる)
builder.SetRenderFunc(static (PassData data, RasterGraphContext context) =>
{
    // 描画実行コード
});

builder.SetRenderFunc に渡す関数には以下の引数を持つ必要があります

  • PassData
    • RenderGraphPassを追加する際に指定したタイプと一致
    • 関数外部からデータを転送するため
  • RenderGraphContext
    • 該当Passで使うCommandBufferを持っている
    • 3種類のPassに対して、3種類のContextがある
      • RasterGraphContext
      • ComputeGraphContext
      • UnsafeGraphContext

解説 *8

3種類のContextはそれぞれのCommandBufferを持っている

  • RasterGraphContext → RasterCommandBuffer
  • ComputeGraphContext → ComputeCommandBuffer
  • UnsafeGraphContext → UnsafeCommandBuffer

3種類のCommandBufferは従来のCommandBufferをラッピングしたものであり、 それぞれの用途に対して、必要なコマンドだけを提供します。

解説 *9

単純なBlit操作なら、RenderGraphは直接Blitを実行するPassを追加する便利関数を用意してくれてます。

  • renderGraph.AddBlitPass()

また、テクスチャコピー処理にもコピーを実行するPassを追加する便利関数を用意してくれてます。

  • renderGraph.AddCopyPass

ただし、この記事を執筆した時点では、以下の不具合っぽい現象があるため、要注意!

  • カメラのMSAAが有効になっている場合、Windowsプラットフォームで、renderGraph.AddBlitPassもrenderGraph.AddCopyPassも正しく実行されない可能性がある

最後に

今回はBlit操作の例から入り、RenderGraph実装に必要なことを解説しました。

RenderGraphの対応をし始める頃は分からないことが多く、結構手間取ってしまったこともありました。しかし、RenderGraphに慣れたら、従来のシステムよりよっぽど便利だと感じると思います。

皆さんも是非RenderGraphで実装してみてください。

次回はRenderGraphPass間でデータを伝達するのにとても便利なContextContainerの使用方法を解説していきたいと思います。

参考