Implementación de un sistema de post-procesamiento personalizado en Unity URP 14.0

La arquitectura del sistema se compone de cinco elementos fundamentales para una integración modular en el pipeline de renderizado.

  • Clase base de post-procesamiento (CustomPostProcessing.cs): Define la interfaz común para todos los efectos personalizados.
  • Scriptable Renderer Feature (CustomPostProcessingFeature.cs): Responsable de registrar y gestionar los pases de renderizado.
  • Scriptable Render Pass (CustomPostProcessingPass.cs): Contiene la lógica de ejecución para aplicar los efectos.
  • Shader de efecto: El programa GPU que realiza el procesamiento de píxeles.
  • Componente de efecto concreto: Una clase que hereda de la base, conecta los parámetros del usuario con el shader.

Componente base de post-procesamiento

Esta clase abstracta sirve como contrato para todos los efectos. Hereda de VolumeComponent y IPostProcessComponent para entegrarse con el sistema de Volumes de URP, e implementa IDisposable para una gestión segura de recursos temporales como los Render Textures.

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public enum CustomEffectLocation
{
    AfterOpaque,
    AfterSkybox,
    BeforePostProcess,
    AfterPostProcess
}

public abstract class CustomPostProcessing : VolumeComponent, IPostProcessComponent, IDisposable
{
    public virtual CustomEffectLocation Location => CustomEffectLocation.AfterPostProcess;
    public virtual int SortOrder => 0;

    public abstract bool IsActive();
    public virtual bool IsTileCompatible() => false;
    public abstract void Setup();

    public virtual void ConfigureCamera(CommandBuffer cmd, ref RenderingData renderingData) { }
    public abstract void Execute(CommandBuffer cmd, ref RenderingData renderingData, RTHandle src, RTHandle dest);

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing) { }
}

Renderer Feature para el sistema

