youxibiao 发表于 2024-7-15 18:58

Unity URP14.0 自定义后措置系统

Unity版本:2022
URP版本:14.0.6
项目连接:



场景来源网络

在Unity官方文档中,给出了两种使用后措置的方式。第一种使用Global Volume,但仅限于使用内置后措置,自定义后措置需要改削URP,十分麻烦。第二种使用自定义RenderFeature添加自定义RenderPass,但一个后措置效果对应两个脚本,而且会带来多个RenderPass,不竭纹理申请和获取的消耗。
在这个基础上,我们创建一个本身的后措置系统,使得Renderer挂载一个RenderFeature,而且一个后措置效果只需要创建一个脚本。

建议先过一遍官方文档,出格是三部门,Render feature、Volume和Fullscreen blit部门。
官方文档分析

Global Volume 后措置

URP使用Volume框架来措置后措置,但是这些后措置都是URP自带的,也就是在RP里写死了的。
给场景添加一个Global Volume,它用来挂载Volume Profile,而Volume Profile用来挂载具体的后措置实例。通过对分歧场景的Global Volume挂载不异Volume Profile或分歧Volume Profile实现场景不异或分歧的后措置效果。例如下图,给场景添加了一个Bloom效果。




通过查看Bloom.cs源码,发现它是一个担任于VolumeComponent和IPostProcessComponent的子类,而且使用VolumeComponentMenuForRenderPipeline向Volume添加后措置选项。




自定义RendererFeature/Pass

在官方文档的Fullscreen blit中,举了一个用担任于
ScriptableRendererFeature和ScriptableRenderPass的脚本分袂自定义RendererFeature和RendererPass,从而实现一个绿色filter的后措置系统。

此中,担任于ScriptableRendererFeature的脚本代码如下。
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

internal class ColorBlitRendererFeature : ScriptableRendererFeature{
    public Shader m_Shader;
    public float m_Intensity;

    private Material m_Material;

    private ColorBlitPass m_RenderPass = null;

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) {
      if (renderingData.cameraData.cameraType == CameraType.Game)
            // Pass入队
            renderer.EnqueuePass(m_RenderPass);
    }

    public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData) {
      // 只对游戏摄像机应用后措置(还有预览摄像机等)
      if (renderingData.cameraData.cameraType == CameraType.Game) {
            // 设置向pass输入color (m_RenderPass父类)
            m_RenderPass.ConfigureInput(ScriptableRenderPassInput.Color);
            // 设置RT为相机的color
            m_RenderPass.SetTarget(renderer.cameraColorTargetHandle, m_Intensity);
      }
    }

    // 基类的抽象函数 OnEnable和OnValidate时调用
    public override void Create() {
      // 创建一个附带m_Shader的material
      m_Material = CoreUtils.CreateEngineMaterial(m_Shader);
      // 创建BiltPass脚本实例
      m_RenderPass = new ColorBlitPass(m_Material);
    }

    protected override void Dispose(bool disposing) {
      CoreUtils.Destroy(m_Material);
    }
}由于用来自定义RendererFeature,所以这个脚本的用处也显而易见:首先Create()函数将在OnEnable和OnValidate时被调用,它创建一个挂载指定Shader的材质。当为每个camera设置一个renderer时,AddRenderPasses函数将被调用,从而向这个camera的renderer入队自定义RenderPass。当每个camera衬着前,SetupRenderPasses函数将被调用,从而设置自定义RenderPass的衬着源RT和衬着方针RT。

担任于SetupRenderPasses的脚本如下。
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

internal class ColorBlitPass : ScriptableRenderPass{
    // 给profiler入一个新的事件
    private ProfilingSampler m_ProfilingSampler = new ProfilingSampler(”ColorBlit”);
    private Material m_Material;
    // RTHandle,封装了纹理及相关信息,可以认为是CPU端纹理
    private RTHandle m_CameraColorTarget;
    private float m_Intensity;

    public ColorBlitPass(Material material) {
      m_Material = material;
      // 指定执行这个Pass的时机
      renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    }

    // 指定进行后措置的target
    public void SetTarget(RTHandle colorHandle, float intensity) {
      m_CameraColorTarget = colorHandle;
      m_Intensity = intensity;
    }

    // OnCameraSetup是纯虚函数,相机初始化时调用
    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) {
      // (父类函数)指定pass的render target
      ConfigureTarget(m_CameraColorTarget);
    }

    // Execute时抽象函数,把cmd命令添加到context中(然后进一步送到GPU调用)
    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) {
      var cameraData = renderingData.cameraData;
      if (cameraData.cameraType != CameraType.Game)
            return;

      if (m_Material == null)
            return;

      // 获取commandbuffer
      CommandBuffer cmd = CommandBufferPool.Get();
      // 把cmd里执行的命令添加到m_ProfilingSampler定义的profiler块中
      // using用来自动释放new的资源
      using (new ProfilingScope(cmd, m_ProfilingSampler)) {
            m_Material.SetFloat(”_Intensity”, m_Intensity);
            // 使用cmd里的命令(设置viewport等,分辩率等),执行m_Material的pass0,将m_CameraColorTarget衬着到m_CameraColorTarget
            // 本质上画了一个覆盖屏幕的三角形
            Blitter.BlitCameraTexture(cmd, m_CameraColorTarget, m_CameraColorTarget, m_Material, 0);
      }
      // 把cmd中的命令入到context中
      context.ExecuteCommandBuffer(cmd);
      // 清空cmd栈
      cmd.Clear();
      
      CommandBufferPool.Release(cmd);
    }
}此中最重要的是Execute函数,它措置了后措置的逻辑。具体用途看代码。

