【Unity】Uiversal Render Pipelineでカスタムポストエフェクトを実装する(公式未対応バージョン)

UnityのUniversal Render Pipeline(URP)でカスタムポストエフェクトを実装する方法についてまとめました。
なおいずれはカスタムポストエフェクトについて公式なサポートが入る予定ですが、この記事は公式が未対応の現状における実装方法となります。

Unity2021.1.11f1
Universal RP 11.0.0

はじめに

この記事ではURPでカスタムポストエフェクトを実装する方法についてまとめます。

URPではOnRenderImageなどのイベントが廃止されているため、従来のポストエフェクトの実装方法とは異なるアプローチが必要です。

docs.unity3d.com

しかしながら現在のバージョンでは、公式としてカスタムポストエフェクトには未対応、将来的には予定されている、という状況です。

Note: URP does not currently support custom post-processing effects. If your Project uses custom post-processing effects, these cannot currently be recreated in URP. Custom post-processing effects will be supported in a forthcoming release of URP.
https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@11.0/manual/InstallingAndConfiguringURP.htmlより

とはいえ独自のポストエフェクトが実装できないというのは大きな問題なので、この記事では現状で出来る範囲でカスタムポストエフェクトを実装します。

前提知識

本記事ではURPについての基礎知識については本記事にはまとめませんので、必要に応じて以下の記事を参照してください。

light11.hatenadiary.com

また、URPにおけるポストエフェクトの基礎知識については以下の記事にまとめています。

light11.hatenadiary.com

URPにおけるシェーダの書き方については以下の記事にまとめています。

light11.hatenadiary.com

さらに、URPでレンダリングパスを追加する方法は以下の記事にまとめています。

light11.hatenadiary.com

本記事ではこれらを前提知識としますので、必要に応じて参照ながら読んでください。

つくるもの

それでは今回はレンダリング結果に指定した色を乗算するポストエフェクトを作成します。

f:id:halya_11:20210715232406p:plain
ポストエフェクトを掛けた状態

Forward Renderer Dataではポストエフェクトを掛けるタイミングを以下から選択できるようにします。

  • 不透明オブジェクト(及びSkybox)の描画を行った後
  • 半透明オブジェクトの描画を行った後
  • すべてのポストエフェクトを掛け終わった後

そのエフェクトをシーンビューにも適用するかどうかを選択できるチェックボックスも作ります。

f:id:halya_11:20210715232757p:plain
Forward Renderer Data

また、ポストエフェクトの有効性や乗算する色はVolumeオブジェクトで制御できるようにします。

f:id:halya_11:20210715232557p:plain
Volume

シェーダを書く

それではまずシェーダから書いていきます。
シェーダはただ色を乗算しているだけなので至ってシンプルです。

Shader "Hidden/Example"
{
    SubShader
    {
        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            half4 _TintColor;

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct Varyings
            {
                float4 positionHCS : SV_POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            Varyings vert(Attributes IN)
            {                
                Varyings OUT;
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
                OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
                OUT.uv = IN.uv;
                return OUT;
            }

            half4 frag(Varyings IN) : SV_Target
            {
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(IN);
                half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
                col.rgb *= _TintColor.rgb;
                return col;
            }
            ENDHLSL
        }
    }
}

Volumeコンポーネントを作成する

次に、Volumeオブジェクトからこのポストエフェクトを選択できるようにVolumeコンポーネントを作成します。
これもシンプルです。

using System;
using UnityEngine;
using UnityEngine.Rendering;

[Serializable]
[VolumeComponentMenu("Example Effect")]
public class ExamplePostProcessVolume : VolumeComponent // VolumeComponentを継承する
{
    public bool IsActive() => tintColor != Color.white;

    // Volumeコンポーネントで設定できる値にはXxxParameterクラスを使う
    public ColorParameter tintColor = new ColorParameter(Color.white);
}

ScriptableRenderPassを作成する

次にポストエフェクト用のレンダリングパスを作成します。
この部分は注意点が多いのですが、コメントを細かく書いたのでそちらを参照してください。

組み込みポストエフェクトより後にエフェクトを掛けたい場合、_AfterPostProcessTextureから色を取得&書き込みしないといけないのがハマりポイントでした。

また注意点として、一度一時バッファにレンダリングしてから最終的なレンダーターゲットにレンダリングしなおしています。
これはできれば削減したい処理負荷ですが、現状では仕方なさそうなのでこの実装にしています。
なおもし複数カスタムエフェクトを掛けたい場合には最後の一度だけレンダーターゲットへのレンダリングを行うなどの最適化が必要です。

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public enum PostprocessTiming
{
    AfterOpaque,
    BeforePostprocess,
    AfterPostprocess
}

public class ExamplePostProcessRenderPass : ScriptableRenderPass
{
    private const string RenderPassName = nameof(ExamplePostProcessRenderPass);
    private const string ProfilingSamplerName = "SrcToDest";

    private readonly bool _applyToSceneView;
    private readonly int _mainTexPropertyId = Shader.PropertyToID("_MainTex");
    private readonly Material _material;
    private readonly ProfilingSampler _profilingSampler;
    private readonly int _tintColorPropertyId = Shader.PropertyToID("_TintColor");

    private RenderTargetHandle _afterPostProcessTexture;
    private RenderTargetIdentifier _cameraColorTarget;
    private RenderTargetHandle _tempRenderTargetHandle;
    private ExamplePostProcessVolume _volume;

