Unity2022 URP14 呟き独歩 その3

2023/12/27に公開

その3で触れる内容

  • BufferedRTHandleSystemsについて
  • RTHandleを用いたPostProcess実装

更新内容

2024/01/15
・BufferedRTHandleSystemのURP14.0.9での活用状況について追記しました。
2024/02/02
・RendererFeature, Pass, Shader, RendererDataを修正、一部解説内容を修正しました。

この記事の趣旨

パイプラインが大きく変化し、URP14は既存の描画処理から大きな変更が加わっているという。自分はURPの知見がURP10で止まっているため、そろそろ重い腰を上げて学ばないとな…と思う。

想定する対象読者

  • URPを気分で使用してきた人
  • 2,3年ほど昔のURPを触ってきた人

開発環境

  • Unity2022.3.15(現時点 2023/12/15のUnity2022最新バージョン)
  • URP14.0.9

その2はこちら

Unity2022 URP14 呟き独歩 その2

その3

前回の続き、BufferedRTHandleSystemについて理解しようと思う。

BufferedRTHandleSystem

BufferedRTHandleSystemとは、名前の通りRTHandlesをマルチバッファリングできるシステムである。

バッファをIDで識別し、同じバッファのインスタンスをいくつか割り当て、それらを以前のフレームから取得するAPIを提供するとのこと…

同じバッファのインスタンスを「いくつか」割り当てるということは、数フレーム分のバッファをもっているということ。これらのバッファをHistory Buffersと名付けられている。

もちろんダブルバッファリングのような処理が不要な場合はHistory Buffersは必要ない。もちろん複数のバッファを確保する分、メモリ消費は枚数分増加してしまう。

また、BufferedRTHandleSystemはカメラごとに設定が可能で、すべてのRenderTextureを考慮した最大サイズのバッファを確保する必要はない。

BufferedRTHandleSystemsのアロケートは、通常のRTHandleのアロケートとは異なる。その違いは、BufferID(Bufferを一意に識別するID)と、BufferCount(History Buffersが確保するインスタンスの数)の指定をおこなう点である。

初期化

BufferedRTHandleSystem  m_HistoryRTSystem = new BufferedRTHandleSystem()

アロケート

アロケートではHistory BuffersすべてのRTHandleインスタンスを生成するわけではなく、必要時に割り当てられる。

public void AllocBuffer(int bufferId, Func<RTHandleSystem, int, RTHandle> allocator, int bufferCount);

取得

BufferIDとHistory BuffersのIndexを指定して取得する。

public RTHandle GetFrameRT(int bufferId, int frameIndex);

解放

BufferID指定で解放する。

public void ReleaseBuffer(int bufferId);

Swap

Swapと同時にReferenceSizeの変更もおこなう。

public void SwapAndSetReferenceSize(int width, int height);

懸念として、動的解像度の場合History Buffersに格納されたBufferのサイズが異なる可能性がある。その場合、前フレームのBufferにアクセスする前に、スケーリングをおこなう。RTHandlePropertiesに前フレームの解像度情報が格納されているので、これを基にスケーリングする。

補足:
History Buffersが活用できそうな実装としてTemporal Anti-Aliasing(TAA)が考えられるが、現状URP14.0.9標準PostProcess実装ではBufferedRTHandleSystemは使用しておらず、複数RTHandleのSwap、またはColorBufferのSwapをおこなって実装されている。自分が確認した限りだとURP14.0.9でBufferedRTHandleSystemを使用した箇所が存在しないため、適切に使用する場合は内部実装をしっかり把握してからの方が良さそうに感じた。

実装(PostProcess)

超簡易的に実装してみる。フルスクリーン描画の方が分かりやすいと思い、PostProcess処理をカスタムRendererFeature、RenderPassで追加してみる。効果はなんでもよいが、個人的に好きな色収差を入れてみようと思う。色収差の実装は以下を参考にさせていただいた。

【Unity】【シェーダ】色収差のポストエフェクトを実装する - LIGHT11

実際に画面に出した時の見た目は以下の通り。色収差の効果が反映されている。