最后就是具体的ColorBlitShader,很简单就不解释了。
Shader ”ColorBlit”
{
      SubShader
    {
      Tags { ”RenderType”=”Opaque” ”RenderPipeline” = ”UniversalPipeline”}
      LOD 100
      ZWrite Off Cull Off
      Pass
      {
            Name ”ColorBlitPass”

            HLSLPROGRAM
            #include ”Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl”
            // The Blit.hlsl file provides the vertex shader (Vert),
            // input structure (Attributes) and output strucutre (Varyings)
            #include ”Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl”

            #pragma vertex Vert
            #pragma fragment frag

            TEXTURE2D_X(_CameraOpaqueTexture);
            SAMPLER(sampler_CameraOpaqueTexture);

            float _Intensity;

            half4 frag (Varyings input) : SV_Target
            {
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
                float4 color = SAMPLE_TEXTURE2D_X(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, input.texcoord);
                return color * float4(0, _Intensity, 0, 1);
            }
            ENDHLSL
      }
    }
}
要应用这个Shader,需要将这个RendererFeature脚本挂载给管线Renderer。



fullscreen blit的效果如下,由于只打开对Game相机的后措置,所以Scene是不能预览的,而且也不管你相机打没打开后措置。


后措置系统设计分析

通过上两个官方文档中的例子,可以发现第二种使用ReanderFeature/Pass来创建一个指定的后措置效果其实就是一个后措置系统的雏形。

然而这样会由一些性能问题。在类似ColorBlitFeature.cs的具体后措置Feature中,它们都在AddRenderPasses()中调用EnqueuePass函数。每个后措置效果城市创建一个RenderPass,即使它们在不异的注入点。这样会导致一些性能问题,例如不竭地请求纹理和创建纹理。
为了解决这个问题,我们可以优化衬着过程。我们可以将不异注入点的后措置效果杂交到一个RenderPass中,然后再通过RendererFeature对这个杂交的RenderPass调用EnqueuePass函数。这样,不异注入点的后措置效果可以共享一个RenderPass,在这个RenderPass的Execute中调用每个具体后措置的衬着逻辑,在不异纹理签名间Blit,从而节省性能消耗。换句话说,让不异注入点的RenderPass包含许多子Pass(子Pass=>一次Blit),每个子Pass措置对应的后措置效果。通过上述描述,可以得出我们只需要向Renderer添加一个RenderFeature即可。

除此之外,这样的设计也很繁琐。每次创建一个新的后措置Shader时,都需要额外创建两个脚本:一个RendererFeature用于创建RenderPass,一个RenderPass用于执行Blit命令。

第一种使用Global Volume的方式就很便利,但仅适用于自带的后措置,要添加自定义后措置可能涉及到管线的改削,不采用。于是有了一个想法,即将第一种和第二种方式杂交一下。

因此,我们想到了一个基本的思路:创建一个后措置基类CustomPostProcessing.cs,让具体的后措置效果担任自这个基类。具体的后措置效果只需要实现本身的衬着逻辑即可。然后,我们需要一个自定义的RendererFeature类来获取所有Volume中CustomPostProcessing.cs子类,并按照它们的具体设置(如注入位置等)创建对应的自定义RenderPass实例。每个RenderPass类在Execute函数中调用对应所有CustomPostProcessing基类的Render函数,从而实现具体后措置脚本的衬着。

大致框架如下。



后措置基类

颠末上面分析,首先需要将CustomPostProcessing的基类添加到Volume的Override菜单里。这里通过前面对Bloom.cs源码的查看。




发现只需要担任VolumeComponent和IPostProcessComponent即可。基类添加VolumeComponentMenuForRenderPipeline()来添加菜单。由于衬着时会生成临时RT,所以还需要担任IDisposable。
    public abstract class CustomPostProcessing : VolumeComponent, IPostProcessComponent, IDisposable{

        }
然后添加IPostProcessComponent必需要override的一些函数,同样可以通过查看Bloom.cs来填写。(其实是按照不写会报错)
IPostProcessComponent要求定义IsActive()用来返回当前后措置是否active;IsTileCompatible()不知道用来干嘛的,但Bloom.cs里get值false,抄下来就行了。
        #region IPostProcessComponent
        public abstract bool IsActive();
       
        public virtual bool IsTileCompatible() => false;
       #endregion
IDispose部门如下:
        #region IDisposable
        public void Dispose() {
          Dispose(true);
          GC.SuppressFinalize(this);
        }
       
        public virtual void Dispose(bool disposing) {
        }
        #endregion
最后为其添加每个公用的属性和函数。
首先是这个后措置效果的注入点,这里先分三个。而且,插手当前后措置在注入点的执行挨次。
public enum CustomPostProcessInjectionPoint{
        AfterOpaqueAndSky,
        BeforePostProcess,
        AfterPostProcess
}

    public abstract class CustomPostProcessing : VolumeComponent, IPostProcessComponent, IDisposable{
      // 注入点
      public virtual CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterPostProcess;
      
      //在注入点的挨次
      public virtual int OrderInInjectionPoint => 0;
}
最后,插手Setup配置当前后措置,Render衬着当前后措置。
      // 配置当前后措置
      public abstract void Setup();

      // 执行衬着
      public abstract void Render(CommandBuffer cmd, ref RenderingData renderingData, RTHandle source, RTHandle destination);
测试一下子类是否能在Volume组件里显示出来,这里新写一个ColorBlit.cs:

public class ColorBlit : CustomPostProcessing{
        public ClampedFloatParameter intensity = new(0.0f, 0.0f, 2.0f)

        public override bool IsActive() => true;
               
        public override void Setup() {

        }

        public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RTHandle source, RTHandle destination) {
        }

        public override void Dispose(bool disposing) {
        }
}
成功。



抓取Volume内后措置组件

