Design Hub

    • 注册
    • 登录
    • 搜索
    • 版块
    • 最新

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

    游戏开发
    1
    1
    293
    正在加载更多帖子
    • 从旧到新
    • 从新到旧
    • 最多赞同
    回复
    • 在新帖中回复
    登录后回复
    此主题已被删除。只有拥有主题管理权限的用户可以查看。
    • Pamisu
      Pamisu 管理员 最后由 编辑

      扩展URP后处理踩坑记录

      更新(2023.2.16)

      已初步适配至Unity 2021 URP 12.1.x版本,仓库地址:pamisu-kit-unity,测试场景为Assets/Examples/CustomPostProcessing/Scenes/ 中的CustomPP3D 与 CustomPP2D。
      依然是本篇文章中的实现思路,只是稍微修改了后处理效果渲染的相关RT。
      自定义后处理效果-3D

      自定义后处理效果-2D

      由于时间有限,没有修改得很完善,也没有充分测试,只测试了打包PC端的情况,并且大部分后处理组件的插入点都在RenderPassEvent.AfterRenderingPostProcessing。如果有不正确的地方欢迎指出。


      原文

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

      效果
      效果
      Volume组件

      实现过程:

      • 封装自定义后处理组件基类,负责提供渲染方法、插入点设置等,并显示组件到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,在里面定义好刚才写的自定义组件列表、Profiling所需变量,还有渲染源、目标与可能会用到的临时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标识与后处理组件列表,以每个组件的名称作为它们渲染时的Profiling标识。

      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/SobleFilter",然后编写后处理组件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/SobleFilter";
      
          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

      添加Render 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 12.1.x中已经有了对2D的Renderer Feature支持,所以只需要添加自定义的Renderer Feature即可,其他使用方式和3D一致。

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

      对于编辑器中运行有效果,但打包后没有效果的情况,可能的原因是Shader文件在打包时被剔除了,这种情况只要确保Shader文件被包含或者可被加载即可(添加到Always Included Shaders、放到Resources、从AB加载等等)。

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

      1 条回复 最后回复 回复 引用
      • 1 / 1
      • First post
        Last post
      版权所有 ©2023 Design Hub 保留所有权利
      鄂ICP备2021004164号-2