
更新(2023.2.16)
已初步適配至Unity 2021 URP 12.1.x版本,倉庫地址:pamisu-kit-unity,測試場景為Assets/Examples/CustomPostProcessing/Scenes/ 中的CustomPP3D 與 CustomPP2D。
依然是本篇文章中的實(shí)現(xiàn)思路,只是稍微修改了后處理效果渲染的相關(guān)RT。


由于時(shí)間有限,沒有修改得很完善,也沒有充分測試,只測試了打包PC端的情況,并且大部分后處理組件的插入點(diǎn)都在RenderPassEvent.AfterRenderingPostProcessing。如果有不正確的地方歡迎指出。
原文
在目前(10.2.2)版本,URP下的自定義后處理依然是通過Renderer Feature來實(shí)現(xiàn),比起以前的PPSV2麻煩了不少,看著隔壁HDRP的提供的自定義后處理組件,孩子都快饞哭了。既然官方暫時(shí)沒有提供,那么就自己先造一個(gè)解饞,對標(biāo)HDRP的自定義后處理,目標(biāo)效果是只需簡單繼承,就能添加自定義后處理組件。實(shí)現(xiàn)過程中遇到了不少問題,但對URP的源碼有了初步的了解。


實(shí)(cai)現(xiàn)(keng)過程:
- 封裝自定義后處理組件基類,負(fù)責(zé)提供渲染方法、插入點(diǎn)設(shè)置等,并顯示組件到Volume的Add Override菜單中。
- 實(shí)現(xiàn)后處理Renderer Feature,獲取所有自定義組件,根據(jù)它們的插入點(diǎn)分配到不同的Render Pass。
- 實(shí)現(xiàn)后處理Render Pass,管理并調(diào)用自定義組件的渲染方法。
- 適配2D場景下的自定義后處理。
類關(guān)系:

后處理組件基類
首先要確保自定義的后處理組件能顯示在Volume的Add Override菜單中,閱讀源碼可知,讓組件出現(xiàn)在這個(gè)菜單中并沒有什么神奇之處,只需繼承VolumeComponent類并且添加VolumeComponentMenu特性即可,而VolumeComponent本質(zhì)上是一個(gè)ScriptableObject。


那么就可以定義一個(gè)CustomVolumeComponent作為我們所有自定義后處理組件的基類:
CustomVolumeComponent.cs
public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
{
...
}
通常希望后處理在渲染過程中能有不同的插入點(diǎn),這里先提供三個(gè)插入點(diǎn),天空渲染之后、內(nèi)置后處理之前、內(nèi)置后處理之后:
/// 后處理插入位置
public enum CustomPostProcessInjectionPoint
{
AfterOpaqueAndSky, BeforePostProcess, AfterPostProcess
}
在同一個(gè)插入點(diǎn)可能會(huì)存在多個(gè)后處理組件,所以還需要一個(gè)排序編號來確定誰先誰后:
public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
{
/// 在InjectionPoint中的渲染順序
public virtual int OrderInPass => 0;
/// 插入位置
public virtual CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterPostProcess;
}
然后定義一個(gè)初始化方法與渲染方法,渲染方法中,將CommandBuffer、RenderingData、渲染源與目標(biāo)都傳入:
/// 初始化,將在RenderPass加入隊(duì)列時(shí)調(diào)用
public abstract void Setup();
/// 執(zhí)行渲染
public abstract void Render(CommandBuffer cmd, refRenderingData renderingData, RenderTargetIdentifiersource, RenderTargetIdentifier destination);
#region IPostProcessComponent
/// 返回當(dāng)前組件是否處于激活狀態(tài)
public abstract bool IsActive();
public virtual bool IsTileCompatible() => false;
#endregion
最后是IDisposable接口的方法,由于渲染可能需要臨時(shí)生成材質(zhì),在這里將它們釋放:
#region IDisposable
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// 釋放資源
public virtual void Dispose(bool disposing) {}
#endregion
后處理組件基類就完成了,隨便寫個(gè)類繼承一下它,Volume菜單中已經(jīng)可以看到組件了:
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并不會(huì)調(diào)用自定義組件的渲染方法(畢竟本來就沒有),這部分需要自己實(shí)現(xiàn),所以還是得祭出Renderer Feature。
官方示例中,一個(gè)Renderer Feature對應(yīng)一個(gè)自定義后處理效果,各個(gè)后處理相互獨(dú)立,好處是靈活自由易調(diào)整;壞處也在此,相互獨(dú)立意味著每個(gè)效果都可能要開臨時(shí)RT,耗費(fèi)資源比雙緩沖互換要多,并且Renderer Feature在Renderer Data下,相對于場景中的Volume來說在代碼中調(diào)用起來反而沒那么方便。
那么這里的思路便是將所有相同插入點(diǎn)的后處理組件放到同一個(gè)Render Pass下渲染,這樣就可以做到雙緩沖交換,又保持了Volume的優(yōu)勢。
獲取自定義后處理組件
先來寫Render Pass,在里面定義好剛才寫的自定義組件列表、Profiler所需變量,還有渲染源、目標(biāo)與可能會(huì)用到的臨時(shí)RT:
CustomPostProcessRenderPass.cs
public class CustomPostProcessRenderPass : ScriptableRenderPass
{
List<CustomVolumeComponent> volumeComponents; // 所有自定義后處理組件
List<int> activeComponents; // 當(dāng)前可用的組件下標(biāo)
string profilerTag;
List<ProfilingSampler> profilingSamplers; // 每個(gè)組件對應(yīng)的ProfilingSampler
RenderTargetHandle source; // 當(dāng)前源與目標(biāo)
RenderTargetHandle destination;
RenderTargetHandle tempRT0; // 臨時(shí)RT
RenderTargetHandle tempRT1;
/// <param name="profilerTag">Profiler標(biāo)識</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");
}
...
}
構(gòu)造方法中接收這個(gè)Render Pass的Profiler標(biāo)識與后處理組件列表,以每個(gè)組件的名稱作為它們渲染時(shí)的Profiler標(biāo)識。
Renderer Feature中,定義三個(gè)插入點(diǎn)對應(yīng)的Render Pass,以及所有自定義組件列表,還有一個(gè)用于后處理之后的RenderTargetHandle,這個(gè)變量之后會(huì)介紹:
CustomPostProcessRendererFeature.cs
/// <summary>
/// 自定義后處理Renderer Feature
/// </summary>
public class CustomPostProcessRendererFeature : ScriptableRendererFeature
{
// 不同插入點(diǎn)的render pass
CustomPostProcessRenderPass afterOpaqueAndSky;
CustomPostProcessRenderPass beforePostProcess;
CustomPostProcessRenderPass afterPostProcess;
// 所有自定義的VolumeComponent
List<CustomVolumeComponent> components;
// 用于after PostProcess的render target
RenderTargetHandle afterPostProcessTexture;
...
}
那么要如何拿到所有自定義后處理組件,這些組件是一開始就存在,還是必須要從菜單中添加之后才存在?暫且蒙在鼓里。
通常可以通過VolumeManager.instance.stack.GetComponent方法來獲取到VolumeComponent,那么去看看VolumeStack的源碼:

