[Unity]為了更好用的后處理——擴(kuò)展URP后處理踩坑記錄

擴(kuò)展URP后處理踩坑記錄

更新(2023.2.16)

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

自定義后處理效果-3D

自定義后處理效果-2D

由于時(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的源碼有了初步的了解。

效果
自定義Volume組件

實(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。

Volueme的Add Override菜單
Bloom.cs

那么就可以定義一個(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的源碼:

VolumeStack.cs

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

VolumeManager.cs

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

VolumeManager.cs

看到這里可以得出結(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:

插入到AfterRenderingPostProcess

插入到AfterRendering:

插入到AfterRendering

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

FinalBlit

所以定義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:

找不到_AfterPostProcessTexture

到這里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:

CoreUtils.cs

添加Renderer Feature:

在Volume中添加并啟用Sobel Filter:

效果:

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

條紋故障
RGB分離

效果:

效果

應(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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容