由前面的分析,CustomRedererFeature应该抓取Volume内后措置的组件,然后按照注入点位置创建3种RenderPass,入到队列中。
CustomRenderPass

首先填写CustomRenderPass的必要代码。为了在Execute内调用对应注入点的CustomPostProcessing的Render函数,我们需要在构造函数中向其传递当前注入点的所有CustomPostProcessing实例。同时,再声明一些必要的变量。
CustomPostProcessingPass.cs
public class CustomPostProcessingPass : ScriptableRenderPass{
        // 所有自定义后措置基类
        private List<CustomPostProcessing> mCustomPostProcessings;
      // 当前active组件下标
      private List<int> mActiveCustomPostProcessingIndex;

      // 每个组件对应的ProfilingSampler
      private string mProfilerTag;
      private List<ProfilingSampler> mProfilingSamplers;

      // 声明RT
      private RTHandle mSourceRT;
      private RTHandle mDesRT;
      private RTHandle mTempRT0;
      private RTHandle mTempRT1;

      private string mTempRT0Name => ”_TemporaryRenderTexture0”;
      private string mTempRT1Name => ”_TemporaryRenderTexture1”;

      public CustomPostProcessingPass(string profilerTag, List<CustomPostProcessing> customPostProcessings) {
            mProfilerTag = profilerTag;
            mCustomPostProcessings = customPostProcessings;
            mActiveCustomPostProcessingIndex = new List<int>(customPostProcessings.Count);
            // 将自定义后措置器对象列表转换成一个性能采样器对象列表
            mProfilingSamplers = customPostProcessings.Select(c => new ProfilingSampler(c.ToString())).ToList();

            mTempRT0 = RTHandles.Alloc(mTempRT0Name, name: mTempRT0Name);
            mTempRT1 = RTHandles.Alloc(mTempRT1Name, name: mTempRT1Name);
      }
}值得注意的是,在URP14.0(或者在这之前)中,丢弃了原有RenderTargetHandle,而通通使用RTHandle。本来的Init也变成了RTHandles.Alloc,具体更新内容可以看最后的参考连接。
CustomRenderFeature

然后需要在CustomRenderFeature中抓取所有Volume中的后措置组件并按照注入点分类。这在Create()函数里进行,它会在OnEnable和OnValidate时被调用。
管线源码分析

下面讨论如何抓取所有Volume中的组件。

首先,与Volume相关代码封装在VolumeManager.cs中。首先可以发现,它采用单例模式。




忽略那些Internal函数,它提供了一个baseComponentArray属性,而且它是public的。



按照描述,它是担任于VolumeComponent的所有子类类型的列表(注意是类型,而非实例)。注意这个描述,这说明非论后措置基类是否在Volume中,它城市存在baseComponentArray里面。

跟踪一下给这个属性赋值的处所,找到ReloadBaseType()函数。



在函数里获取了所有担任自VolumeComponent的非抽象类类型派生。而且,循环baseComponentTypeArray,把它作为VolumeComponent派生类实例添加到m_ComponentDefaultState里面,这个m_ComponentDefaultState在后面也有用处。
按照注释描述,得知它只会在运行时调用一次,而每次脚本重载在编纂器中启动时,我们需要跟踪项目中的任何兼容组件。

继续跟踪ReloadBaseTypes()函数,发现它是在构造函数里创建的(忽略Editor Only)。的确是运行时只调用一次。也就是说,这个单例被创建时,就填充baseComponentTypeArray。



通过上述讨论,我们得知,如果想要获取所有担任自VolumeComponent的派生类类型,而且筛选出派生类型为CustomPostProcessing的列表,代码如下:
var derivedVolumeComponentTypes = VolumeManager.instance.baseComponentTypeArray;
var customPostProcessingTypes = derivedVolumeComponentTypes.Where(t => t.IsSubclassOf(typeof(CustomPostProcessing))).Tolist(;但是这只能筛选出担任自VolumeComponent的派生类类型的列表,并不能获取具体派生类实例。继续分析。

注意到这个构造函数里还有一个CreateStack()函数赋值给m_DefaultStack,而且将stack赋值。首先stack是public属性,我们可以直接通过单例访谒到它。然后跟踪CreateStack()。



发现它用Reload(m_ComponentDefaultStata)函数填充stack并返回。继续跟踪Reload函数。发现它就是把m_ComponentDefaultStata里存放的VolumeComponent派生实例的类型和实例分割,放到一个字典components里。





也就是说,如果我们想要知道一个VolumeComponent派生类的具体实例,只需要访谒components即可。但是components是internal的,我们对它进行跟踪,找到GetComponent()函数,正是我们需要的public的返回components的函数。



那么获取所有担任于VolumeComponent的CustomPostProcessing实例的思路就很清晰了:首先获取所有类型为CustomProcessing的元素,让后将它们替换为stack.components里对应的实例。这样既保证了获取的是实例,又保证了实例类型是CustomProcessing。
代码如下:
// 获取VolumeStack
var stack = VolumeManager.instance.stack;

// 获取volumeStack中所有CustomPostProcessing实例
var customPostProcessings = VolumeManager.instance.baseComponentTypeArray
    .Where(t => t.IsSubclassOf(typeof(CustomPostProcessing))) // 筛选出volumeStack中的CustomPostProcessing类型元素 非论是否在Volume中 非论是否激活
    .Select(t => stack.GetComponent(t) as CustomPostProcessing) // 将类型元素转换为实例
    .ToList(); // 转换为List具体代码

颠末上述分析,我们首先需要在Create()里抓取VolumeComponent的所有派生类实例。
CustomPostProcessingFeature.cs
public override void Create() {
        // 获取VolumeStack
        var stack = VolumeManager.instance.stack;

        // 获取所有的CustomPostProcessing实例
        mCustomPostProcessings = VolumeManager.instance.baseComponentTypeArray
                .Where(t => t.IsSubclassOf(typeof(CustomPostProcessing))) // 筛选出VolumeComponent派生类类型中所有的CustomPostProcessing类型元素 非论是否在Volume中 非论是否激活
                .Select(t => stack.GetComponent(t) as CustomPostProcessing) // 将类型元素转换为实例
                .ToList(); // 转换为List
}
下一步,对抓取到的所有CustomPostProcessing实例按照注入点分类,而且按照在注入点的挨次进行排序。再把对应CPPs实例传递给RenderPass实例,依据注入点分类通过renderPassEvent设置衬着时间。
        // 初始化分歧插入点的render pass
        // 找到在透明物和天空后衬着的CustomPostProcessing
        var afterOpaqueAndSkyCPPs = mCustomPostProcessings
                .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterOpaqueAndSky) // 筛选出所有CustomPostProcessing类中注入点为透明物体和天空后的实例
                .OrderBy(c => c.OrderInInjectionPoint) // 按照挨次排序
                .ToList(); // 转换为List
        // 创建CustomPostProcessingPass类
        mAfterOpaqueAndSkyPass = new CustomPostProcessingPass(”Custom PostProcess after Skybox”, afterOpaqueAndSkyCPPs);
        // 设置Pass执行时间
        mAfterOpaqueAndSkyPass.renderPassEvent = RenderPassEvent.AfterRenderingSkybox;

        var beforePostProcessingCPPs = mCustomPostProcessings
                .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.BeforePostProcess)
                .OrderBy(c => c.OrderInInjectionPoint)
                .ToList();
        mBeforePostProcessPass = new CustomPostProcessingPass(”Custom PostProcess before PostProcess”, beforePostProcessingCPPs);
        mBeforePostProcessPass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;

        var afterPostProcessCPPs = mCustomPostProcessings
                .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterPostProcess)
                .OrderBy(c => c.OrderInInjectionPoint)
                .ToList();
        mAfterPostProcessPass = new CustomPostProcessingPass(”Custom PostProcess after PostProcessing”, afterPostProcessCPPs);
        mAfterPostProcessPass.renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing;