Renderer

なるべく他実装を削ぎ落としたRendererDataを用意した(下記画像参照)。このRendererDataにカスタムRendererFeatureを追加する。URP14でForward Renderingを採用する場合、”Forward”と”Forward+”どちらかを選択することができる。Forward+ではForwardと比較して、ライト上限数の増加、Reflection Probeの2つ以上のブレンド、ECSを使用する際の複数ライトをサポートしていたりと環境光表現がアップデートされている。今回実装するPostProcessは関係なさそう。

できるだけ最小コードで記述したRendererFeature, RenderPassを記載する。

最小コード

PostProcessRendererFeature.cs

using UnityEngine;
using UnityEngine.Rendering.Universal;

internal class PostProcessRendererFeature : ScriptableRendererFeature
{
    [SerializeField] private Shader _shader;
    PostProcessRenderPass _renderPass = null;
    
    public override void Create()
    {
        if (_renderPass == null)
        {
            _renderPass = new PostProcessRenderPass(_shader);
        }
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        renderer.EnqueuePass(_renderPass);
        // ConfigureInputはAddRenderPasses内で呼ぶこと
        _renderPass.ConfigureInput(ScriptableRenderPassInput.Color);
    }

    public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
    {
        RenderTextureDescriptor cameraTargetDescriptor = renderingData.cameraData.cameraTargetDescriptor;
        _renderPass.Setup(cameraTargetDescriptor);
    }

    protected override void Dispose(bool disposing)
    {
        _renderPass.Cleanup();
        _renderPass = null;
        base.Dispose(disposing);
    }
}

PostProcessRenderPass.cs

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

internal class PostProcessRenderPass : ScriptableRenderPass
{
    private ProfilingSampler _profilingSampler = new ProfilingSampler("PostProcess");
    private MaterialLibrary m_Materials;
    private ColorAberration m_ColorAberration;
    private static readonly int IntensityShaderId = Shader.PropertyToID("_AberrationIntensity");
    private RenderTextureDescriptor m_Descriptor;
    
    public PostProcessRenderPass(Shader uberPost)
    {
        m_Materials = new MaterialLibrary(uberPost);
        renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    }
    
    public void Cleanup() => m_Materials.Cleanup();

    public void Setup(in RenderTextureDescriptor baseDescriptor)
    {
        m_Descriptor = baseDescriptor;
        m_Descriptor.useMipMap = false;
        m_Descriptor.autoGenerateMips = false;
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        if (m_Materials == null)
        {
            Debug.LogError("PostProcessRenderPass.Execute: Material is null.");
            return;
        }

        if (renderingData.cameraData.cameraType == CameraType.SceneView)
        {
            return;
        }
        
        var stack = VolumeManager.instance.stack;
        m_ColorAberration = stack.GetComponent<ColorAberration>();
        
        CommandBuffer cmd = CommandBufferPool.Get();

        using (new ProfilingScope(cmd, _profilingSampler))
        {
            bool colorAberrationActive = m_ColorAberration.IsActive();
            if (colorAberrationActive)
            {
                Render(cmd, ref renderingData);
            }
        }
        
        context.ExecuteCommandBuffer(cmd);
        cmd.Clear();

        CommandBufferPool.Release(cmd);
    }

    void Render(CommandBuffer cmd, ref RenderingData renderingData)
    {
        if (m_Materials.uber != null)
        {
            m_Materials.uber.SetFloat(IntensityShaderId, m_ColorAberration.intensity.value);
            Blit(cmd, ref renderingData, m_Materials.uber, 0);
        }
    }
    
    class MaterialLibrary
    {
        public readonly Material uber;
    
        public MaterialLibrary(Shader uberPost)
        {
            uber = Load(uberPost);
        }
        
        Material Load(Shader shader)
        {
            if (shader == null)
            {
                Debug.LogErrorFormat($"Missing shader. {GetType().DeclaringType.Name} render pass will not execute. Check for missing reference in the renderer resources.");
                return null;
            }
            else if (!shader.isSupported)
            {
                return null;
            }

            return CoreUtils.CreateEngineMaterial(shader);
        }