它用一個(gè)字典存放了所有的VolumeComponent,并且在Reload方法中根據(jù)baseTypes參數(shù)創(chuàng)建了它們,遺憾的是這是個(gè)internal變量。再看VolumeMangager中,CreateStack方法與CheckStack方法對Reload方法進(jìn)行了調(diào)用:

在ReloadBaseTypes中對baseComponentTypes進(jìn)行了賦值,可以發(fā)現(xiàn)它包含了所有VolumeComponent的非抽象子類類型:

看到這里可以得出結(jié)論,所有后處理組件的實(shí)例一開始便存在于默認(rèn)的VolumeStack中,不管它們是否從菜單中添加。并且萬幸的是,baseComponentTypes是一個(gè)public變量,這樣就不需要通過粗暴手段來獲取了。
接著編寫CustomPostProcessRendererFeature的Create方法,在這里獲取到所有的自定義后處理組件,并且將它們根據(jù)各自的插入點(diǎn)分類并排好序,放入到對應(yīng)的Render Pass中:
CustomPostProcessRendererFeature.cs
// 初始化Feature資源,每當(dāng)序列化發(fā)生時(shí)都會(huì)調(diào)用
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();
// 初始化不同插入點(diǎn)的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");
}
依次設(shè)置每個(gè)Render Pass的renderPassEvent,對于AfterPostProcess插入點(diǎn),renderPassEvent為AfterRendering而不是AfterRenderingPostProcessing,原因是如果插入到AfterRenderingPostProcessing,無法確保渲染輸入源為_AfterPostProcessTexture,查看兩種情況下的幀調(diào)試器:
插入到AfterRenderingPostProcess:

插入到AfterRendering:

對比二者,可以發(fā)現(xiàn)插入點(diǎn)之前的Render PostProcessing Effects的RenderTarget會(huì)不一樣,并且在插入到AfterRendering的情況下,還會(huì)多出一個(gè)FinalBlit,而FinalBlit的輸入源正是_AfterPostProcessTexture:

所以定義afterPostProcessTexture變量的目的便是為了能獲取到_AfterPostProcessTexture,處理后再渲染到它。
現(xiàn)在已經(jīng)拿到了所有自定義后處理組件,下一步就可以開始初始化它們了。在這之前,記得重寫Dispose方法做好資源釋放,避免臨時(shí)創(chuàng)建的材質(zhì)漏得到處都是:
CustomPostProcessRendererFeature.cs
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && components != null)
{
foreach(var item in components)
{
item.Dispose();
}
}
}
初始化
上面在CustomPostProcessRenderPass中定義了一個(gè)變量activeComponents來存儲當(dāng)前可用的的后處理組件,在Render Feature的AddRenderPasses中,需要先判斷Render Pass中是否有組件處于激活狀態(tài),如果沒有一個(gè)組件激活,那么就沒必要添加這個(gè)Render Pass,這里調(diào)用先前在組件中定義好的Setup方法初始化,隨后調(diào)用IsActive判斷其是否處于激活狀態(tài):
CustomPostProcessRenderPass.cs
/// <summary>
/// 設(shè)置后處理組件
/// </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;
}
當(dāng)一個(gè)Render Pass中有處于激活狀態(tài)的組件時(shí),說明它行,很有精神,可以加入到隊(duì)列中,那么需要設(shè)置它的渲染源與目標(biāo):
CustomPostProcessRenderPass.cs
/// <summary>
/// 設(shè)置渲染源和渲染目標(biāo)
/// </summary>
public void Setup(RenderTargetHandle source, RenderTargetHandle destination)
{
this.source = source;
this.destination = destination;
}
之后在CustomPostProcessRendererFeature的AddRenderPasses方法中調(diào)用這兩個(gè)方法,符合條件就將Render Pass添加:
CustomPostProcessRendererFeature.cs
// 你可以在這里將一個(gè)或多個(gè)render pass注入到renderer中。
// 當(dāng)為每個(gè)攝影機(jī)設(shè)置一次渲染器時(shí),將調(diào)用此方法。
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (renderingData.cameraData.postProcessEnabled)
{
// 為每個(gè)render pass設(shè)置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())
{
// 如果下一個(gè)Pass是FinalBlit,則輸入與輸出均為_AfterPostProcessTexture
source = renderingData.cameraData.resolveFinalTarget ? afterPostProcessTexture : source;
afterPostProcess.Setup(source, source);
renderer.EnqueuePass(afterPostProcess);
}
}
}
至此Renderer Feature類中的所有代碼就寫完了,接下來繼續(xù)在Render Pass中實(shí)現(xiàn)渲染。
執(zhí)行渲染
編寫Render Pass中渲染執(zhí)行的方法Execute:
// 你可以在這里實(shí)現(xiàn)渲染邏輯。
// 使用<c>ScriptableRenderContext</c>來執(zhí)行繪圖命令或Command Buffer
// https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.html
// 你不需要手動(dòng)調(diào)用ScriptableRenderContext.submit,渲染管線會(huì)在特定位置調(diào)用它。
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;
// 初始化臨時(shí)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);
}
// 執(zhí)行每個(gè)組件的Render方法
// 如果只有一個(gè)組件,則直接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
{
// 如果有多個(gè)組件,則在兩個(gè)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);
}
這里如果寫得再簡潔一些應(yīng)該是可以只需要source和destination兩個(gè)變量就行。需要注意某些情況下_AfterPostProcessTexture可能不存在,所以添加了手動(dòng)獲取RT的處理。如果不做這一步可能會(huì)出現(xiàn)Warning:

到這里Renderer Feature與Render Pass就全部編寫完成,接下來使用一下看看實(shí)際效果。
使用一下看看實(shí)際效果
以官方示例中的卡通描邊效果為例,先從把示例中的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創(chuàng)建材質(zhì),在Dispose中銷毀它。Render方法中的cmd.Blit之后可以考慮換成CoreUtils.DrawFullScreen畫全屏三角形。
需要注意的是,IsActive方法最好要在組件無效時(shí)返回false,避免組件未激活時(shí)仍然執(zhí)行了渲染,原因之前提到過,無論組件是否添加到Volume菜單中或是否勾選,VolumeManager總是會(huì)初始化所有的VolumeComponent。
CoreUtils.CreateEngineMaterial(shaderName)內(nèi)部依然是調(diào)用Shader.Find方法來查找Shader:

添加Renderer Feature:

在Volume中添加并啟用Sobel Filter:

效果:


繼續(xù)加入更多后處理組件,這里使用連連看簡單連了一個(gè)條紋故障和一個(gè)RGB分離故障,它們的插入點(diǎn)都是內(nèi)置后處理之后:



效果:

應(yīng)用到2D
由于目前2D Renderer還不支持Renderer Feature,只好采取一個(gè)妥協(xié)的辦法。首先新建一個(gè)Forward Renderer添加到Renderer List中:

場景中新建一個(gè)相機(jī),Render Type改為Overlay,Renderer選擇剛才創(chuàng)建的Forward Renderer,并開啟Post Processing:

添加到主相機(jī)的Stack上,主相機(jī)關(guān)閉Post Processing:

URP 12.1.x中已經(jīng)有了對2D的Renderer Feature支持,所以只需要添加自定義的Renderer Feature即可,其他使用方式和3D一致。
到這里對URP后處理的擴(kuò)展就基本完成了,當(dāng)然包括渲染在內(nèi)還有很多地方可以繼續(xù)完善,比如進(jìn)一步優(yōu)化雙緩沖、全屏三角形、同一組件支持多個(gè)插入點(diǎn)等等。
對于編輯器中運(yùn)行有效果,但打包后沒有效果的情況,可能的原因是Shader文件在打包時(shí)被剔除了,這種情況只要確保Shader文件被包含或者可被加載即可(添加到Always Included Shaders、放到Resources、從AB加載等等)。
用到的素材:Free Space Runner Pack & Free Lunar Battle Pack by MattWalkden