抓取完CustomPostProcessing实例后,我们需要在Dispose里全部释放他们。
CustomPostProcessingFeature.cs
protected override void Dispose(bool disposing) {
        base.Dispose(disposing);
        if (disposing && mCustomPostProcessings != null) {
                foreach (var item in mCustomPostProcessings) {
                        item.Dispose();
                }
        }
}注入Pass

前面提到,通过VolumeManager抓取是所有VolumeComponent的派生类,不管是否在Volume中,所以我们还需要在RenderPass中判断杂交的CustomPostProcessing是否是激活状态。
CustomPostProcessing.cs
// 获取active的CPPs下标,并返回是否存在有效组件
public bool SetupCustomPostProcessing() {
        mActiveCustomPostProcessingIndex.Clear();
        for (int i = 0; i < mCustomPostProcessings.Count; i++) {
                mCustomPostProcessings.Setup();
                if (mCustomPostProcessings.IsActive()) {
                        mActiveCustomPostProcessingIndex.Add(i);
                }
        }

        return mActiveCustomPostProcessingIndex.Count != 0;
}
下面完成RenderFeature的第二个任务,将RenderPass EnqueuePass。它在AddRenderPasses函数中进行,我们将分歧的注入点的RenderPass注入到renderer中。
CustomRenderFeature.cs
// 当为每个摄像机设置一个衬着器时,调用此方式
// 将分歧注入点的RenderPass注入到renderer中
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) {
        // 当前衬着的游戏相机撑持后措置
        if (renderingData.cameraData.postProcessEnabled) {
                // 为每个render pass设置RT
                // 而且将pass列表加到renderer中
                if (mAfterOpaqueAndSkyPass.SetupCustomPostProcessing()) {
                        mAfterOpaqueAndSkyPass.ConfigureInput(ScriptableRenderPassInput.Color);
                        renderer.EnqueuePass(mAfterOpaqueAndSkyPass);
                }
               
                if (mBeforePostProcessPass.SetupCustomPostProcessing()) {
                        mBeforePostProcessPass.ConfigureInput(ScriptableRenderPassInput.Color);
                        renderer.EnqueuePass(mBeforePostProcessPass);
                }
               
                if (mAfterPostProcessPass.SetupCustomPostProcessing()) {
                        mAfterPostProcessPass.ConfigureInput(ScriptableRenderPassInput.Color);
                        renderer.EnqueuePass(mAfterPostProcessPass);
                }
        }
}
网上有些资料在这个函数里配置RenderPass的源RT和方针RT,具体来说使用类似RenderPass.Setup(renderer.cameraColorTargetHandle, renderer.cameraColorTargetHandle)的方式,但是这在URP14.0中会报错,提示renderer.cameraColorTargetHandle只能在ScriptableRenderPass子类里调用。具体细节可以查看最后的参考连接。

下面再讨论一种错误做法。转到RenderPass中,在OnCameraSetup重载函数中设置当前RenderPass的源RT和方针RT,它将在相机衬着前被调用。
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) {
        RenderTextureDescriptor blitTargetDescriptor = renderingData.cameraData.cameraTargetDescriptor;
        blitTargetDescriptor.depthBufferBits = 0;

        var renderer = renderingData.cameraData.renderer;

        // 源RT固定为相机的颜色RT ”_CameraColorAttachmentA”
        mSourceRT = renderer.cameraColorTargetHandle;
        mDesRT = renderer.cameraColorTargetHandle;
}这会带来什么错误呢?如果注入点非后措置后,则相机源和方针应该为_CameraColorAttachmentA。但是注入点在后措置后时,RP会进行一次FinalBlit,最终相机的源和方针应该为_CameraColorAttachmentB。不外这不需要判断,直接在Execute里面赋值即可。(按理来说可能消耗会更高,但是我不知道怎样在有finalBlit的情况下提前获取_CameraColorAttachmentB)