        internal void Cleanup()
        {
            CoreUtils.Destroy(uber);
        }
    }
}

UberPost.shader (色収差)

Shader "InPro/UberPost"
{
    SubShader
    {
        Cull Off
        ZTest Always
        ZWrite Off

        Tags { "RenderType"="Opaque" "RenderPipeline" = "UniversalPipeline"}

        Pass
        {
            Name "UberPost"
            
            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

            half _AberrationIntensity;
            TEXTURE2D_X(_CameraOpaqueTexture);
            SAMPLER(sampler_CameraOpaqueTexture);
            

            half4 frag (Varyings i) : SV_Target
            {
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
                float4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, i.texcoord);

                /// 色収差. ///
                half2 uvBase = i.texcoord - 0.5h;
                // R値を拡大したものに置き換える
                half2 uvR = uvBase * (1.0h - _AberrationIntensity * 2.0h) + 0.5h;
                color.r = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uvR);
                // G値を拡大したものに置き換える
                half2 uvG = uvBase * (1.0h - _AberrationIntensity) + 0.5h;
                color.g = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uvG);

                return color;
            }
            ENDHLSL
        }
    }
}

全体概要

全体の概要を図で示してみる。

全体的な流れは既存と同様のところが多い。変わったところをピックアップしてみる。

AddRenderPassesとSetupRenderPassesについて

(厳密にはURP13.1辺りからだが)ScriptableRendererFeatureのインスタンスがScriptableRendererクラスによって割り当てられる前にRenderTargetにアクセスしようとすると、エラーとなる。今までAddRenderPassesのついでにPassのSetupをおこなったりしている場合、内部でRTHandleへアクセスしようとするとエラーが返される(自分が実装した時はnull referenceエラーが返された)。

そのため、RenderTargetが割り当てられた後で呼び出し可能な関数SetupRenderPassesが新たに定義された(AddRenderPassesは元からある)。

RenderTarget割り当て前後で、AddRenderPassesかSetupRenderPassesどちらに処理を記述すべきかは考慮する必要がある。

Configure内で実行するConfigureTargetでColorAttachments(RTHandle[])を初期化する

今後はRenderTargetHandleは廃止となる。その代わりに、RTHandleを使用する。それに伴い、ScriptableRenderer.cameraColorTargetScriptableRenderer.cameraDepthTargetも廃止され、代わりにcameraColorTargetHandlecameraDepthTargetHandleを使用する。

cmd.Blit → Blitter

今までは

cmd.Blit(RenderTargetIdentifier, RenderTargetIdentifier, Material, Path);

でテクスチャのBlitをおこなっていたが、今後はBlitterを用いて

Blitter.BlitCameraTexture(cmd, RTHandle, RTHandle, Material, Path);

のようにテクスチャをBlitする。

RenderTargetIdentifierで入出力Targetを設定していた箇所は、RTHandle指定でTargetを設定する。

なぜRTHandleを渡すのか?

Blitter.BlitCameraTextureの実装を見てみる。

// Blitter.BlitCameraTexture
public static void BlitCameraTexture(CommandBuffer cmd, RTHandle source, RTHandle destination, Material material, int pass)
{
    Vector2 viewportScale = source.useScaling ? new Vector2(source.rtHandleProperties.rtHandleScale.x, source.rtHandleProperties.rtHandleScale.y) : Vector2.one;
    // Will set the correct camera viewport as well.
    CoreUtils.SetRenderTarget(cmd, destination);
    BlitTexture(cmd, source, viewportScale, material, pass);
}

rtHandlePropertiesに格納したスケール値をもとに、ビューポートに対するスケーリングをおこなっている。RTHandleでは1枚のRenderTextureに対してスケーリングしながら書き込みをおこなう方針なので、ここでスケール値を決定している。

Blitter.BlitTextureはこちら。

