更新(2023.2.16)
已初步适配至Unity 2021 URP 12.1.x版本,仓库地址:pamisu-kit-unity,测试场景为Assets/Examples/CustomPostProcessing/Scenes/
中的CustomPP3D
与 CustomPP2D
。
依然是本篇文章中的实现思路,只是稍微修改了后处理效果渲染的相关RT。
由于时间有限,没有修改得很完善,也没有充分测试,只测试了打包PC端的情况,并且大部分后处理组件的插入点都在RenderPassEvent.AfterRenderingPostProcessing
。如果有不正确的地方欢迎指出。
原文
在目前(10.2.2)版本,URP下的自定义后处理依然是通过Renderer Feature来实现,比起以前的PPSV2麻烦了不少,看着隔壁HDRP的提供的自定义后处理组件,孩子都快馋哭了。既然官方暂时没有提供,那么就自己先造一个解馋,对标HDRP的自定义后处理,目标效果是只需简单继承,就能添加自定义后处理组件。实现过程中遇到了不少问题,但对URP的源码有了初步的了解。
实(cai)现(keng)过程:
- 封装自定义后处理组件基类,负责提供渲染方法、插入点设置等,并显示组件到Volume的Add Override菜单中。
- 实现后处理Renderer Feature,获取所有自定义组件,根据它们的插入点分配到不同的Render Pass。
- 实现后处理Render Pass,管理并调用自定义组件的渲染方法。
- 适配2D场景下的自定义后处理。
类关系:
后处理组件基类
首先要确保自定义的后处理组件能显示在Volume的Add Override菜单中,阅读源码可知,让组件出现在这个菜单中并没有什么神奇之处,只需继承VolumeComponent
类并且添加VolumeComponentMenu
特性即可,而VolumeComponent本质上是一个ScriptableObject。
那么就可以定义一个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的源码:
它用一个字典存放了所有的VolumeComponent,并且在Reload
方法中根据baseTypes
参数创建了它们,遗憾的是这是个internal变量。再看VolumeMangager中,CreateStack
方法与CheckStack
方法对Reload
方法进行了调用:
在ReloadBaseTypes
中对baseComponentTypes
进行了赋值,可以发现它包含了所有VolumeComponent的非抽象子类类型:
看到这里可以得出结论,所有后处理组件的实例一开始便存在于默认的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:
插入到AfterRendering:
对比二者,可以发现插入点之前的Render PostProcessing Effects
的RenderTarget会不一样,并且在插入到AfterRendering的情况下,还会多出一个FinalBlit,而FinalBlit的输入源正是_AfterPostProcessTexture
:
所以定义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:
到这里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:
添加Renderer Feature:
在Volume中添加并启用Sobel Filter:
效果:
继续加入更多后处理组件,这里使用连连看简单连了一个条纹故障和一个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