In Unity's Universal Render Pipeline (URP), the Scriptable Render Feature (and Scriptable Render Pass) is a powerful mechanism for adding custom post-processing effects (such as pixelation, chromatic aberration, or scanlines), outline renders for specific characters, or rendering certain objects on top of everything else.

However, many developers face frustrating issues when setting up custom Render Features: effects not appearing on screen, transparent objects (like particles) sorting incorrectly, overlays drawing on top of UI elements, or screen blackouts (Black Screen) after upgrading to Unity 2022.3 or Unity 6 because cmd.Blit no longer works.

This article explains the root cause of these rendering ordering and missing effect bugs through URP's execution pipeline and RenderPassEvent timings, and provides a modern implementation template using RTHandle and Blitter.BlitCameraTexture compatible with Unity 2022.3 and Unity 6.

1. The Root Cause: RenderPassEvent Mismatches and Buffer Lifecycles

The most common cause of custom rendering failing or sorting incorrectly is an improperly configured RenderPassEvent (Injection Point). URP executes its rendering steps in a strict sequence (Opaque, Depth Prepass, Transparents, Post-Processing, etc.), as illustrated below:

Conceptual diagram of RenderPassEvents and buffer states in the URP rendering pipeline

Figure: URP Render Pipeline Flow and Custom Render Pass Injection Points

Typically, issues arise from three scenarios:

  • Reading Uninitialized Buffers: Injecting full-screen effects during events like BeforeRenderingOpaques before the camera's color or depth buffers are cleared and rendered results in a black screen or trails from previous frames.
  • Post-Processing Conflicts: URP's default post-processing (Bloom, Color Adjustments, etc.) is processed between BeforeRenderingPostProcessing and AfterRenderingPostProcessing. If you inject a custom post-effect after AfterRenderingPostProcessing, it runs after URP color grading is finished and writes directly to the backbuffer, bypassing color-correction or drawing on top of Screen Space - Overlay UI canvases.
  • Transparency Sorting Mismatches: Transparent objects (water, smoke, glass) are rendered after opaque objects are completed. If you execute a custom outline or masking pass during AfterRenderingOpaques, it draws correctly relative to opaques, but subsequent transparent rendering will draw on top of your outline, making it vanish or sort incorrectly.

2. RenderPassEvent Selection Matrix

Choose the correct RenderPassEvent based on your desired rendering behavior:

RenderPassEvent Primary Use Cases / Suitable Rendering Available Buffers & Characteristics
BeforeRenderingOpaques Custom depth pre-passes, custom screen-space decals. Color buffer is cleared. 3D objects are not yet rendered.
AfterRenderingOpaques Silhouettes (X-ray wall vision), character outline rendering. Opaque geometry is fully rendered. Transparent objects are unrendered.
AfterRenderingTransparents Water refraction, fog modifications, pre-post-processing overlays. Entire 3D scene (opaques and transparents) is fully rendered.
BeforeRenderingPostProcessing Custom post-processing (DOF, blurs, screen distortion, etc.). Just before default URP post-processing (Bloom, Color Grading) applies.
AfterRenderingPostProcessing Final screen adjustments (dithering, scanlines, pixelation) before UI. All 3D passes and post-processing are complete. Runs right before Overlay UI.

3. Solution: Modern Scriptable Render Feature Template for Unity 2022.3 / Unity 6

Legacy APIs like cmd.Blit(source, destination) and GetTemporaryRT are **deprecated** in newer Unity versions because they conflict with dynamic resolution scaling (Render Scale) and VR multi-pass rendering. Use the modern RTHandle and Blitter.BlitCameraTexture classes to ensure zero-allocation runtime performance.

Example: Custom Color Inversion Post-Effect

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

public class ColorInvertRenderFeature : ScriptableRenderFeature
{
    [System.Serializable]
    public class Settings
    {
        public Material invertMaterial;
        public RenderPassEvent renderEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    }

    [SerializeField] private Settings settings = new Settings();
    private ColorInvertPass invertPass;

    public override void Create()
    {
        invertPass = new ColorInvertPass(settings.invertMaterial);
        invertPass.renderPassEvent = settings.renderEvent;
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (settings.invertMaterial == null) return;
        
        // Pass camera target color handle to the pass
        invertPass.Setup(renderer.cameraColorTargetHandle);
        renderer.EnqueuePass(invertPass);
    }