Este ScriptableRendererFeature crea instancias de pases para cada punto de inyección deseado. Durante su método Create, consulta el VolumeManager para obtener todos los componentes activos derivados de nuestra base, los clasifica por su Location y SortOrder, y genera los pases correspondientes.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class CustomPostProcessRendererFeature : ScriptableRendererFeature
{
    private CustomPostProcessRenderPass _afterOpaquePass;
    private CustomPostProcessRenderPass _afterSkyboxPass;
    private CustomPostProcessRenderPass _beforePostProcessPass;
    private CustomPostProcessRenderPass _afterPostProcessPass;

    private List<CustomPostProcessing> _allCustomEffects;

    public override void Create()
    {
        var volumeStack = VolumeManager.instance.stack;
        _allCustomEffects = VolumeManager.instance.baseComponentTypeArray
            .Where(type => type.IsSubclassOf(typeof(CustomPostProcessing)))
            .Select(type => volumeStack.GetComponent(type) as CustomPostProcessing)
            .ToList();

        CreatePassForLocation(CustomEffectLocation.AfterOpaque, RenderPassEvent.AfterRenderingOpaques, out _afterOpaquePass);
        CreatePassForLocation(CustomEffectLocation.AfterSkybox, RenderPassEvent.AfterRenderingSkybox, out _afterSkyboxPass);
        CreatePassForLocation(CustomEffectLocation.BeforePostProcess, RenderPassEvent.BeforeRenderingPostProcessing, out _beforePostProcessPass);
        CreatePassForLocation(CustomEffectLocation.AfterPostProcess, RenderPassEvent.AfterRenderingPostProcessing, out _afterPostProcessPass);
    }

    private void CreatePassForLocation(CustomEffectLocation location, RenderPassEvent timing, out CustomPostProcessRenderPass pass)
    {
        var effectsForLocation = _allCustomEffects
            .Where(effect => effect.Location == location)
            .OrderBy(effect => effect.SortOrder)
            .ToList();

        pass = new CustomPostProcessRenderPass("CustomPass_" + location, effectsForLocation);
        pass.renderPassEvent = timing;
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (!renderingData.cameraData.postProcessEnabled) return;

        EnqueuePassIfActive(renderer, _afterOpaquePass);
        EnqueuePassIfActive(renderer, _afterSkyboxPass);
        EnqueuePassIfActive(renderer, _beforePostProcessPass);
        EnqueuePassIfActive(renderer, _afterPostProcessPass);
    }

    private void EnqueuePassIfActive(ScriptableRenderer renderer, CustomPostProcessRenderPass pass)
    {
        if (pass.HasActiveEffects())
        {
            pass.ConfigureInput(ScriptableRenderPassInput.Color);
            renderer.EnqueuePass(pass);
        }
    }

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

Pase de renderziado del sistema

Este pase maneja el flujo de trabajo de blitting. Utiliza RTHandle para texturas temporales, gestionando una o múltiples pasadas de post-procesamiento. Para múltiples efectos, implementa un patrón de doble buffer alternando entre dos texturas auxiliares (_tmpBufferA y _tmpBufferB) para minimizar las asignaciones de memoria.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class CustomPostProcessRenderPass : ScriptableRenderPass
{
    private readonly List<CustomPostProcessing> _effects;
    private readonly List<int> _activeEffectIndices = new List<int>();
    private readonly List<ProfilingSampler> _samplers;

    private RTHandle _sourceRT;
    private RTHandle _destinationRT;
    private RTHandle _tmpBufferA;
    private RTHandle _tmpBufferB;

    private readonly string _tmpBufferAName = "_CustomPostProcessTmpA";
    private readonly string _tmpBufferBName = "_CustomPostProcessTmpB";

    public CustomPostProcessRenderPass(string profilerTag, List<CustomPostProcessing> effects)
    {
        _effects = effects;
        _samplers = effects.Select(effect => new ProfilingSampler(effect.GetType().Name)).ToList();
    }

    public bool HasActiveEffects() => _activeEffectIndices.Count > 0;

    public void ConfigureEffects()
    {
        _activeEffectIndices.Clear();
        for (int i = 0; i < _effects.Count; i++)
        {
            _effects[i].Setup();
            if (_effects[i].IsActive())
                _activeEffectIndices.Add(i);
        }
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        var descriptor = renderingData.cameraData.cameraTargetDescriptor;
        descriptor.msaaSamples = 1;
        descriptor.depthBufferBits = 0;

        RenderingUtils.ReAllocateIfNeeded(ref _tmpBufferA, descriptor, name: _tmpBufferAName);
        RenderingUtils.ReAllocateIfNeeded(ref _tmpBufferB, descriptor, name: _tmpBufferBName);

        foreach (var index in _activeEffectIndices)
            _effects[index].ConfigureCamera(cmd, ref renderingData);
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer cmd = CommandBufferPool.Get("Custom Post Process");
        context.ExecuteCommandBuffer(cmd);
        cmd.Clear();

        _sourceRT = renderingData.cameraData.renderer.cameraColorTargetHandle;
        _destinationRT = renderingData.cameraData.renderer.cameraColorTargetHandle;

        bool requiresSecondBuffer = false;
        RTHandle currentSource = _sourceRT;
        RTHandle currentDest = _tmpBufferA;

        if (_activeEffectIndices.Count == 1)
        {
            var effectIndex = _activeEffectIndices[0];
            using (new ProfilingScope(cmd, _samplers[effectIndex]))
                _effects[effectIndex].Execute(cmd, ref renderingData, currentSource, currentDest);
        }
        else
        {
            requiresSecondBuffer = true;
            Blit(cmd, currentSource, _tmpBufferA);
            currentSource = _tmpBufferA;
            currentDest = _tmpBufferB;

            foreach (var effectIndex in _activeEffectIndices)
            {
                using (new ProfilingScope(cmd, _samplers[effectIndex]))
                    _effects[effectIndex].Execute(cmd, ref renderingData, currentSource, currentDest);

                CoreUtils.Swap(ref currentSource, ref currentDest);
            }
            currentDest = currentSource;
        }

        Blitter.BlitCameraTexture(cmd, currentDest, _destinationRT);

        cmd.ReleaseTemporaryRT(Shader.PropertyToID(_tmpBufferA.name));
        if (requiresSecondBuffer)
            cmd.ReleaseTemporaryRT(Shader.PropertyToID(_tmpBufferB.name));

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public override void OnCameraCleanup(CommandBuffer cmd)
    {
        _sourceRT = null;
        _destinationRT = null;
    }

    public void ReleaseResources()
    {
        _tmpBufferA?.Release();
        _tmpBufferB?.Release();
    }
}

Ejemplo: Efecto de Brillo, Saturación y Contraste

Shader del efecto (BSC)

Shader "Custom/PostProcess/BrightnessSaturationContrast"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Brightness ("Brightness", Float) = 1.0
        _Saturation ("Saturation", Float) = 1.0
        _Contrast ("Contrast", Float) = 1.0
    }

    SubShader
    {
        Tags { "RenderPipeline" = "UniversalPipeline" }

        ZTest Always
        Cull Off
        ZWrite Off

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

        TEXTURE2D(_MainTex);
        SAMPLER(sampler_MainTex);

        CBUFFER_START(UnityPerMaterial)
            float _Brightness;
            float _Saturation;
            float _Contrast;
        CBUFFER_END

        struct Attributes
        {
            float4 positionOS : POSITION;
            float2 uv : TEXCOORD0;
        };

        struct Varyings
        {
            float4 positionCS : SV_POSITION;
            float2 uv : TEXCOORD0;
        };

        Varyings Vert(Attributes input)
        {
            Varyings output;
            output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
            output.uv = input.uv;
            return output;
        }
        ENDHLSL

        Pass
        {
            Name "BSC Pass"

            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag

            half4 Frag(Varyings input) : SV_Target
            {
                half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);

                // Brillo
                half3 result = texColor.rgb * _Brightness;

                // Saturación
                half luminance = dot(result, half3(0.2126, 0.7152, 0.0722));
                result = lerp(luminance.rrr, result, _Saturation);

                // Contraste
                result = lerp(0.5.rrr, result, _Contrast);

                return half4(result, texColor.a);
            }
            ENDHLSL
        }
    }
    Fallback Off
}

