ノートの端の書き残し

UnityやらC#やら。設計が得意かもしれない。

【URP】RenderGraph入門 概念編

前置き

現時点での私の持っている知識は、URP12くらいまでの、

  1. ScriptableRendererFeatureからScriptableRendererPassを生成して
  2. Passの中でCommandBufferに色んな命令を入れて実行

という実装知識です。
レンダリングパイプラインのカスタムによる負荷軽減(縮小バッファ、などと呼ばれるもの)や簡単なポストエフェクトなどは実装経験がありますが、RenderGraphについては全く知りませんでした。 Unity6000.0.0bを数字のデカさに爆笑しながらインストールしていつも通りURPで開いてみたら何やら見慣れないコードが見えたため、一転泣きながら入門しようという状態です。

そんな姿勢で書いていくので、従来のURPのカスタマイズの知識はあると良いです。
RenderGraphは既存の知識に追加で知るべきものなので、従来のURPの勉強は全く無駄にならないと思います)

RenderGraphとは

Unity特有の概念ではありません。以下の記事はUEの公式マニュアルですが、URPのRenderGraphと同じようなことを言っています。

docs.unrealengine.com

従来のレンダリングパイプラインのカスタマイズにおいては、各カスタムパスで自由に処理を記述することができました。
自由さそれ自体に問題は無いのですが、複数のパスで計算途中にバッファとして使用するテクスチャが存在しているなどの場合、バッファを使いまわすためにはプログラマが気を付けて設計する他ありませんでした。

描画とは無関係に、私のフレームワークやライブラリの実装経験から至った考えなのですが、パフォーマンスの観点において、「中央集権的な管理コアモジュールを用意して、利用者は知らないうちにそれを必ず利用している。そして管理コアモジュールが全体を見て最適化を行う」というのは非常に実装しやすい割に効果的です。簡単な例を挙げると、オブジェクトプールなどは中央集権的な管理コアモジュールが生成破棄のコストを削減していますね。
抽象的に見るとRenderGraphの最適化はそれと同じと思ってよいでしょう。

RenderGraphは何がしたいのか

先ほどのオブジェクトプールの例のように、「パイプライン全体で使用されるリソースを管理して、良い感じに使いまわしたり、無駄を無くしたい!」というのが目的です。RenderGraphの中身を読む前に、一般的にこのような要件を求められたらどう実装したいかを考えてみましょう。

  1. 描画処理の前に、このフレームでどんなリソースを使うのかを知りたい
  2. カスタムパスで使用する型には制限をかけたくない
  3. カスタムパスの処理内容には制限をかけたくない

この辺が要件定義時に真っ先に出てくることと思います。(どんなフレームワークを作っていても割と似たような要件が出てくると思う)

そして、これらを満たすための最低条件として、ユーザには「処理の実行」ではなく「処理の登録」というモチベーションでコードを書いてもらいたいですね。

「処理の実行」ではなく「処理の登録」というモチベーション

RenderGraphによるPassの処理では以下のような記述が登場します。

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
    using var builder = renderGraph.AddUnsafePass<PassData>(_passName, out var passData);
        
    builder.SetRenderFunc((PassData data, UnsafeGraphContext context) =>
    {
        using (new ProfilingScope(context.cmd, profilingSampler))
        {
            // 描画処理
        }
    });
}

従来のExecuteメソッドの代わりがこのRecordRenderGraphメソッドです。名前からして「処理の実行」ではなく「処理の登録」ですね。

このフレームでどんなリソースを使うのか知りたい

先ほどのコードにPassDataという型が出てきましたが、これは利用者である我々が勝手に宣言していいものです。そのためジェネリックの型引数に入れることでURPフレームワークに教えてあげる必要があります。 勝手に宣言するものなので名前も宣言場所もなんでもいいのですが、要するにパスで使用するデータ、なので内部クラスとしてPassDataと名付けることが一般的なようです。

public class CustomPass : ScriptableRenderPass
{
    private class PassData
    {
        public Material PostEffectMaterial;
    }

    public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
    {
        using var builder = renderGraph.AddUnsafePass<PassData>(_passName, out var passData);
        
        // builderがジェネリックなので、我々が定義したPassDataが渡ってくる
        builder.SetRenderFunc((PassData data, UnsafeGraphContext context) =>
        {
            using (new ProfilingScope(context.cmd, profilingSampler))
            {
                // 描画処理
            }
        });
    }
}

描画処理には制限をかけたくない

リソースの宣言には様々なルールを設けていますが、処理の実行はメソッドを普通に記述してデリゲートで渡すので、ほとんど制限はありません。

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
    using var builder = renderGraph.AddUnsafePass<PassData>(_passName, out var passData);
        
    builder.SetRenderFunc((PassData data, UnsafeGraphContext context) =>
    {
        using (new ProfilingScope(context.cmd, profilingSampler))
        {
            // 描画処理。従来のExecuteに書いていた、例えばBlitなどはここに書くことになる。
        }
    });
}

終わり

パッと見これまでと全然違うような気がするRenderGraphですが、RenderGraphがやりたいことを考えれば結構素直な設計をしていますね。まぁ今回書かなかった部分で、フレームワークとしてかなり許せないポイントがあるのですが……それは番外編としてまた書きます。

参考

zenn.dev