    public ExamplePostProcessRenderPass(bool applyToSceneView, Shader shader)
    {
        if (shader == null)
        {
            return;
        }

        _applyToSceneView = applyToSceneView;
        _profilingSampler = new ProfilingSampler(ProfilingSamplerName);
        _tempRenderTargetHandle.Init("_TempRT");

        // マテリアルを作成
        _material = CoreUtils.CreateEngineMaterial(shader);

        // RenderPassEvent.AfterRenderingではポストエフェクトを掛けた後のカラーテクスチャがこの名前で取得できる
        _afterPostProcessTexture.Init("_AfterPostProcessTexture");
    }

    public void Setup(RenderTargetIdentifier cameraColorTarget, PostprocessTiming timing)
    {
        _cameraColorTarget = cameraColorTarget;

        renderPassEvent = GetRenderPassEvent(timing);

        // Volumeコンポーネントを取得
        var volumeStack = VolumeManager.instance.stack;
        _volume = volumeStack.GetComponent<ExamplePostProcessVolume>();
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        if (_material == null)
        {
            return;
        }

        // カメラのポストプロセス設定が無効になっていたら何もしない
        if (!renderingData.cameraData.postProcessEnabled)
        {
            return;
        }

        // カメラがシーンビューカメラかつシーンビューに適用しない場合には何もしない
        if (!_applyToSceneView && renderingData.cameraData.cameraType == CameraType.SceneView)
        {
            return;
        }

        if (!_volume.IsActive())
        {
            return;
        }

        // renderPassEventがAfterRenderingの場合、カメラのカラーターゲットではなく_AfterPostProcessTextureを使う
        var source = renderPassEvent == RenderPassEvent.AfterRendering && renderingData.cameraData.resolveFinalTarget
            ? _afterPostProcessTexture.Identifier()
            : _cameraColorTarget;

        // コマンドバッファを作成
        var cmd = CommandBufferPool.Get(RenderPassName);
        cmd.Clear();

        // Cameraのターゲットと同じDescription(Depthは無し)のRenderTextureを取得する
        var tempTargetDescriptor = renderingData.cameraData.cameraTargetDescriptor;
        tempTargetDescriptor.depthBufferBits = 0;
        cmd.GetTemporaryRT(_tempRenderTargetHandle.id, tempTargetDescriptor);

        using (new ProfilingScope(cmd, _profilingSampler))
        {
            // VolumeからTintColorを取得して反映
            _material.SetColor(_tintColorPropertyId, _volume.tintColor.value);
            cmd.SetGlobalTexture(_mainTexPropertyId, source);

            // 元のテクスチャから一時的なテクスチャにエフェクトを適用しつつ描画
            Blit(cmd, source, _tempRenderTargetHandle.Identifier(), _material);
        }

        // 一時的なテクスチャから元のテクスチャに結果を書き戻す
        Blit(cmd, _tempRenderTargetHandle.Identifier(), source);

        // 一時的なRenderTextureを解放する
        cmd.ReleaseTemporaryRT(_tempRenderTargetHandle.id);

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    private static RenderPassEvent GetRenderPassEvent(PostprocessTiming postprocessTiming)
    {
        switch (postprocessTiming)
        {
            case PostprocessTiming.AfterOpaque:
                return RenderPassEvent.AfterRenderingSkybox;
            case PostprocessTiming.BeforePostprocess:
                return RenderPassEvent.BeforeRenderingPostProcessing;
            case PostprocessTiming.AfterPostprocess:
                return RenderPassEvent.AfterRendering;
            default:
                throw new ArgumentOutOfRangeException(nameof(postprocessTiming), postprocessTiming, null);
        }
    }
}

ScriptableRendererFeatureを作成する

最後に前節のパスをレンダラに登録するRenderer Featureを作成します。
ここは特に難しいことはしていません。

using System;
using UnityEngine;
using UnityEngine.Rendering.Universal;

[Serializable]
public class ExamplePostProcessRenderFeature : ScriptableRendererFeature
{
    [SerializeField] private Shader _shader;
    [SerializeField] private PostprocessTiming _timing = PostprocessTiming.AfterOpaque;
    [SerializeField] private bool _applyToSceneView = true;

    private ExamplePostProcessRenderPass _postProcessPass;

    public override void Create()
    {
        _postProcessPass = new ExamplePostProcessRenderPass(_applyToSceneView, _shader);
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        _postProcessPass.Setup(renderer.cameraColorTarget, _timing);
        renderer.EnqueuePass(_postProcessPass);
    }
}

使う

それでは実際にこれらを使ってポストエフェクトを掛けていきます。
まずForward Renderer DataにExample Post Process Render Featureを追加します。

f:id:halya_11:20210715235822p:plain
Forward Renderer Data

追加出来たらShaderプロパティに今回作ったシェーダをアサインします。
TimingやApply To Scene Viewは好きなように設定してください。

f:id:halya_11:20210716000044p:plain
シェーダを設定

次にCameraコンポーネントのPost Processingにチェックを入れます。

f:id:halya_11:20210716000248p:plain
Camera

最後にVolumeオブジェクトを作成して、今回作ったVolumeコンポーネントを追加します。
追加したらTint Colorにチェックをいれて適当に色を設定します。

f:id:halya_11:20210716000517p:plain
Volume

以下のようにエフェクトが掛かかることが確認できました。

f:id:halya_11:20210716000629p:plain
エフェクトがかかった

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com