๐งฉ 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:
ScriptableRendererFeatureandScriptableRenderPass - Write a feature's
CreateandAddRenderPassesand enqueue a pass at a chosenRenderPassEvent - Implement a pass with the URP 17
RecordRenderGraphmethod - Access frame resources via
UniversalResourceData/UniversalCameraDataand blit withAddBlitPass - Register a feature on the URP Renderer asset
- Understand why the old
Execute/CommandBufferpath 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:
- 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.
- In its Inspector, click Add Renderer Feature and choose Grayscale Feature.
- Assign the full-screen blit Shader to the feature's shader slot.
- 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:
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 aScriptableRenderPass(the work). - The feature's
Createbuilds the pass and sets itsrenderPassEvent;AddRenderPassescallsEnqueuePasseach frame. - In Unity 6 / URP 17 the pass overrides
RecordRenderGraph; the oldExecute/CommandBufferpath is legacy. - Get frame textures from
UniversalResourceData; aTextureHandleis a declaration;AddBlitPassruns a full-screen blit through a material. - Swap your result in with
resourceData.cameraColor = destination, and dispose the material inDispose. - Add the feature to the Universal Renderer Data asset, and choose the
RenderPassEventto 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.