ScriptableRenderPipelineの部品化とUniversalRP

先日の記事で素朴なUnlitのSRPを書きました。

enrike3.hatenablog.com

今回は、素朴なSRPとUniversalRPの間を埋めUniversalRPの構造を理解すべく、SRPの部品化を試みます。
最終的にはUniversalRPの下図の構造を理解できることが目標です。

f:id:enrike3:20191010024130p:plain 我ながら配色のセンスがやばすぎて震える

さて、先日の素朴なパイプラインの中には、いかにも描画要素に分解できそうな三つのメソッドがありました。

private void ClearRenderTarget(ScriptableRenderContext context, Camera camera)
{
    //カメラをクリアする
}

private void RenderOpaque(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
{
    //不透明描画を行う
}

private void RenderTransparent(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
{
    //透明描画を行う
}

これを、まずはSRPRenderPassという概念のオブジェクトにしてしまいます。

public abstract class SRPRenderPass
{
    public abstract void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults);
}

public class ClearPass : SRPRenderPass
{
    public override void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
    {
        //カメラをクリアする
    }
}

public class OpaquePass : SRPRenderPass
{
    public override void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
    {
        //不透明描画を行う
    }
}

public class TransparentPass : SRPRenderPass
{
    public override void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
    {
        //透明描画を行う
    }
}

これらのクラスがあると、SRP側は以下のようになります。SceneViewとかGizmoまわりは説明を単純化するために除外します。

class MyRenderPipeline : RenderPipeline
{
    SRPRenderPass[] _renderPass = null;

    public MyRenderPipeline()
    {
        _renderPass = new SRPRenderPass[]
        {
            new ClearPass(),
            new OpaquePass(),
            new TransparentPass(),
        };
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        foreach(var camera in cameras)
        {
            context.SetupCameraProperties(camera);

            if (!camera.TryGetCullingParameters(out ScriptableCullingParameters cullingParameters))
                continue;

            var cullResults = context.Cull(ref cullingParameters);

            foreach(var pass in _renderPass)
            {
                pass.ExecutePass(context, camera, ref cullResults);
            }
        }

        context.Submit();
    }
}

SRP側は、カメラに対してカリングを行い複数のSRPRenderPassを実行するという構造になりました。

さらに部品化をすすめましょう。カメラ操作と複数のSRPRenderPassを実行する役割をSRPRendererという概念にしてしまいます。

public abstract class SRPRenderer
{
    public abstract void Execute(ScriptableRenderContext context, Camera camera);
}

public class MyRenderer : SRPRenderer
{
    SRPRenderPass[] _renderPass = null;

    public MyRenderer()
    {
        _renderPass = new SRPRenderPass[]
        {
                new ClearPass(),
                new OpaquePass(),
                new TransparentPass(),
        };
    }

    public override void Execute(ScriptableRenderContext context, Camera camera)
    {
        context.SetupCameraProperties(camera);

        if (!camera.TryGetCullingParameters(out ScriptableCullingParameters cullingParameters))
            return;

        var cullResults = context.Cull(ref cullingParameters);

        foreach (var pass in _renderPass)
        {
            pass.ExecutePass(context, camera, ref cullResults);
        }
    }
}

これを導入すると、SRP側はさらに簡素になります。カメラループの中の処理はすべてRendererに委譲できました。

class MyRenderPipeline : RenderPipeline
{
    SRPRenderer _renderer = null;

    public MyRenderPipeline()
    {
        _renderer = new MyRenderer();
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        foreach(var camera in cameras)
        {
            _renderer.Execute(context, camera);
        }

        context.Submit();
    }
}

さて、SRPRendererという便利な単位ができたので、カメラに応じてSRPRendererを切り替えてみましょう。 カメラに対してコンポーネントを付けます。

[RequireComponent(typeof(Camera))]
public class AdditionalCameraData : MonoBehaviour
{
    public int RendererIndex;
}

SRPは、Rendererを複数持つように変更します。

class MyRenderPipeline : RenderPipeline
{
    SRPRenderer[] _renderers = null;

    public SRPRenderer DefaultRenderer => _renderers[0];

    public MyRenderPipeline()
    {
        _renderers = new Renderer[]
        {
            new MyRenderer(),
            new NazoRenderer(),
        };
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        foreach(var camera in cameras)
        {
            var cameraData = camera.GetComponent<AdditionalCameraData>();
            var renderer = cameraData == null ? DefaultRenderer : _renderers[cameraData.RendererIndex];

            renderer.Execute(context, camera);
        }

        context.Submit();
    }
}

カメラ毎に描画を大きく切り替えることができるようになりました。

さて、部品化が進んでくるとSRPRendererをエディタから設定したくなってきます。Unityでエディタ設定といえばScriptableObjectです。
SRPRendererをScriptableObjectからもらうようにしましょう。SRPRendererDataというclassを導入します。

public abstract class SRPRendererData : ScriptableObject
{
    public abstract SRPRenderer Create();
}

[CreateAssetMenu(menuName = "MySRP/MyRendererData")]
public class MyRendererData : SRPRendererData
{
    public override SRPRenderer Create() => new MyRenderer();
}

[CreateAssetMenu(menuName = "MySRP/NazoRendererData")]
public class NazoRendererData : SRPRendererData
{
    public override SRPRenderer Create() => new NazoRenderer();
}

これでSRPRendererのネタをアセットとしてSerializeできるので、 RenderPipelineAssetにSRPRendererの配列を持たせることでエディタによるカスタマイズ性を手にいれることができます。

public class MyRenderPipelineAsset : UnityEngine.Rendering.RenderPipelineAsset
{
    public SRPRendererData[] RendererData;

    protected override RenderPipeline CreatePipeline()
    {
        return new MyRenderPipeline(this);
    }
}

public class MyRenderPipeline : RenderPipeline
{
    SRPRenderer[] _renderers = null;

    public SRPRenderer DefaultRenderer => _renderers[0];

    public MyRenderPipeline(MyRenderPipelineAsset asset)
    {
        _renderers = asset.RendererData.Select(x => x.Create()).ToArray();
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        //略
    }
}

f:id:enrike3:20191010015647p:plain

同様にSRPRenderPassに対応するScriptableObjectを作れば、SRPRendererの内容をカスタマイズすることも可能です。

さて、ここまでを振り返ってみましょう。

  • 小さな描画コードをRenderPassという単位に切り出した
  • カメラ単位の処理、すなわちカメラのカリングと複数のRenderPassの実行をRendererという単位に束ねた
  • RenderPipeline側で複数のRendererをもち、カメラに応じて切り替えられるようにした
  • Rendererを作成するScriptableObjectを用意することで、RenderPipelineを構成するRenderer群をエディタで設定できるようにした
  • さらに頑張れば、Renderer内のRenderPassをエディタで設定するためのScriptableObjectを作ることも可能

これらすべてをUnity公式がしっかり実装したものがUniversalRPです。

  • UniversalRPはScriptableRendererを複数持つことができ、カメラに応じて切り替えることができます
  • UniversalAdditionalCameraDataがRendererのIndexをもっています。が、UniversalRPはCameraのインスペクタを乗っ取っているので、UniversalAdditionalCameraDataの項目をCameraのインスペクタで設定します
  • ScriptableRendererは、複数のScriptableRenderPassの入れ物です
  • ScriptableRendererを生成するアセットとして、ScriptableRendererDataというScriptableObjectがあります
  • ScriptableRenderPassをScriptableRendererに複数注入するために、ScriptableRenderFeatureというScriptableObjectがあります
    • ScriptableRenderFeatureとScriptableRenderPassが1:1でないのは、複数のパスがまとまって1機能となることがあるためでしょう

冒頭の図を再掲 f:id:enrike3:20191010024130p:plain

上記構造が分かっていれば、UniversalRPのコードリーディングもはかどります。

まず読むべきはForwardRendererがどのようなScriptableRenderPassを保持しているか。これで描画のおおよそを掴むことができます。
ForwardRendererがもつScriptableRenderPassの組み合わせが、求める用途にまったくそぐわないのであれば、ScriptableRendererを自作すべきでしょう。 ForwardRendererにいくつかのScriptableRenderPassを足せば求める用途を満たせるのであれば、ScriptableRenderFeatureを自作してForwardRendererにScriptableRenderPassをいくつか注入します。

この二つの方法で上手くいかないのであればUniversalRPは採用しないほうが良いでしょう。
が、まかなえるシナリオのほうが多いのではと思います。UniversalRP使いこなしていきたいですね。