如图,这是只有注入点在衬着天空盒后的Frame Debugger。它没有Final Blit,所以可以把RT设置为_CameraColorAttachmentA。



然后,这是添加注入点在后措置后的Frame Debugger。它生出了Final Blit,而且Final Blit的输入源RT为_CameraColorAttachmentB。



所以这个注入点在后措置后的后措置RT应该为_CameraColorAttchmentB。


RenderPass衬着

下面完成最后一步,即在CustomPostProcessingPass的Execute函数里填写具体的衬着代码。
主要流程如下

[*]声明临时纹理
[*]设置源衬着纹理mSourceRT方针衬着纹理mDesRT为衬着数据的相机颜色方针措置。(区分有无finalBlit)
[*]如果只有一个后措置效果,则直接将这个后措置效果从mSourceRT衬着到mTempRT0。
[*]如果有多个后措置效果,则逐后措置的在mTempRT0和mTempRT1之间衬着。由于每次循环结束交换它们,所以最终纹理依然存在mTempRT0。
[*]使用Blitter.BlitCameraTexture函数将mTempRT0中的成果复制到方针衬着纹理mDesRT中。

完整代码如下:
// 实现衬着逻辑
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) {
        // 初始化commandbuffer
        var cmd = CommandBufferPool.Get(mProfilerTag);
        context.ExecuteCommandBuffer(cmd);
        cmd.Clear();

        // 获取相机Descriptor
        var descriptor = renderingData.cameraData.cameraTargetDescriptor;
        descriptor.msaaSamples = 1;
        descriptor.depthBufferBits = 0;

        // 初始化临时RT
        bool rt1Used = false;

        // 设置源和方针RT为本次衬着的RT 在Execute里进行 特殊措置后措置后注入点
        mDesRT = renderingData.cameraData.renderer.cameraColorTargetHandle;
        mSourceRT = renderingData.cameraData.renderer.cameraColorTargetHandle;

        // 声明temp0临时纹理
        // cmd.GetTemporaryRT(Shader.PropertyToID(mTempRT0.name), descriptor);
        // mTempRT0 = RTHandles.Alloc(mTempRT0.name);
        RenderingUtils.ReAllocateIfNeeded(ref mTempRT0, descriptor, name: mTempRT0Name);

        // 执行每个组件的Render方式
        if (mActiveCustomPostProcessingIndex.Count == 1) {
                int index = mActiveCustomPostProcessingIndex;
                using (new ProfilingScope(cmd, mProfilingSamplers)) {
                        mCustomPostProcessings.Render(cmd, ref renderingData, mSourceRT, mTempRT0);
                }
        }
        else {
                // 如果有多个组件,则在两个RT上来回bilt
                RenderingUtils.ReAllocateIfNeeded(ref mTempRT1, descriptor, name: mTempRT1Name);
                rt1Used = true;
                Blit(cmd, mSourceRT, mTempRT0);
                for (int i = 0; i < mActiveCustomPostProcessingIndex.Count; i++) {
                        int index = mActiveCustomPostProcessingIndex;
                        var customProcessing = mCustomPostProcessings;
                        using (new ProfilingScope(cmd, mProfilingSamplers)) {
                                customProcessing.Render(cmd, ref renderingData, mTempRT0, mTempRT1);
                        }

                        CoreUtils.Swap(ref mTempRT0, ref mTempRT1);
                }
        }
       
        Blitter.BlitCameraTexture(cmd, mTempRT0, mDesRT);

        // 释放
        cmd.ReleaseTemporaryRT(Shader.PropertyToID(mTempRT0.name));
        if (rt1Used) cmd.ReleaseTemporaryRT(Shader.PropertyToID(mTempRT1.name));

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
}例子:ColorBlit

在这之前,注意把CustomPostProcessingFeature添加到Renderer中。



下面把官方文档里的ColorBlit例子改为我们CPP系统能用的脚本。
ColorBlit.cs

C#很简单,首先创建一个挂载ColorBlit.shader的材质,然后在Render()函数中调用Blit从sourceRT调用材质的Shader衬着给destinationRT。
需要注意的是,我们的RenderFeature抓取的CusomProcessing是全部基于VolumenComponent的派生类,而不是当前场景Global Volume组件里的后措置。所以为了RenderPass能够判断当前后措置是否有效,最好给每个后措置IsActive()除了判断材质非空外加上一个属性的约束。
using CPP;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace CPP.EFFECTS{
   
    public class ColorBlit : CustomPostProcessing{
      public ClampedFloatParameter intensity = new(0.0f, 0.0f, 2.0f);

      private Material mMaterial;
      private const string mShaderName = ”Hidden/PostProcess/ColorBlit”;

      public override bool IsActive() => mMaterial != null && intensity.value > 0;

      public override CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterOpaqueAndSky;
      public override int OrderInInjectionPoint => 0;

      public override void Setup() {
            if (mMaterial == null)
                mMaterial = CoreUtils.CreateEngineMaterial(mShaderName);
      }

      public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RTHandle source, RTHandle destination) {
            if (mMaterial == null) return;
            mMaterial.SetFloat(”_Intensity”, intensity.value);
            cmd.Blit(source, destination, mMaterial, 0);
      }

      public override void Dispose(bool disposing) {
            base.Dispose(disposing);
            CoreUtils.Destroy(mMaterial);
      }
    }
}PostProcessing.hlsl

