Skip to main content

๐Ÿงฉ Lesson 5.3: Scriptable Renderer Features & Custom Passes

Lesson 5.2 let you write code that runs inside a pass. Now you'll add whole passes to the frame โ€” clearing a buffer, drawing extra geometry, or applying a full-screen effect at exactly the RenderPassEvent you choose. This is done with a Scriptable Renderer Feature, and in Unity 6 it's built on URP 17's RenderGraph API. This is the most version-sensitive topic in the module, so we'll be precise.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the two classes: ScriptableRendererFeature and ScriptableRenderPass
  • Write a feature's Create and AddRenderPasses and enqueue a pass at a chosen RenderPassEvent
  • Implement a pass with the URP 17 RecordRenderGraph method
  • Access frame resources via UniversalResourceData/UniversalCameraData and blit with AddBlitPass
  • Register a feature on the URP Renderer asset
  • Understand why the old Execute/CommandBuffer path is legacy

Estimated Time: 75 minutes  ยท  Prerequisite: Lessons 5.1โ€“5.2

In This Lesson

Two Classes

A custom render addition is always two pieces:

๐Ÿงฉ ScriptableRendererFeature

The asset-facing half. It's a ScriptableObject you add to the URP Renderer, exposes Inspector settings, creates the pass in Create(), and enqueues it each frame in AddRenderPasses().

๐ŸŽฌ ScriptableRenderPass

The work half. It declares when it runs (renderPassEvent) and what it does, in RecordRenderGraph() โ€” reading and writing the frame's render textures.

The feature is the packaging; the pass is the behaviour. One feature can enqueue several passes if needed. We'll build a simple full-screen colour effect to make the shape concrete โ€” the same skeleton scales up to outlines, blurs, and the mini-project in Lesson 5.5.

The Renderer Feature

The feature creates the pass, points it at a material, and chooses its injection point. Note where it runs โ€” BeforeRenderingPostProcessing, one of the events from Lesson 5.1 โ€” and that we skip non-Game cameras so the Scene view and previews aren't affected:

using UnityEngine;
using UnityEngine.Rendering.Universal;

public class GrayscaleFeature : ScriptableRendererFeature
{
    [SerializeField] Shader shader;   // a full-screen blit shader
    Material material;
    GrayscalePass pass;

    public override void Create()
    {
        if (shader == null) return;
        material = new Material(shader);
        pass = new GrayscalePass(material)
        {
            // WHERE in the frame this pass runs:
            renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing
        };
    }

    public override void AddRenderPasses(ScriptableRenderer renderer,
        ref RenderingData renderingData)
    {
        if (pass == null) return;
        // Only affect the actual game camera.
        if (renderingData.cameraData.cameraType == CameraType.Game)
            renderer.EnqueuePass(pass);
    }

    protected override void Dispose(bool disposing)
    {
        if (Application.isPlaying) Destroy(material);
        else DestroyImmediate(material);
    }
}

Create() runs when the feature is loaded or its settings change; AddRenderPasses() runs every frame, per camera, and EnqueuePass schedules our pass into that camera's frame. Dispose cleans up the material we newed โ€” features are long-lived, so leaking a material here is a real bug.

RenderGraph: A Shift

Before diving into the pass, one important context. In Unity 6 / URP 17, custom passes are written with the RenderGraph API. Instead of issuing commands into a CommandBuffer imperatively (the old Execute(context, ref renderingData) method), you declare what the pass reads and writes, and the render graph figures out allocation, ordering, and which resources can be reused or culled. It's more declarative โ€” and it's the path Unity is standardising on.

โš ๏ธ Old tutorials will mislead you

Plenty of URP tutorials still override Execute(ScriptableRenderContext, ref RenderingData) and call cmd.Blit. That path is legacy in URP 17 and being phased out. For Unity 6, implement RecordRenderGraph instead โ€” if you find yourself writing a CommandBuffer by hand in a pass, you're following an outdated guide.

The Pass: RecordRenderGraph

The pass overrides one method. It fetches the frame's resources from a ContextContainer, grabs the camera's colour texture, creates a temporary destination, blits the source through our material into it, and swaps that result back in as the new camera colour:

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.RenderGraphModule.Util;
using UnityEngine.Rendering.Universal;

public class GrayscalePass : ScriptableRenderPass
{
    const string k_PassName = "GrayscalePass";
    Material material;

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

    public override void RecordRenderGraph(RenderGraph renderGraph,
        ContextContainer frameData)
    {
        // Frame resources for THIS camera.
        var resourceData = frameData.Get<UniversalResourceData>();
        if (resourceData.isActiveTargetBackBuffer) return;   // can't sample the backbuffer

        TextureHandle source = resourceData.activeColorTexture;

        // A temp texture the same shape as the camera colour.
        var desc = source.GetDescriptor(renderGraph);
        desc.name = "_GrayscaleTemp";
        desc.depthBufferBits = 0;
        TextureHandle destination = renderGraph.CreateTexture(desc);

        if (!source.IsValid() || !destination.IsValid()) return;

        // Blit source โ†’ destination through our material (pass index 0).
        var blit = new RenderGraphUtils.BlitMaterialParameters(source, destination, material, 0);
        renderGraph.AddBlitPass(blit, k_PassName);

        // Make the effect the new camera colour for later passes.
        resourceData.cameraColor = destination;
    }
}