    protected override void Dispose(bool disposing)
    {
        invertPass?.Dispose();
    }
}

public class ColorInvertPass : ScriptableRenderPass
{
    private Material material;
    private RTHandle cameraColorTarget;
    private RTHandle tempTexture; // RTHandle for the temporary buffer

    public ColorInvertPass(Material material)
    {
        this.material = material;
    }

    public void Setup(RTHandle colorTarget)
    {
        this.cameraColorTarget = colorTarget;
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        // Get camera rendering parameters (resolution, format, etc.)
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.depthBufferBits = 0; // We only need color, no depth buffer needed for post effect

        // Reallocate temporary texture safely (automatically scales with Render Scale)
        RenderingUtils.ReAllocateIfNeeded(ref tempTexture, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_TempColorInvertTexture");
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        if (material == null || cameraColorTarget == null) return;

        // Fetch command buffer from Pool
        CommandBuffer cmd = CommandBufferPool.Get("ColorInvertPass");

        using (new ProfilingScope(cmd, new ProfilingSampler("Color Invert Execution")))
        {
            // 1. Copy the camera color texture into the temporary buffer
            Blitter.BlitCameraTexture(cmd, cameraColorTarget, tempTexture);

            // 2. Render from temporary texture back into camera target using custom material
            // Note: The shader must sample from "_BlitTexture"
            Blitter.BlitCameraTexture(cmd, tempTexture, cameraColorTarget, material, 0);
        }

        // Execute and release buffer
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public override void OnCameraCleanup(CommandBuffer cmd)
    {
        // Do not release RTHandles here (rely on Dispose for persistent allocations)
    }

    public void Dispose()
    {
        // Prevent memory leaks by explicitly releasing RTHandle resources
        tempTexture?.Release();
    }
}

Corresponding HLSL Shader (URP Blit Shader)

The texture passed by the Blitter must be sampled as _BlitTexture inside the HLSL block. Below is the minimum shader code to invert colors:

Shader "Hidden/URPInvertBlit"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline" = "UniversalPipeline"}
        LOD 100
        ZWrite Off ZTest Always Cull Off

        Pass
        {
            Name "InvertColorPass"

            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag

            // Standard URP Blit HLSL Includes
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

            half4 Frag(Varyings input) : SV_Target
            {
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
                
                // Sample pixel color from _BlitTexture using screen-space UV coordinates
                float2 uv = input.texcoord;
                half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
                
                // Invert RGB channels
                return half4(1.0 - color.rgb, color.a);
            }
            ENDHLSL
        }
    }
}

4. Bad Practices and How to Avoid Them

Ensure your custom Scriptable Render Features do not commit these common mistakes:

1. Allocating RTHandles or Instantiating Command Buffers inside Execute()

Instantiating objects or re-allocating textures inside Execute() triggers excessive GC Allocations every frame, leading to periodic framerate stutter (GC spikes).
Solution: Fetch command buffers using CommandBufferPool.Get() and handle texture allocation in OnCameraSetup using RenderingUtils.ReAllocateIfNeeded. Always clean up persistent RTHandles in your feature's Dispose() callback.

2. Direct Blitting: cmd.Blit(cameraColorTarget, cameraColorTarget, material)

Reading from and writing to the exact same render texture simultaneously causes undefined GPU behaviors. This leads to screen flickering, corrupted textures, or total blackouts.
Solution: Always allocate a temporary buffer (Ping-pong setup). Blit from the source to the temp buffer, then blit from the temp buffer back to the target with your material.

3. Render Features Drawing Over Transparents / Particle Effects

If you disable depth testing (ZTest Always) in your custom shader, objects drawn by your Render Feature will bypass occlusion, drawing on top of foreground particles and geometry.
Solution: Ensure your custom material uses ZTest LEqual (or ZTest Keep) to respect depth buffer constraints, or schedule the pass to execute after transparents (AfterRenderingTransparents) if sorting rules require it.

By understanding URP pipeline schedules and injection events, you can build performant, stable custom effects and rendering modifications that scale across platforms.