在官方文档中,对相机RT的采样用的是对_BlitTexture的SAMPLE_TEXTURE2D_X。_BlitTexture在Blit.hlsl库中声明。但是使用Blit函数衬着到的相机纹理是_MainTex(不知道是不是API调用的原因)。而且,_MainTex必需在Properties中定义。
这里仿照官方提供的Blit.hlsl,将公用的声明和常用函数放到Common/PostProcessing.hlsl中。
#ifndef POSTPROCESSING_INCLUDED
#define POSTPROCESSING_INCLUDED

#include ”Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl”

TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);

TEXTURE2D(_CameraDepthTexture);
SAMPLER(sampler_CameraDepthTexture);

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

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

half4 SampleSourceTexture(float2 uv) {
    return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
}

half4 SampleSourceTexture(Varyings input) {
    return SampleSourceTexture(input.uv);
}

Varyings Vert(Attributes input) {
    Varyings output = (Varyings)0;
    // 分配instance id
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    output.vertex = vertexInput.positionCS;
    output.uv = input.uv;

    return output;
}

#endifColorBlit.shader

Shader ”Hidden/PostProcess/ColorBlit” {
    Properties {
      // 显式声明出来_MainTex
      _MainTex (”Base (RGB)”, 2D) = ”white” {}
    }
    SubShader {

      Tags {
            ”RenderType”=”Opaque”
            ”RenderPipeline” = ”UniversalPipeline”
      }
      LOD 200
      Pass {
            Name ”ColorBlitPass”

            HLSLPROGRAM
            #include ”Common/PostProcessing.hlsl”

            #pragma vertex Vert
            #pragma fragment frag

            float _Intensity;

            half4 frag(Varyings input) : SV_Target {
                float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
                return color * float4(0, _Intensity, 0, 1);
            }
            ENDHLSL
      }
    }
}例子:ColorAdjustment

最基本的色彩调整后措置。
ColorAdjustment.cs

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

namespace CPP.EFFECTS{
   
    public class ColorAdjustments : CustomPostProcessing{
      #region Parameters Define

      // 后曝光
      public FloatParameter postExposure = new FloatParameter(0.0f);

      // 对比度
      public ClampedFloatParameter contrast = new ClampedFloatParameter(0.0f, 0.0f, 100.0f);

      // 颜色滤镜
      public ColorParameter colorFilter = new ColorParameter(Color.white, true, false, false);

      // 色相偏移
      public ClampedFloatParameter hueShift = new ClampedFloatParameter(0.0f, -180.0f, 180.0f);

      // 饱和度
      public ClampedFloatParameter saturation = new ClampedFloatParameter(0.0f, -100.0f, 100.0f);

      #endregion

      private Material mMaterial;
      private const string mShaderName = ”Hidden/PostProcess/ColorAdjusments”;

      #region Active State Check

      public override bool IsActive() =>
            mMaterial != null && (IsPostExposureActive() || IsContrastActive() || IsContrastActive() || IsColorFilterActive() || IsHueShiftActive() || IsSaturationActive());

      private bool IsPostExposureActive() => postExposure.value != 0.0f;
      private bool IsContrastActive() => contrast.value != 0.0f;
      private bool IsColorFilterActive() => colorFilter.value != Color.white;
      private bool IsHueShiftActive() => hueShift.value != 0.0f;
      private bool IsSaturationActive() => saturation.value != 0.0f;

      #endregion

      public override CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterPostProcess;
      public override int OrderInInjectionPoint => 99;

      private int mColorAdjustmentsId = Shader.PropertyToID(”_ColorAdjustments”),
            mColorFilterId = Shader.PropertyToID(”_ColorFilter”);

      private const string mExposureKeyword = ”EXPOSURE”,
            mContrastKeyword = ”CONTRAST”,
            mHueShiftKeyword = ”HUE_SHIFT”,
            mSaturationKeyword = ”SATURATION”,
            mColorFilterKeyword = ”COLOR_FILTER”;

      public override void Setup() {
            if (mMaterial == null)
                mMaterial = CoreUtils.CreateEngineMaterial(mShaderName);
      }

      public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RTHandle source, RTHandle destination) {
            if (mMaterial == null) return;
            Vector4 colorAdjustmentsVector4 = new Vector4(
                Mathf.Pow(2f, postExposure.value), // 曝光度 曝光单元是2的幂次
                contrast.value * 0.01f + 1f, // 对比度 将范围从[-100, 100]映射到
                hueShift.value * (1.0f / 360.0f), // 色相偏移 将范围从[-180, 180]转换到[-0.5, 0.5]
                saturation.value * 0.01f + 1.0f); // 饱和度 将范围从[-100, 100]转换到
            mMaterial.SetVector(mColorAdjustmentsId, colorAdjustmentsVector4);
            mMaterial.SetColor(mColorFilterId, colorFilter.value);

            // 按照是否激活对应调整设置keyword
            SetKeyWord(mExposureKeyword, IsPostExposureActive());
            SetKeyWord(mContrastKeyword, IsContrastActive());
            SetKeyWord(mHueShiftKeyword, IsHueShiftActive());
            SetKeyWord(mSaturationKeyword, IsSaturationActive());
            SetKeyWord(mColorFilterKeyword, IsColorFilterActive());

            cmd.Blit(source, destination, mMaterial, 0);
      }

      private void SetKeyWord(string keyword, bool enabled = true) {
            if (enabled) mMaterial.EnableKeyword(keyword);
            else mMaterial.DisableKeyword(keyword);
      }

      public override void Dispose(bool disposing) {
            base.Dispose(disposing);
            CoreUtils.Destroy(mMaterial);
      }
    }
}ColorAdjustment.shader