The key objects: UniversalResourceData gives you the frame's textures (camera colour/depth, normals, etc.), and there's a UniversalCameraData for camera info. A TextureHandle is a declaration of a texture, not the texture itself โ€” the graph resolves it. AddBlitPass records a full-screen blit through the material. Setting resourceData.cameraColor = destination tells everything downstream to use our processed result.

Registering the Feature

A feature does nothing until it's added to the renderer:

  1. Find your URP Renderer asset (Project Settings โ–ธ Graphics points to the URP asset; the URP asset points to a Renderer). Select the Universal Renderer Data asset.
  2. In its Inspector, click Add Renderer Feature and choose Grayscale Feature.
  3. Assign the full-screen blit Shader to the feature's shader slot.
  4. Enter Play โ€” the game view renders in grayscale; the Scene view stays coloured (we filtered to CameraType.Game).

The material's shader is a full-screen blit shader (it samples _BlitTexture over a full-screen triangle and outputs the modified colour). You'll write exactly that shader โ€” and a more interesting effect than grayscale โ€” in the Lesson 5.5 mini-project; here the focus is the feature/pass plumbing that delivers it.

Where It Injects

Tying it back to the frame from Lesson 5.1 โ€” the feature slots a new pass into the ordered event list at the point you chose:

A custom pass injected before post-processing The URP frame passes in order โ€” opaque, skybox, transparent โ€” then a highlighted custom Grayscale pass injected at BeforeRenderingPostProcessing, then post-processing and the final blit. The custom pass reads the camera colour and writes a processed version back. EnqueuePass at RenderPassEvent.BeforeRenderingPostProcessing Opaque Skybox Transparent Grayscale(your custom pass) Post-processing Final blit reads activeColorTexture โ†’ blits through material โ†’ writes it back as cameraColor
Figure 1: The Grayscale feature injects its pass right before post-processing, so it operates on the fully-rendered scene colour but before bloom/tonemapping run.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Trace the data flow

Objective: Cement the RenderGraph mental model without writing code yet.

In your own words, answer: (1) In RecordRenderGraph, where does the input image come from? (2) Why create a separate destination texture instead of writing back into the source? (3) What does resourceData.cameraColor = destination accomplish?

โœ… Answers

(1) From resourceData.activeColorTexture โ€” the camera's current colour target. (2) You generally can't read and write the same texture in one blit; ping-ponging to a fresh destination avoids the read/write hazard. (3) It swaps the graph's notion of "the camera colour" to your processed texture, so post-processing and the final blit use your result instead of the original.

๐Ÿ‹๏ธ Exercise 2: Move the injection point

Change the feature's renderPassEvent to AfterRenderingOpaques and reason about what changes: would transparent objects (particles, glass) be affected by the grayscale now? Why or why not?

โœ… Answer

No โ€” at AfterRenderingOpaques the transparents haven't been drawn yet, so they'd be composited on top of the already-grayscaled opaques and keep their colour. To grayscale the whole scene including transparents, inject at BeforeRenderingPostProcessing (after transparents). This is exactly why choosing the event matters.

๐ŸŽฏ Quick Quiz

Question 1: In URP 17 / Unity 6, which method should a custom pass override?

Question 2: What is a TextureHandle?

Question 3: A feature does nothing in-game. Most likely cause?

Summary

๐ŸŽ‰ Key Takeaways

  • A custom render addition is a ScriptableRendererFeature (asset + settings + enqueue) plus a ScriptableRenderPass (the work).
  • The feature's Create builds the pass and sets its renderPassEvent; AddRenderPasses calls EnqueuePass each frame.
  • In Unity 6 / URP 17 the pass overrides RecordRenderGraph; the old Execute/CommandBuffer path is legacy.
  • Get frame textures from UniversalResourceData; a TextureHandle is a declaration; AddBlitPass runs a full-screen blit through a material.
  • Swap your result in with resourceData.cameraColor = destination, and dispose the material in Dispose.
  • Add the feature to the Universal Renderer Data asset, and choose the RenderPassEvent to control what it affects.

๐Ÿš€ What's Next?

Passes work on images the GPU already drew. Sometimes you want the GPU to compute something arbitrary โ€” a simulation, a procedural texture โ€” outside the draw pipeline entirely. In Lesson 5.4: Compute Shaders we do exactly that, and capture the GPU's output directly.

๐Ÿงฉ You can extend the frame

Feature to package it, pass to do the work, RenderGraph to wire the resources, RenderPassEvent to place it. That's the whole vocabulary of custom URP rendering.