Hi! I’m Jemin Lee, a Partner Engineer at Unity.
As we shift our focus from the Built-in Render Pipeline(BiRP) to the Universal Render Pipeline (URP), I often see beginners get stuck when migrating custom rendering logic (especially custom post-processing) to URP.
Although our ebook, Introduction to URP for advanced creators (Unity 6 edition) covers most migration scenarios, this article focuses on a more beginner-friendly conceptual model:
- How the SRP/URP frame is structured
- Why URP behaves differently from the Built-in Render Pipeline
- What Renderer Features actually do
This article is for developers who:
- Understand fragment (pixel) shaders at a basic level
- Are comfortable with surface-level rendering concepts
- But have never intentionally designed or modified a render pipeline
You may have:
- Written multi-pass shaders in the Built-in pipeline
- Followed shader tutorials without a deep dive into frame execution order
What is a Render Pipeline?
A Render Pipeline defines the structure and execution order of operations that transform a 3D scene into the final image on screen.
It orchestrates the whole frame. It determines things like:
- In what order are objects rendered (opaque vs transparent, sorting rules)
- What object should be drawn(Culling rules)?
- When shadows are generated
- When post-processing runs
- What buffers exist (color, depth, normals, motion vectors, …)
- How passes are scheduled and how intermediate results flow between them
Most developers first learn rendering through shaders (vertex/fragment). But shaders execute inside a larger frame schedule. That schedule is the render pipeline.
Three Levels of Render Pipeline
To understand URP, it helps to see how the render pipeline’s flexibility has evolved over time.
Fixed-function hardware pipeline (no programmable shaders)
On early/limited hardware, even shading was not programmable. Rendering followed a predefined sequence of operations implemented directly from hardware.
Developers could configure parameters, but only through predefined knobs: lighting enable, blending mode, fog enable, texture combine modes, etc. You could tweak what already existed, but you couldn’t inject new per-pixel algorithms.
A practical example is the PSP’s Graphics Engine (GE): a GPU driven by a command stream that mostly toggles fixed-function states and issues draw calls (for example, a developer could enable lighting with the CMD_LTE command).
There was no concept of user-programmable vertex/fragment shaders in the modern sense. So, custom shading models and arbitrary post-processing kernels were not possible.
This model provided predictable performance and simplicity, but very limited expressiveness.
Fixed frame structure + programmable shaders (typical game engine model)
As hardware improved, shaders became programmable, but the frame structure remained mostly fixed by the engine.
This is the model most developers implicitly learned through Unity’s Built-in Render Pipeline:
- You can write vertex/fragment shaders
- You can author multiple passes in a shader
- The engine still decides the big picture:
- When shadows render
- Opaque/transparent ordering
- When depth is available
- When post-processing runs
Engines also exposed predefined hook points (e.g., AfterForwardOpaque camera event in Unity). You could also get an intermediate buffer and inject additional work. But you still didn’t own the pipeline structure itself. The overall pipeline architecture remained fixed.
This made per-object multipass rendering feel very natural.
When developers wanted additional rendering work, they often added another shader pass inside the built-in pipeline stage.
For example, if you want to add an outline pass and a rim light pass, you will add these shader passes inside the Opaque Render Stage.
Programmable Frame Structure (Scriptable Render Pipeline)
As visual complexity increased, ‘programmable shading’ wasn’t enough. Developers needed control over:
- When intermediate buffers are generated
- How many passes are executed
- Whether certain stages even exist
- How data flows between rendering steps
So, instead of only making shading programmable, the Unity engine began allowing the frame structure itself to be defined in user scripts(C#) without editing the native C++ engine core. This is the foundation of Unity’s Scriptable Render Pipeline (SRP).
In the programmable frame model, additional render work usually starts with an additional render stage, not shader passes.
Instead of starting from an object and asking, “Which new shader passes does this object have?”, we start from a stage and ask, “What stage do we need to add?”
With the SRP API, you can add or remove a render stage. Also, you can control how stages are ordered.
And each render stage can decide
- What objects to render
- Which shader pass to use
- Which resources to read or write
In SRP terminology, we call that stage a render pass.
Where URP fits
URP is an SRP implementation that already defines a complete rendering architecture:
- Opaque/transparent rendering
- Shadow passes
- Depth/normal generation (when needed)
- Post-processing ordering
- Lighting strategy, batching strategy, buffer strategy
You typically don’t rewrite URP from scratch. Instead, URP exposes structured extension points (especially Renderer Features) to insert a custom render stage into its predefined architecture. You can also use SRP APIs to customize URP’s architecture, too.
BiRP vs URP
Now that we understand what a render pipeline is. Let’s examine how the Built-in Render Pipeline and URP differ structurally.
URP rendering is pipeline-driven, not shader-driven
In the Built-in Render Pipeline, shaders could contain multiple passes that often appear to execute sequentially per object. In URP, the rendering flow is defined by the pipeline, not by individual shaders.
In URP, the pipeline decides:
- Which shader passes are valid for a given stage
- Which objects are drawn for that stage
- Which buffers exist at that moment
A shader pass does nothing unless a pipeline stage(render pass) actively selects it.
How URP selects shader passes
In URP, a shader pass is usually selected by its LightMode tag, for example:
- “UniversalForward”
- “UniversalForwardOnly”
- “ShadowCaster”
- “DepthOnly”
When URP draws a stage (= render pass), each stage searches for a shader pass whose LightMode matches the stage’s ShaderTagId and executes the first matching shader pass.
Internally, URP renders objects by calling something conceptually like:
- “Draw all visible objects using ShaderTagId = UniversalForward”
- Then later: “Draw all visible objects using ShaderTagId = ShadowCaster”
So LightMode in the shader is less a “lighting mode” and more like a stage-selection key.
BiRP vs URP Example: Second Additive Rim Pass
To illustrate this, consider a rim light implemented as a second additive pass. Assume Object A and Object B use the same material (same shader), but different meshes.
BiRP style shader
SubShader
{
Pass
{
Name "BaseOpaque"
// regular shading
}
Pass
{
Name "RimLight"
Blend One One
// additive rim lighting shading
}
}
BiRP execution model
In the Built-in pipeline, each object often appears to execute all of its passes before moving to the next object:
- Render Object A using Pass 0, then Pass 1
- Render Object B using Pass 0, then Pass 1
So in our example, execution looks like this:
Object A
SetPass(Base)
Draw
SetPass(Rim)
Draw
Object B
SetPass(Base)
Draw
SetPass(Rim)
Draw
The problem here is that SetPass is called more often than necessary, even when the same shader pass is reused.
For instance, both Object A and Object B use the identical shader program for the Base pass. However, because the renderer switches to a different shader program (Rim) in between, it must switch back to Base again for Object B, which forces additional SetPass calls.
Each SetPass invokes additional CPU work to bind a different shader program and update GPU pipeline state. Of course, this per-object multi-pass flow is intuitive. But it can introduce extra state churn that scales with pass count × object count.
The same shader structure in URP
Here’s the URP equivalent of the same “Base + Rim”:
SubShader
{
Pass
{
Name "Base"
Tags { "LightMode"="UniversalForward" }
// base shading
}
Pass
{
Name "RimLight"
Tags { "LightMode"="MyRimLightStage" }
Blend One One
// rim shading
}
}
At first glance, this appears equivalent. However, URP does not iterate through passes sequentially.
URP execution model: grouped by stage
Let’s assume you added a custom rim-light stage (MyRimLightStage) after the forward opaque stage using a Renderer Feature.
Instead of “render one object through all its passes,” URP renders the frame as a sequence of pipeline stages, where each stage selects one pass via LightMode/ShaderTagId and draws all matching objects together:
Forward Opaque stage (ShaderTagId = UniversalForward)
SetPass(Base)
Draw A
Draw B
Custom Rim stage (ShaderTagId = MyRimLightStage)
SetPass(RimLight)
Draw A
Draw B
This achieves the same visual layering, but with a key structural benefit:
- All Base draws happen together
- All RimLight draws happen together
Meaning fewer pass/program switches and a render order that is much more compatible with SRP’s batching philosophy (and the SRP Batcher’s design goals).
URP intentionally groups draw calls into the same shader pass, so that many objects can be rendered with the same GPU state, minimizing CPU overhead by reducing disruptive state changes.
Why “Adding a Second Pass” Often Does Nothing in URP
At this point, we now understand why simply adding an additional shader pass won’t work in URP. URP will not render that pass unless the pipeline runs a stage that requests it.
For example, if you add LightMode="MyRimLightStage" to a shader, your shader pass exists, but no predefined render pass(pipeline stage) uses ShaderTagId("MyRimLightStage"). So the shader pass is never selected.
To make that pass run, you must either:
- Fold the effect into an existing URP stage
- Add a new stage that draws objects using LightMode=“MyRimLightStage”
That “stage injection” is exactly what Renderer Features are for.
Side Note: One render pass does not execute multiple shader passes per object
In URP, a render pass typically selects only one shader pass per object.
So in our example, even if you change the Rim pass’s `LightMode` to `“UniversalForward”`, “Base then Rim” still won’t happen automatically within the same opaque stage. The opaque stage will simply pick the first `“UniversalForward”` pass it finds and ignore the rest.
If you want rim lighting without adding an extra render pass, the common approach is to integrate the rim lighting calculation into the same shader pass URP already uses for the opaque stage (i.e., render base + rim in one pass).
SubShader
{
Pass
{
Name "Base + Rim"
Tags { "LightMode"="UniversalForward" }
// base shading...
// calculate rim color...
// return base color + rim color;
}
}
If you want rim lighting as a separate shader pass, then you need a separate pipeline stage that draws the objects again after opaques.
Why render order/sorting change after URP migration?
A very common URP migration pitfall is trying to fix ordering problems using Sorting Order, Render Queue, or Render Queue Range, only to see no effect.
It’s because the pipeline executes multiple render passes per frame, and each render pass selects a shader pass. So sorting rules apply within a render pass(pipeline stage), not across render passes.
For example, in URP, the skybox is rendered by DrawSkybox render pass. DrawSkybox pass runs after DrawOpaqueObjects pass and before DrawTransparent Objects pass.
That means anything drawn in the Opaque render pass will happen before the skybox render pass. No matter how you tweak Sorting Order, Render Queue, you cannot make an opaque stage object render after the skybox, because the pipeline has already committed to running the skybox pass later.
So if you have a migrated effect that used to appear behind the skybox in the Built-in Render Pipeline, the URP fix is to place that rendering work into a render pass that runs after the skybox, or to inject a new Renderer Feature at a later event.
What a Renderer Feature Actually Does
A Renderer Feature is an extension point on URP’s Renderer that can inject one or more render passes into URP’s frame.
It can:
- Insert a new stage at a specific point in the frame
- Decide what to render in that stage (fullscreen vs objects)
- Create/read/write intermediate textures (when needed)
- Request pipeline inputs (depth, normals, etc.) so URP produces them
Two common types of Renderer Features
- Fullscreen pass (post-processing style)
- Reads camera color (and optionally depth/normals)
- Writes back to camera color (or an intermediate)
- Object draw pass (extra geometry stage)
- Draws a filtered set of objects again
- Selects a shader pass using a ShaderTagId (your custom LightMode)
Our rim-light example is type (2). Most “custom post-processing” migrations are type (1).
Quick hands-on: using Render Objects feature
If your goal is a relatively simple “second shader pass” pattern (like our rim-light overlay), you can often solve it with URP’s built-in Render Objects Renderer Feature without writing a custom feature in C#.
The key is to make the Renderer Feature request the same LightMode tag as your shader pass.
Step 1) Add a custom pass to your shader (‘MyRimLightStage’ in this example)
Pass
{
Name "RimLight"
Tags { "LightMode"="MyRimLightStage" }
Blend One One
// rim shading...
}
Step 2) Add Render Objects and point it at MyRimLightStage
- Open your URP Renderer Data (the Renderer asset referenced by your URP Asset).
- Click Add Renderer Feature → Render Objects.
- Set Event to AfterRenderingOpaques (so it runs after the base pass).
- Under Filters, set:
- Queue: Opaque (or Transparent, depending on your target)
- Layer Mask: your target layer(s)
- LightMode Tags: add
MyRimLightStage
Now, this Render Objects stage will draw objects using the first pass in their shader that has LightMode=“MyRimLightStage”.
If you open the Frame Debugger, you’ll see an extra stage inserted into the frame.
Step 3) Decide whether you need an Override Material
You have two valid workflows:
- Use the object’s original material (recommended for our exact example)
- Set Override Mode to None
- Result: the stage draws the same objects with the same material, but using MyRimLightStage pass.
- Use an Override Material (useful for quick prototyping or special cases)
- Enable Overrides → Assign a rim-only material.
- Result: the stage draws the selected objects with the override material instead of the original one.
Minimal Checklist for Writing Your Own Renderer Feature
When you write a Renderer Feature, answer these questions in order:
When should it run? (RenderPassEvent)
Pick an injection point such as:
- BeforeRenderingOpaques
- AfterRenderingOpaques
- BeforeRenderingTransparents
- AfterRenderingTransparents
- BeforeRenderingPostProcessing
- AfterRenderingPostProcessing
What are you rendering?
- Fullscreen effect (blit)
- Or objects
What inputs do you need?
One of URP’s design principles is to avoid creating resources that are not explicitly requested. Therefore, you must explicitly enable the required resources within your URP assets and Renderer Data.
URP only generates _CameraDepthTexture when Depth Texture is enabled
If you need depth or normals, be explicit. In URP, those buffers may not exist unless requested.
Conceptually:
- “I need depth” → pipeline must ensure depth is available at that point
- “I need normals” → pipeline must generate normals (often via a depth-normals pass)
What resources do you read/write?
Renderer Features are explicit pipeline stages, which means you must be clear about:
- What you read (camera color? opaque texture? depth? normals?)
- What you write (back into camera color? into a temporary texture? into a custom target?)
- Whether later stages will actually use what you wrote
A large percentage of “my Renderer Feature does nothing” bugs are resource flow bugs:
- reading from a texture that wasn’t created
- writing to a target that isn’t used afterward
- running at a time when the expected buffer doesn’t contain what you think it contains
Summary
In this article, we learn about the historical context of render pipeline evolution and the difference between Built-in Render Pipeline and URP’s design principles.
URP’s key mental shift is this:
- URP doesn’t “run shader passes.” URP runs pipeline stages, and stages select shader passes.
That’s why:
- Adding a second shader pass often does nothing in URP
- Renderer Features exist: to add stages intentionally
- URP groups draws by stage and shader pass, reducing disruptive state changes (the “SetPass churn” you often see with per-object multi-pass patterns)
What’s next
In the next article, we’ll translate this model into practice by implementing Renderer Features in code.
- Implementing minimal outline Renderer Feature
- Correct way to use Render Graph