Shader ”Hidden/PostProcess/ColorAdjusments” {
    Properties {
       _MainTex (”Base (RGB)”, 2D) = ”white” {}
    }

    SubShader {
      Tags {
            ”RenderType” = ”Opaque”
            ”RenderPipeline” = ”UniversalPipeline”
      }
      LOD 200
      Pass {
            name ”ColorAdjustmentPass”

            HLSLPROGRAM
            #include ”Common/PostProcessing.hlsl”
            #include ”Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl”

            #pragma vertex Vert
            #pragma fragment frag

            #pragma shader_feature EXPOSURE
            #pragma shader_feature CONTRAST
            #pragma shader_feature COLOR_FILTER
            #pragma shader_feature HUE_SHIFT
            #pragma shader_feature SATURATION

            // 曝光度 对比度 色相偏移 饱和度
            float4 _ColorAdjustments;
            float4 _ColorFilter;

            // 后曝光
            half3 ColorAdjustmentExposure(half3 color) {
                return color * _ColorAdjustments.x;
            }

            // 对比度
            half3 ColorAdjustmentContrast(float3 color) {
                // 为了更好的效果 将颜色从线性空间转换到logC空间(因为要取美术中灰)
                color = LinearToLogC(color);
                // 从颜色中减去均匀的中间灰度,然后通过对比度进行缩放,然后在中间添加中间灰度
                color = (color - ACEScc_MIDGRAY) * _ColorAdjustments.y + ACEScc_MIDGRAY;
                return LogCToLinear(color);
            }

            // 颜色滤镜
            half3 ColorAdjustmentColorFilter(float3 color) {
                color = SRGBToLinear(color);
                color = color * _ColorFilter.rgb;
                return color;
            }

            // 色相偏移
            half3 ColorAdjustmentHueShift(half3 color) {
                // 将颜色格式从rgb转换为hsv
                color = RgbToHsv(color);
                // 将色相偏移添加到h
                float hue = color.x + _ColorAdjustments.z;
                // 如果色相超出范围 将其截断
                color.x = RotateHue(hue, 0.0, 1.0);
                // 将颜色格式从hsv转换为rgb
                return HsvToRgb(color);
            }

            // 饱和度
            half3 ColorAdjustmentSaturation(half3 color) {
                // 获取颜色的亮度
                float luminance = Luminance(color);
                // 从颜色中减去亮度,然后通过饱和度进行缩放,然后在中间添加亮度
                return (color - luminance) * _ColorAdjustments.w + luminance;
            }

            half3 ColorAdjustment(half3 color) {
                // 防止颜色值过大的潜在隐患
                color = min(color, 60.0);
                // 后曝光
                #ifdef EXPOSURE
                color = ColorAdjustmentExposure(color);
                #endif
                // 对比度
                #ifdef CONTRAST
                color = ColorAdjustmentContrast(color);
                #endif
                // 颜色滤镜
                #ifdef COLOR_FILTER
                color = ColorAdjustmentColorFilter(color);
                #endif
                // 当对比度增加时,会导致颜色分量变暗,在这之后将颜色钳位
                color = max(color, 0.0);
                // 色相偏移
                #ifdef HUE_SHIFT
                color = ColorAdjustmentHueShift(color);
                #endif
                // 饱和度
                #ifdef SATURATION
                color = ColorAdjustmentSaturation(color);
                #endif
                // 当饱和度增加时,可能发生负数,在这之后将颜色钳位
                return max(color, 0.0);
                return color;
            }

            half4 frag(Varyings input) : SV_Target {
                half3 color = SampleSourceTexture(input).xyz;
                half3 finalCol = ColorAdjustment(color);
                return half4(finalCol, 1.0);
            }
            ENDHLSL
      }
    }
}其他

首先,编纂模式下。切换场景,Scene和Game视图相机里的后措置并不会刷新,此时先封锁Renderer的CustomPostProcessingFeature组件再打开,后措置就刷新了。
但是,在播放模式下,通过场景切换脚本,后措置还是可以刷新的。
目前还没找到编纂模式下直接刷新的方式,以后找到了再更新一下。

此外,衬着的性能也没怎么优化,例如Blit替换为全屏三角形等,后面再更新。

先把这套用一用再更新一些细节。
更新:绘制法式化全屏三角形

在之前的衬着方式中,我们使用带材质参数的Blit函数,通过查看frame debugger,可以明确它实际上绘制了一个四边形。





然而,我们使用不带有材质参数的Blit时,它会调用CoreBlit.shader,而且只绘制一个三角形。这使得顶点和索引数量减少,带来的消耗必定更少了。


所以我们的目的就是放弃使用带材质参数的Blit函数,而是自定义一个函数来使用绘制法式化三角形的方式进行衬着。