Componente de volumen (BSC.cs)

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

[VolumeComponentMenu("Custom Effects/BSC")]
public class BrightnessSaturationContrastEffect : CustomPostProcessing
{
    public ClampedFloatParameter brightness = new ClampedFloatParameter(1.0f, 0f, 5f);
    public ClampedFloatParameter saturation = new ClampedFloatParameter(1.0f, 0f, 2f);
    public ClampedFloatParameter contrast = new ClampedFloatParameter(1.0f, 0f, 2f);

    private Material _material;
    private const string ShaderPath = "Custom/PostProcess/BrightnessSaturationContrast";

    public override CustomEffectLocation Location => CustomEffectLocation.AfterOpaque;
    public override int SortOrder => 0;

    public override bool IsActive() => _material != null;

    public override void Setup()
    {
        if (_material == null)
            _material = CoreUtils.CreateEngineMaterial(ShaderPath);
    }

    public override void Execute(CommandBuffer cmd, ref RenderingData renderingData, RTHandle source, RTHandle destination)
    {
        if (_material == null) return;

        _material.SetFloat("_Brightness", brightness.value);
        _material.SetFloat("_Saturation", saturation.value);
        _material.SetFloat("_Contrast", contrast.value);
        cmd.Blit(source, destination, _material, 0);
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        CoreUtils.Destroy(_material);
    }
}

Etiquetas: Unity Universal Render Pipeline URP ScriptableRendererFeature ScriptableRenderPass

Publicado el 6-21 02:09