美文网首页常用查询Unity进阶技术
[Unity]为了更好用的后处理——扩展URP后处理踩坑记录

[Unity]为了更好用的后处理——扩展URP后处理踩坑记录

作者: pamisu | 来源:发表于2021-03-01 22:37 被阅读0次
    扩展URP后处理踩坑记录

    在目前(10.2.2)版本,URP下的自定义后处理依然是通过Renderer Feature来实现,比起以前的PPSV2麻烦了不少,看着隔壁HDRP的提供的自定义后处理组件,孩子都快馋哭了。既然官方暂时没有提供,那么就自己先造一个解馋,对标HDRP的自定义后处理,目标效果是只需简单继承,就能添加自定义后处理组件。实现过程中遇到了不少问题,但对URP的源码有了初步的了解。

    效果 效果 自定义Volume组件

    实(cai)现(keng)过程:

    • 封装自定义后处理组件基类,负责提供渲染方法、插入点设置等,并显示组件到Volume的Add Override菜单中。
    • 实现后处理Renderer Feature,获取所有自定义组件,根据它们的插入点分配到不同的Render Pass。
    • 实现后处理Render Pass,管理并调用自定义组件的渲染方法。
    • 适配2D场景下的自定义后处理。

    类关系:

    后处理组件基类

    首先要确保自定义的后处理组件能显示在Volume的Add Override菜单中,阅读源码可知,让组件出现在这个菜单中并没有什么神奇之处,只需继承VolumeComponent类并且添加VolumeComponentMenu特性即可,而VolumeComponent本质上是一个ScriptableObject。

    Volueme的Add Override菜单 Bloom.cs

    那么就可以定义一个CustomVolumeComponent作为我们所有自定义后处理组件的基类:

    CustomVolumeComponent.cs

    public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
    {
        ...
    }
    

    通常希望后处理在渲染过程中能有不同的插入点,这里先提供三个插入点,天空渲染之后、内置后处理之前、内置后处理之后:

    /// 后处理插入位置
    public enum CustomPostProcessInjectionPoint
    {
        AfterOpaqueAndSky, BeforePostProcess, AfterPostProcess
    }
    

    在同一个插入点可能会存在多个后处理组件,所以还需要一个排序编号来确定谁先谁后:

    public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
    {
        /// 在InjectionPoint中的渲染顺序
        public virtual int OrderInPass => 0;
    
        /// 插入位置
        public virtual CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterPostProcess;
    }
    

    然后定义一个初始化方法与渲染方法,渲染方法中,将CommandBuffer、RenderingData、渲染源与目标都传入:

    /// 初始化,将在RenderPass加入队列时调用
    public abstract void Setup();
    
    /// 执行渲染
    public abstract void Render(CommandBuffer cmd, refRenderingData renderingData, RenderTargetIdentifiersource, RenderTargetIdentifier destination);
    
    #region IPostProcessComponent
    /// 返回当前组件是否处于激活状态
    public abstract bool IsActive();
    
    public virtual bool IsTileCompatible() => false;
    #endregion
    

    最后是IDisposable接口的方法,由于渲染可能需要临时生成材质,在这里将它们释放:

    #region IDisposable
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    
    /// 释放资源
    public virtual void Dispose(bool disposing) {}
    #endregion
    

    后处理组件基类就完成了,随便写个类继承一下它,Volume菜单中已经可以看到组件了:

    TestVolumeComponent.cs

    [VolumeComponentMenu("Custom Post-processing/Test Test Test!")]
    public class TestVolumeComponent : CustomVolumeComponent
    {
    
        public ClampedFloatParameter foo = new ClampedFloatParameter(.5f, 0, 1f);
    
        public override bool IsActive()
        {
        }
    
        public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RenderTargetIdentifier source, RenderTargetIdentifier destination)
        {
        }
    
        public override void Setup()
        {
        }
    }
    
    可以看到测试组件

    Renderer Feature与Render Pass

    好看吗?就让你们看看,不卖。URP并不会调用自定义组件的渲染方法(毕竟本来就没有),这部分需要自己实现,所以还是得祭出Renderer Feature。

    官方示例中,一个Renderer Feature对应一个自定义后处理效果,各个后处理相互独立,好处是灵活自由易调整;坏处也在此,相互独立意味着每个效果都可能要开临时RT,耗费资源比双缓冲互换要多,并且Renderer Feature在Renderer Data下,相对于场景中的Volume来说在代码中调用起来反而没那么方便。

    那么这里的思路便是将所有相同插入点的后处理组件放到同一个Render Pass下渲染,这样就可以做到双缓冲交换,又保持了Volume的优势。

    获取自定义后处理组件

    先来写Render Pass,在里面定义好刚才写的自定义组件列表、Profiler所需变量,还有渲染源、目标与可能会用到的临时RT:

    CustomPostProcessRenderPass.cs

    public class CustomPostProcessRenderPass : ScriptableRenderPass
    {
        List<CustomVolumeComponent> volumeComponents;   // 所有自定义后处理组件
        List<int> activeComponents; // 当前可用的组件下标
    
        string profilerTag;
        List<ProfilingSampler> profilingSamplers; // 每个组件对应的ProfilingSampler
    
        RenderTargetHandle source;  // 当前源与目标
        RenderTargetHandle destination;
        RenderTargetHandle tempRT0; // 临时RT
        RenderTargetHandle tempRT1;
    
        /// <param name="profilerTag">Profiler标识</param>
        /// <param name="volumeComponents">属于该RendererPass的后处理组件</param>
        public CustomPostProcessRenderPass(string profilerTag, List<CustomVolumeComponent> volumeComponents)
        {
            this.profilerTag = profilerTag;
            this.volumeComponents = volumeComponents;
            activeComponents = new List<int>(volumeComponents.Count);
            profilingSamplers = volumeComponents.Select(c => new ProfilingSampler(c.ToString())).ToList();
    
            tempRT0.Init("_TemporaryRenderTexture0");
            tempRT1.Init("_TemporaryRenderTexture1");
        }
    
        ...
    }
    

    构造方法中接收这个Render Pass的Profiler标识与后处理组件列表,以每个组件的名称作为它们渲染时的Profiler标识。

    Renderer Feature中,定义三个插入点对应的Render Pass,以及所有自定义组件列表,还有一个用于后处理之后的RenderTargetHandle,这个变量之后会介绍:

    CustomPostProcessRendererFeature.cs

    /// <summary>
    /// 自定义后处理Renderer Feature
    /// </summary>
    public class CustomPostProcessRendererFeature : ScriptableRendererFeature
    {
        // 不同插入点的render pass
        CustomPostProcessRenderPass afterOpaqueAndSky;
        CustomPostProcessRenderPass beforePostProcess;
        CustomPostProcessRenderPass afterPostProcess;
    
        // 所有自定义的VolumeComponent
        List<CustomVolumeComponent> components;
    
        // 用于after PostProcess的render target
        RenderTargetHandle afterPostProcessTexture;
        ...
    }
    

    那么要如何拿到所有自定义后处理组件,这些组件是一开始就存在,还是必须要从菜单中添加之后才存在?暂且蒙在鼓里。
    通常可以通过VolumeManager.instance.stack.GetComponent方法来获取到VolumeComponent,那么去看看VolumeStack的源码:

    VolumeStack.cs

    它用一个字典存放了所有的VolumeComponent,并且在Reload方法中根据baseTypes参数创建了它们,遗憾的是这是个internal变量。再看VolumeMangager中,CreateStack方法与CheckStack方法对Reload方法进行了调用:

    VolumeManager.cs

    ReloadBaseTypes中对baseComponentTypes进行了赋值,可以发现它包含了所有VolumeComponent的非抽象子类类型:

    VolumeManager.cs

    看到这里可以得出结论,所有后处理组件的实例一开始便存在于默认的VolumeStack中,不管它们是否从菜单中添加。并且万幸的是,baseComponentTypes是一个public变量,这样就不需要通过粗暴手段来获取了。

    接着编写CustomPostProcessRendererFeature的Create方法,在这里获取到所有的自定义后处理组件,并且将它们根据各自的插入点分类并排好序,放入到对应的Render Pass中:

    CustomPostProcessRendererFeature.cs

    // 初始化Feature资源,每当序列化发生时都会调用
    public override void Create()
    {
        // 从VolumeManager获取所有自定义的VolumeComponent
        var stack = VolumeManager.instance.stack;
        components = VolumeManager.instance.baseComponentTypes
            .Where(t => t.IsSubclassOf(typeof(CustomVolumeComponent)) && stack.GetComponent(t) != null)
            .Select(t => stack.GetComponent(t) as CustomVolumeComponent)
            .ToList();
    
        // 初始化不同插入点的render pass
        var afterOpaqueAndSkyComponents = components
            .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterOpaqueAndSky)
            .OrderBy(c => c.OrderInPass)
            .ToList();
        afterOpaqueAndSky = new CustomPostProcessRenderPass("Custom PostProcess after Opaque and Sky", afterOpaqueAndSkyComponents);
        afterOpaqueAndSky.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
    
        var beforePostProcessComponents = components
            .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.BeforePostProcess)
            .OrderBy(c => c.OrderInPass)
            .ToList();
        beforePostProcess = new CustomPostProcessRenderPass("Custom PostProcess before PostProcess", beforePostProcessComponents);
        beforePostProcess.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    
        var afterPostProcessComponents = components
            .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterPostProcess)
            .OrderBy(c => c.OrderInPass)
            .ToList();
        afterPostProcess = new CustomPostProcessRenderPass("Custom PostProcess after PostProcess", afterPostProcessComponents);
        // 为了确保输入为_AfterPostProcessTexture,这里插入到AfterRendering而不是AfterRenderingPostProcessing
        afterPostProcess.renderPassEvent = RenderPassEvent.AfterRendering;
    
        // 初始化用于after PostProcess的render target
        afterPostProcessTexture.Init("_AfterPostProcessTexture");
    }
    

    依次设置每个Render Pass的renderPassEvent,对于AfterPostProcess插入点,renderPassEvent为AfterRendering而不是AfterRenderingPostProcessing,原因是如果插入到AfterRenderingPostProcessing,无法确保渲染输入源为_AfterPostProcessTexture,查看两种情况下的帧调试器:

    插入到AfterRenderingPostProcess:

    插入到AfterRenderingPostProcess

    插入到AfterRendering:

    插入到AfterRendering

    对比二者,可以发现插入点之前的Render PostProcessing Effects的RenderTarget会不一样,并且在插入到AfterRendering的情况下,还会多出一个FinalBlit,而FinalBlit的输入源正是_AfterPostProcessTexture

    FinalBlit

    所以定义afterPostProcessTexture变量的目的便是为了能获取到_AfterPostProcessTexture,处理后再渲染到它。

    现在已经拿到了所有自定义后处理组件,下一步就可以开始初始化它们了。在这之前,记得重写Dispose方法做好资源释放,避免临时创建的材质漏得到处都是:

    CustomPostProcessRendererFeature.cs

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        if (disposing && components != null)
        {
            foreach(var item in components)
            {
                item.Dispose();
            }
        }
    }
    

    初始化

    上面在CustomPostProcessRenderPass中定义了一个变量activeComponents来存储当前可用的的后处理组件,在Render Feature的AddRenderPasses中,需要先判断Render Pass中是否有组件处于激活状态,如果没有一个组件激活,那么就没必要添加这个Render Pass,这里调用先前在组件中定义好的Setup方法初始化,随后调用IsActive判断其是否处于激活状态:

    CustomPostProcessRenderPass.cs

    /// <summary>
    /// 设置后处理组件
    /// </summary>
    /// <returns>是否存在有效组件</returns>
    public bool SetupComponents()
    {
        activeComponents.Clear();
        for (int i = 0; i < volumeComponents.Count; i++)
        {
            volumeComponents[i].Setup();
            if (volumeComponents[i].IsActive())
            {
                activeComponents.Add(i);
            }
        }
        return activeComponents.Count != 0;
    }
    

    当一个Render Pass中有处于激活状态的组件时,说明它行,很有精神,可以加入到队列中,那么需要设置它的渲染源与目标:

    CustomPostProcessRenderPass.cs

    /// <summary>
    /// 设置渲染源和渲染目标
    /// </summary>
    public void Setup(RenderTargetHandle source, RenderTargetHandle destination)
    {
        this.source = source;
        this.destination = destination;
    }
    

    之后在CustomPostProcessRendererFeature的AddRenderPasses方法中调用这两个方法,符合条件就将Render Pass添加:

    CustomPostProcessRendererFeature.cs

    // 你可以在这里将一个或多个render pass注入到renderer中。
    // 当为每个摄影机设置一次渲染器时,将调用此方法。
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (renderingData.cameraData.postProcessEnabled)
        {
            // 为每个render pass设置render target
            var source = new RenderTargetHandle(renderer.cameraColorTarget);
            if (afterOpaqueAndSky.SetupComponents())
            {
                afterOpaqueAndSky.Setup(source, source);
                renderer.EnqueuePass(afterOpaqueAndSky);
            }
            if (beforePostProcess.SetupComponents())
            {
                beforePostProcess.Setup(source, source);
                renderer.EnqueuePass(beforePostProcess);
            }
            if (afterPostProcess.SetupComponents())
            {
                // 如果下一个Pass是FinalBlit,则输入与输出均为_AfterPostProcessTexture
                source = renderingData.cameraData.resolveFinalTarget ? afterPostProcessTexture : source;
                afterPostProcess.Setup(source, source);
                renderer.EnqueuePass(afterPostProcess);
            }
        }
    }
    

    至此Renderer Feature类中的所有代码就写完了,接下来继续在Render Pass中实现渲染。

    执行渲染

    编写Render Pass中渲染执行的方法Execute

    // 你可以在这里实现渲染逻辑。
    // 使用<c>ScriptableRenderContext</c>来执行绘图命令或Command Buffer
    // https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.html
    // 你不需要手动调用ScriptableRenderContext.submit,渲染管线会在特定位置调用它。
    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get(profilerTag);
        context.ExecuteCommandBuffer(cmd);
        cmd.Clear();
    
        // 获取Descriptor
        var descriptor = renderingData.cameraData.cameraTargetDescriptor;
        descriptor.msaaSamples = 1;
        descriptor.depthBufferBits = 0;
    
        // 初始化临时RT
        RenderTargetIdentifier buff0, buff1;
        bool rt1Used = false;
        cmd.GetTemporaryRT(tempRT0.id, descriptor);
        buff0 = tempRT0.id;
        // 如果destination没有初始化,则需要获取RT,主要是destinaton为_AfterPostProcessTexture的情况
        if (destination != RenderTargetHandle.CameraTarget && !destination.HasInternalRenderTargetId())
        {
            cmd.GetTemporaryRT(destination.id, descriptor);
        }
    
        // 执行每个组件的Render方法
        // 如果只有一个组件,则直接source -> buff0
        if (activeComponents.Count == 1)
        {
            int index = activeComponents[0];
            using (new ProfilingScope(cmd, profilingSamplers[index]))
            {
                volumeComponents[index].Render(cmd, ref renderingData, source.Identifier(), buff0);
            }
        }
        else
        {
            // 如果有多个组件,则在两个RT上左右横跳
            cmd.GetTemporaryRT(tempRT1.id, descriptor);
            buff1 = tempRT1.id;
            rt1Used = true;
            Blit(cmd, source.Identifier(), buff0);
            for (int i = 0; i < activeComponents.Count; i++)
            {
                int index = activeComponents[i];
                var component = volumeComponents[index];
                using (new ProfilingScope(cmd, profilingSamplers[index]))
                {
                    component.Render(cmd, ref renderingData, buff0, buff1);
                }
                CoreUtils.Swap(ref buff0, ref buff1);
            }
        }
    
        // 最后blit到destination
        Blit(cmd, buff0, destination.Identifier());
    
        // 释放
        cmd.ReleaseTemporaryRT(tempRT0.id);
        if (rt1Used)
            cmd.ReleaseTemporaryRT(tempRT1.id);
    
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
    

    这里如果写得再简洁一些应该是可以只需要source和destination两个变量就行。需要注意某些情况下_AfterPostProcessTexture可能不存在,所以添加了手动获取RT的处理。如果不做这一步可能会出现Warning:

    找不到_AfterPostProcessTexture

    到这里Renderer Feature与Render Pass就全部编写完成,接下来使用一下看看实际效果。

    使用一下看看实际效果

    以官方示例中的卡通描边效果为例,先从把示例中的SobelFilter.shader窃过来,将Shader名称改为"Hidden/PostProcess/SobelFilter",然后编写后处理组件SobelFilter类:

    SobelFilter.cs

    [VolumeComponentMenu("Custom Post-processing/Sobel Filter")]
    public class SobelFilter : CustomVolumeComponent
    {
        public ClampedFloatParameter lineThickness = new ClampedFloatParameter(0f, .0005f, .0025f);
        public BoolParameter outLineOnly = new BoolParameter(false);
        public BoolParameter posterize = new BoolParameter(false);
        public IntParameter count = new IntParameter(6);
    
        Material material;
        const string shaderName = "Hidden/PostProcess/SobelFilter";
    
        public override CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterOpaqueAndSky;
    
        public override void Setup()
        {
            if (material == null)
                material = CoreUtils.CreateEngineMaterial(shaderName);
        }
    
        public override bool IsActive() => material != null && lineThickness.value > 0f;
    
        public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RenderTargetIdentifier source, RenderTargetIdentifier destination)
        {
            if (material == null)
                return;
    
            material.SetFloat("_Delta", lineThickness.value);
            material.SetInt("_PosterizationCount", count.value);
            if (outLineOnly.value)
                material.EnableKeyword("RAW_OUTLINE");
            else
                material.DisableKeyword("RAW_OUTLINE");
            if (posterize.value)
                material.EnableKeyword("POSTERIZE");
            else
                material.DisableKeyword("POSTERIZE");
    
            cmd.Blit(source, destination, material);
        }
    
        public override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
            CoreUtils.Destroy(material);
        }
    }
    

    使用CoreUtils.CreateEngineMaterial来从Shader创建材质,在Dispose中销毁它。Render方法中的cmd.Blit之后可以考虑换成CoreUtils.DrawFullScreen画全屏三角形。

    需要注意的是,IsActive方法最好要在组件无效时返回false,避免组件未激活时仍然执行了渲染,原因之前提到过,无论组件是否添加到Volume菜单中或是否勾选,VolumeManager总是会初始化所有的VolumeComponent。

    CoreUtils.CreateEngineMaterial(shaderName)内部依然是调用Shader.Find方法来查找Shader:

    CoreUtils.cs

    添加Renderer Feature:

    在Volume中添加并启用Sobel Filter:

    效果:

    继续加入更多后处理组件,这里使用连连看简单连了一个条纹故障和一个RGB分离故障,它们的插入点都是内置后处理之后:

    条纹故障 RGB分离

    效果:

    效果

    应用到2D

    由于目前2D Renderer还不支持Renderer Feature,只好采取一个妥协的办法。首先新建一个Forward Renderer添加到Renderer List中:

    场景中新建一个相机,Render Type改为Overlay,Renderer选择刚才创建的Forward Renderer,并开启Post Processing:

    添加到主相机的Stack上,主相机关闭Post Processing:

    效果:

    到这里对URP后处理的扩展就基本完成了,当然包括渲染在内还有很多地方可以继续完善,比如进一步优化双缓冲、全屏三角形、同一组件支持多个插入点等等。

    用到的素材:Free Space Runner Pack & Free Lunar Battle Pack by MattWalkden

    相关文章

      网友评论

        本文标题:[Unity]为了更好用的后处理——扩展URP后处理踩坑记录

        本文链接:https://www.haomeiwen.com/subject/pbszfltx.html