public static void BlitTexture(CommandBuffer cmd, RTHandle source, Vector4 scaleBias, Material material, int pass)
{
    s_PropertyBlock.SetVector(BlitShaderIDs._BlitScaleBias, scaleBias);
    s_PropertyBlock.SetTexture(BlitShaderIDs._BlitTexture, source);
    DrawTriangle(cmd, material, pass);
}

スケール値は_BlitScaleBias というプロパティ名でセットしているようだ。Vector4ではあるが、xy要素にのみそれぞれxy軸のスケール値が書き込まれる。

ソーステクスチャは_BlitTexture というプロパティ名でセットされているので、ソースをサンプリングする場合は_BlitTexture をサンプリングする必要がありそう。

トポロジはTriangle。Quadではないので注意。

また、Shader側でも後々記載するが、Blitter.BlitTextureを使用する場合、Shader側ではBlit.hlslに定義されたプロパティを使用する必要がある。

Vertex shader

今回はBlit.hlslに記述しているVertexShaderを利用している。というのも、少なくともURP10系では、BlitはQuadで描画していた(PostProcessingStackのポスプロはTriangle描画)。そのため、Attributeからuvをそのままの値で代入すると位置が合わず、描画が崩れてしまう。

Blit.hlslのVertex shaderを見てみる。

Varyings Vert(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

#if SHADER_API_GLES
    float4 pos = input.positionOS;
    float2 uv  = input.uv;
#else
    float4 pos = GetFullScreenTriangleVertexPosition(input.vertexID);
    float2 uv  = GetFullScreenTriangleTexCoord(input.vertexID);
#endif

    output.positionCS = pos;
    output.texcoord   = uv * _BlitScaleBias.xy + _BlitScaleBias.zw;
    return output;
}

特に注視する点はここ。

    float4 pos = GetFullScreenTriangleVertexPosition(input.vertexID);
    float2 uv  = GetFullScreenTriangleTexCoord(input.vertexID);

Triangleによるフルスクリーン描画をおこなうために、positionとuvを調整している。

また、

output.texcoord   = uv * _BlitScaleBias.xy + _BlitScaleBias.zw;

_BlitScaleBias に先程Blitterでプロパティに転送した、ビューポートに対するスケール値を乗算している。これによってTriangleのuv座標を調整することで、異なる解像度間での描画が可能となる。

テクスチャサンプリング

テクスチャサンプリングでは、テクスチャプロパティ名_BlitTexture でサンプリングしている。BlitメソッドでSetTexture指定していたので、サンプリングが可能な状態になっている。

Swap

今までのBlitでも、SourceTargetとDestTargetが同一となる処理(読み込みと書き込みが同一Targetとなる処理)は正常に動作しない。

そのため、今までは

cmd.GetTemporaryRT(tempIdentifier, descriptor)
cmd.Blit(srcIdentifier, tempIdentifier);
cmd.Blit(tempIdentifier, destIdentifier);

というように、一時テクスチャTargetに対して書き込みをおこない、再度元々書き込みたかったTargetに書き込む処理をおこなっていた。

今回はRTHandleのSwap処理を用いて実現している。実装したポスプロ処理では、

ScriptableRenderPass.Blit(cmd, renderingData, material, pass)

を使用してBlitをおこなっているが、内部的にはBufferのSwapをおこなっている。

// ScriptableRenderPass.Blit
public void Blit(CommandBuffer cmd, ref RenderingData data, Material material, int passIndex = 0)
        {
            var renderer = data.cameraData.renderer;

            Blit(cmd, renderer.cameraColorTargetHandle, renderer.GetCameraColorFrontBuffer(cmd), material, passIndex);
            renderer.SwapColorBuffer(cmd);
        }
renderer.SwapColorBuffer(cmd);

がSwap処理にあたる。RTHandleはFrontBufferとBackBufferを持ち合わせており、それらのTargetを切り替えている。
なお、

renderer.GetCameraColorFrontBuffer(cmd)

renderer.SwapColorBuffer(cmd)

はinternal定義されており、Swapする際はScriptableRenderPass.Blitを使用するのが無難かもしれない。

今回は以上。次は上記記載した内容からピックアップして詳細を書こうかなと考えている。

Discussion