在后措置基类中,新建一个Draw函数,它接收CommandBuffer,源、方针RT以及调用pass索引。绘制法式如下:将GPU端的”_SourceTexture”纹理设置为sourceRT,将RenderTarget设置为destination,绘制法式化三角形。
CustomPostProcessing.cs
        // 材质声明
        protected Material mMaterial = null;
        private Material mCopyMaterial = null;

        private const string mCopyShaderName = ”Hidden/PostProcess/PostProcessCopy”;
        protected override void OnEnable() {
                base.OnEnable();
                if (mCopyMaterial == null) {
                        mCopyMaterial = CoreUtils.CreateEngineMaterial(mCopyShaderName);
                }
               
        #region Draw Function

        private int mSourceTextureId = Shader.PropertyToID(”_SourceTexture”);

        public virtual void Draw(CommandBuffer cmd, in RTHandle source, in RTHandle destination, int pass = -1) {
                // 将GPU端_SourceTexture设置为source
                cmd.SetGlobalTexture(mSourceTextureId, source);
                // 将RT设置为destination 不关心初始状态(直接填充) 需要存储
                cmd.SetRenderTarget(destination, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
                // 绘制法式化三角形
                if (pass == -1 || mMaterial == null)
                        cmd.DrawProcedural(Matrix4x4.identity, mCopyMaterial, 0, MeshTopology.Triangles, 3);
                else
                        cmd.DrawProcedural(Matrix4x4.identity, mMaterial, pass, MeshTopology.Triangles, 3);
        }

        #endregion这个三角形应该是覆盖整个屏幕的大三角形,则此时屏幕xy只占了三角形xy的一半,而且索引挨次按顺时针。屏幕坐标范围为 [-1,1] ,所以这个三角形的(裁剪)坐标分袂为 (-1, -1),(-1,3),(3,-1) 。UV范围为 [-1,1] ,所以这个三角形的UV坐标(屏幕)分袂为 (0,0), (0, 2), (2, 0) 。




然后更新后措置shader公用基础Shader,顶点着色器中,我们通过顶点索引判断裁剪空间顶点坐标和屏幕UV。这里仿照VertexPositionInputs的方式写了一个转换函数,用于便利可能呈现的自定义顶点着色器情况。
PostProcessing.hlsl
struct ScreenSpaceData {
    float4 positionCS;
    float2 uv;
};

ScreenSpaceData GetScreenSpaceData(uint vertexID : SV_VertexID) {
    ScreenSpaceData output;
    // 按照id判断三角形顶点的坐标
    // 坐标挨次为(-1, -1) (-1, 3) (3, -1)
    output.positionCS = float4(vertexID <= 1 ? -1.0 : 3.0, vertexID == 1 ? 3.0 : -1.0, 0.0, 1.0);
    output.uv = float2(vertexID <= 1 ? 0.0 : 2.0, vertexID == 1 ? 2.0 : 0.0);
    // 分歧API可能会发生倒置的情况 进行判断
    if (_ProjectionParams.x < 0.0) {
      output.uv.y = 1.0 - output.uv.y;
    }
    return output;
}

Varyings Vert(uint vertexID : SV_VertexID) {
    Varyings output;
    ScreenSpaceData ssData = GetScreenSpaceData(vertexID);
    output.positionCS = ssData.positionCS;
    output.uv = ssData.uv;
    return output;
}
然后再定义一些基本的功能。
#include ”Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl”
#include ”Packages/com.unity.render-pipelines.universal/ShaderLibrary/UnityInput.hlsl”

TEXTURE2D(_SourceTexture);
SAMPLER(sampler_SourceTexture);

TEXTURE2D(_CameraDepthTexture);
SAMPLER(sampler_CameraDepthTexture);

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

half4 GetSource(float2 uv) {
    return SAMPLE_TEXTURE2D(_SourceTexture, sampler_SourceTexture, uv);
}

half4 GetSource(Varyings input) {
    return GetSource(input.uv);
}

float SampleDepth(float2 uv) {
    #if defined(UNITY_STEREO_INSTANCING_ENABLED) || defined(UNITY_STEREO_MULTIVIEW_ENABLED)
    return SAMPLE_TEXTURE2D_ARRAY(_CameraDepthTexture, sampler_CameraDepthTexture, uv, unity_StereoEyeIndex).r;
    #else
    return SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, uv);
    #endif
}

float SampleDepth(Varyings input) {
    return SampleDepth(input.uv);
}
注意,此时的后措置Shader需要封锁深度写入,封锁剔除,即:
      ZWrite Off
      Cull Off
然后再写一个用了复制color buffer的Shader。
PostProcessCopy.shader
Shader ”Hidden/PostProcess/PostProcessCopy” {
    SubShader {
      Tags {
            ”RenderType”=”Opaque”
            ”RenderPipeline” = ”UniversalPipeline”
      }
      LOD 200
      ZWrite Off
      Cull Off

      HLSLINCLUDE
      #include ”PostProcessing.hlsl”
      ENDHLSL

      Pass {
            Name ”CopyPass”

            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment frag;

            half4 frag(Varyings input) : SV_Target {
                half4 color = GetSource(input);
                return half4(color.rgb, 1.0);
            }
            ENDHLSL
      }
    }
}
最后,把自定义后措置的C#脚本中的Blit函数通通换成Draw函数。
// cmd.Blit(source, destination, mMaterial, 0);
Draw(cmd, source, destination, 0);更新

2023/8/23改了临时纹理的声明和释放逻辑。
具体来说,在CustomPostProcessingPass的OnCameraSetup()函数中分配本次主Pass的中间纹理,而且分袂该主Pass下的CustomPostProcessing实例的OnCameraSetup()函数,它们各自在此中分配本身的临时纹理。纹理的分配使用RenderingUtils.ReAllocateIfNeeded函数,猜测它不像CommandBuffer加命令那样可以让GPU按挨次执行分配纹理和之后的衬着逻辑,所以只能在正式衬着前将纹理全部门配,最后在统一释放。这也是Unity文档和我搜索资料的写法,有没有更好的方式我暂时没有看到。
最重要的是释放纹理,否则会直接死机。参考Unity文档的写法,在CustomPostProcessingFeature的Dispose()函数中,首先调用各个主Pass实例和CustomPostProcessing实例的Dispose()函数,它们在此中分袂释放各自纹理。使用RTHandle.Release()函数。

2023/8/23添加更多后措置效果,部门参考毛星云前辈的X-PostProcessing库。
参考

pamisu: 为了更好用的后措置——扩展URP后措置踩坑记录
tatsuya-kosuda: URPCustomPostProcessing
Adapt URP to use RTHandles
(URP 13.1.8) Proper RTHandle usage in a Renderer Feature
How to Blit in URP - Documentation & Unity blog post on every Blit function
页: [1]
查看完整版本: Unity URP14.0 自定义后措置系统