Elytra
Blog游戏引擎

Unity 自定义渲染:Renderer Feature、Custom Pass 与 Render Graph

从 Render Pass 的基本概念出发,理解 URP 的 Renderer Feature / ScriptableRenderPass、HDRP 的 Custom Pass,以及它们与 SRP、Render Graph 之间真正的层级关系。

Unity 的 URP 和 HDRP 已经包含一套完整的渲染流程:阴影、深度、Opaque、Transparent、后处理……大多数项目只要配置材质、灯光与 Volume,就能完成绝大部分画面。

但一旦你想做这些效果,问题就会出现:

  • 只把某一批对象重新绘制一次;
  • 给被墙挡住的角色做 X-Ray;
  • 生成 Outline 所需的 Mask;
  • 输出 Object ID、Selection Buffer 或自定义 Depth;
  • 在透明物体之前插入扫描效果;
  • 在后处理前后运行自己的全屏 Shader;
  • 增加一段 Compute Shader 工作;
  • 让一个效果由多个连续的 GPU 步骤组成。

它们的共同点并不是“需要一个更复杂的 Shader”,而是:默认渲染流程里缺少你需要的那一步。

这正是自定义 Render Pass 要解决的问题。

本文的边界

本文讨论的是:如何扩展 Unity 已经实现好的 URP / HDRP。SRP 如何从零构建一条完整 Render Pipeline,以及 Render Graph 更深入的编译、资源别名和调度机制,会分别留给独立文章。

本文以 Unity 6 时代的 URP Render Graph 工作流为主要参考。Unity 6 / URP 17 将 Render Graph 引入为默认路径;旧式、非 Render Graph 的 ScriptableRenderPass API 仍可通过 Compatibility Mode 使用,但 Unity 已明确说明不会继续开发或改进这条路径。1

1为什么需要“自定义渲染”?

渲染管线本质上不是“执行一个 Shader”,而是一系列按顺序发生的工作。

一个极度简化的帧可能类似:

Loading diagram…

如果你只是想让一个材质“变红”,修改 Shader 就够了;但如果你的需求是:

在 Opaque 结束后,把 Highlight Layer 中的对象再画一次,而且使用另一套材质。

那么 Shader 本身无法决定“在整条渲染管线的哪个时刻,再调度一次绘制”。你需要修改的是 render loop。

类似地,Outline 常常不是一个单独的 Shader 就能完成,而可能需要:

Loading diagram…

这里已经出现了两个独立的渲染步骤。

因此,自定义渲染真正关心的问题是:

如何在已有渲染流程的某个位置,增加一个或多个新的 GPU 工作单元?

URP 和 HDRP 都能做到,但它们暴露给用户的扩展 API 并不相同。

2先理解 Render Pass

在进入 ScriptableRenderPass、CustomPass 和 Render Graph 之前,最好先把 Render Pass 当作一个普通概念理解。

2.1Pass 是渲染流程中的一个工作单元

一个 Pass 可以做很多事,例如:

  • 把一批 Renderer 画进 Color Buffer;
  • 只写 Depth;
  • 把角色画进 Mask Texture;
  • 运行一个 Fullscreen Shader;
  • 运行 Compute Shader;
  • 把临时纹理合成回 Camera Color。

所以一个效果完全可能由多个 Pass 构成:

Outline
├─ Mask Pass
├─ Blur / Edge Detection Pass
└─ Composite Pass

这里“一个 Feature 包含多个 Pass”的设计很自然:Feature 是功能,Pass 是工序。

2.2Render Pass 不等于 Shader Pass

Unity 中“Pass”这个词很容易制造混淆。

ShaderLab 中也有:

SubShader
{
    Pass
    {
        // vertex / fragment shader
    }
}

这个 Pass {} 表示 一个 Shader 可被管线选择和执行的着色通道。

而本文所说的 Render Pass,则是 渲染流程中的工作单元。

二者可以发生关系:一个 Render Pass 在绘制对象时,可能选择 Material 中某个 Shader Pass;但它们并不是同一种抽象。

可以这样记:

名称关注的问题
Shader Pass“这个 Material 用哪组 GPU Shader 状态和程序来画?”
Render Pass“这一阶段要做什么渲染工作?”
Render Graph Pass“这个渲染工作读什么、写什么,与其他工作如何形成依赖?”

3这些机制和 SRP 是什么关系?

SRP,全称 Scriptable Render Pipeline,是 Unity 允许 C# 代码调度和配置渲染命令的一层框架。URP 和 HDRP 都建立在 SRP 之上;开发者也可以基于 SRP 创建自己的 Render Pipeline。2

所以关系不是:

SRP → URP → HDRP

而是:

Loading diagram…

这里最重要的边界是:

ScriptableRendererFeature 和 ScriptableRenderPass 不是 SRP 的统一 API,而是 URP 自己的扩展架构。

HDRP 是另一套 SRP 实现,因此没有义务提供同名的 Renderer Feature。它选择了 CustomPass 体系。

本文真正讨论哪一层?

SRP 回答的是“怎样构建一整条渲染管线”;本文回答的是“当 URP / HDRP 已经替你构建好管线以后,怎样进入其中并增加自己的步骤”。

4URP:Renderer Feature 与 ScriptableRenderPass

URP 的自定义渲染核心可以先压缩成两个类:

ScriptableRendererFeature
        ↓
ScriptableRenderPass

官方当前的 URP 工作流也是:先创建继承自 ScriptableRenderPass 的自定义 Pass,再通过 ScriptableRendererFeature 或 RenderPipelineManager 把它注入帧渲染循环。3

4.1Renderer Feature 是什么?

ScriptableRendererFeature 更像一个功能入口、配置容器和 Pass 组织者。

它最典型的职责是:

  1. 创建 Pass;
  2. 配置 Pass;
  3. 决定哪些 Camera 使用它;
  4. 把一个或多个 Pass 加入 Renderer 的执行序列。

最小骨架如下:

using UnityEngine.Rendering.Universal;

public class HighlightRendererFeature : ScriptableRendererFeature
{
    private HighlightPass _pass;

    public override void Create()
    {
        _pass = new HighlightPass();
        _pass.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
    }

    public override void AddRenderPasses(
        ScriptableRenderer renderer,
        ref RenderingData renderingData)
    {
        renderer.EnqueuePass(_pass);
    }
}

官方 API 中,Create() 用于初始化 Feature 所需资源和 Pass;AddRenderPasses() 则通过 EnqueuePass() 把一个或多个 Pass 加入 Renderer 的渲染序列。Unity 会针对 Camera 调用 AddRenderPasses(),因此这里不适合反复创建昂贵资源。4

4.2ScriptableRenderPass 是什么?

ScriptableRenderPass 才是那道真正的“渲染工序”。

例如:

HighlightRendererFeature
├─ HighlightMaskPass
└─ HighlightCompositePass

Feature 代表“Highlight”这个功能;两个 Pass 分别负责:

  • 将目标对象写进 Mask;
  • 从 Mask 找边缘并合成到屏幕。

这也解释了为什么 Unity 没有简单设计成:

RendererFeature.Execute();

因为一个功能并不等于一次 GPU 工作。

4.3为什么 Feature 和 Pass 要分开?

这个分层带来几个重要好处。

首先是配置与执行分离。LayerMask、材质、开关、质量级别可以属于 Feature,而真正的 Draw / Blit / Compute 留在 Pass。

其次是组合。一个 Feature 可以组织多个 Pass:

public class OutlineFeature : ScriptableRendererFeature
{
    private MaskPass _maskPass;
    private CompositePass _compositePass;

    public override void Create()
    {
        _maskPass = new MaskPass();
        _compositePass = new CompositePass();
    }

    public override void AddRenderPasses(
        ScriptableRenderer renderer,
        ref RenderingData renderingData)
    {
        renderer.EnqueuePass(_maskPass);
        renderer.EnqueuePass(_compositePass);
    }
}

最后是注入时机。Pass 自己可以通过 renderPassEvent 表示大致插入位置,例如 Opaque 之后、Transparent 之前或 Post Process 之后。

5一个完整的 URP 自定义 Pass 是怎样工作的?

先看一个比 Outline 更简单、但结构完整的例子:

把场景中一批对象用 Override Material 再绘制一次。

这种能力本身就能扩展成 Highlight、X-Ray、Selection、Stencil Mark 等许多效果。

5.1第一步:Feature 创建并注入 Pass

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

public class RedrawObjectsFeature : ScriptableRendererFeature
{
    [SerializeField] private Material overrideMaterial;
    [SerializeField] private LayerMask layerMask = ~0;

    private RedrawObjectsPass _pass;

    public override void Create()
    {
        _pass = new RedrawObjectsPass(overrideMaterial, layerMask)
        {
            renderPassEvent = RenderPassEvent.AfterRenderingOpaques
        };
    }

    public override void AddRenderPasses(
        ScriptableRenderer renderer,
        ref RenderingData renderingData)
    {
        renderer.EnqueuePass(_pass);
    }

    private sealed class RedrawObjectsPass : ScriptableRenderPass
    {
        private readonly Material _overrideMaterial;
        private readonly LayerMask _layerMask;

        public RedrawObjectsPass(Material overrideMaterial, LayerMask layerMask)
        {
            _overrideMaterial = overrideMaterial;
            _layerMask = layerMask;
        }

        private sealed class PassData
        {
            public RendererListHandle rendererList;
        }

        public override void RecordRenderGraph(
            RenderGraph renderGraph,
            ContextContainer frameData)
        {
            using var builder = renderGraph.AddRasterRenderPass<PassData>(
                "Redraw Highlight Objects",
                out var passData);

            var renderingData = frameData.Get<UniversalRenderingData>();
            var cameraData = frameData.Get<UniversalCameraData>();
            var lightData = frameData.Get<UniversalLightData>();
            var resources = frameData.Get<UniversalResourceData>();

            var filter = new FilteringSettings(
                RenderQueueRange.opaque,
                _layerMask);

            var shaderTag = new ShaderTagId("UniversalForward");

            var drawing = RenderingUtils.CreateDrawingSettings(
                shaderTag,
                renderingData,
                cameraData,
                lightData,
                cameraData.defaultOpaqueSortFlags);

            drawing.overrideMaterial = _overrideMaterial;

            var listParams = new RendererListParams(
                renderingData.cullResults,
                drawing,
                filter);

            passData.rendererList = renderGraph.CreateRendererList(listParams);

            builder.UseRendererList(passData.rendererList);
            builder.SetRenderAttachment(resources.activeColorTexture, 0);
            builder.SetRenderAttachmentDepth(
                resources.activeDepthTexture,
                AccessFlags.Write);

            builder.SetRenderFunc(
                static (PassData data, RasterGraphContext context) =>
                {
                    context.cmd.DrawRendererList(data.rendererList);
                });
        }
    }
}

这段代码和 Unity 官方 RendererList 示例采用同样的核心路径:通过 CullingResults + DrawingSettings + FilteringSettings 构造对象列表,转换成 RendererListHandle,声明 Color / Depth Attachment,再由 DrawRendererList() 执行绘制。5

5.2第二步:理解这段代码,而不是背 API

真正重要的是它表达了四件事。

1. “哪些对象要画?”

var filter = new FilteringSettings(
    RenderQueueRange.opaque,
    _layerMask);

这里先从当前 Camera 已经完成的 Culling 结果中筛选对象。

你可以据此控制:

  • Opaque / Transparent;
  • Layer;
  • Shader Pass / LightMode;
  • 排序规则。

2. “这些对象用什么方式画?”

var drawing = RenderingUtils.CreateDrawingSettings(...);
drawing.overrideMaterial = _overrideMaterial;

这一步决定绘制状态和材质。

因此同一批 Mesh 完全可以在主场景里正常绘制一次,再在自定义 Pass 中用纯色材质重新绘制。

3. “画到哪里?”

builder.SetRenderAttachment(resources.activeColorTexture, 0);
builder.SetRenderAttachmentDepth(
    resources.activeDepthTexture,
    AccessFlags.Write);

这一步把输出资源显式告诉 Render Graph。

4. “真正执行什么 GPU 命令?”

context.cmd.DrawRendererList(data.rendererList);

前面的工作在描述这个 Pass;这里才记录真正的绘制命令。

这正好引出 Unity 6 中非常关键的变化:RecordRenderGraph()。

6从 Execute 到 RecordRenderGraph

如果看过较老的 URP 教程,你很可能见过这样的结构:

public override void Execute(
    ScriptableRenderContext context,
    ref RenderingData renderingData)
{
    var cmd = CommandBufferPool.Get();

    // SetRenderTarget(...)
    // Blit(...)
    // DrawRenderers(...)

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

这种思维方式接近传统命令式渲染:

创建资源
→ 切 Render Target
→ Draw
→ Blit
→ 再切 Render Target
→ 释放资源

问题在于,当渲染流程越来越复杂时,系统很难仅从这些命令判断:

  • 哪个 Pass 真正需要哪个 Texture?
  • 一个临时 Texture 最早什么时候需要创建?
  • 最晚什么时候可以释放或复用?
  • 某个 Pass 的结果没人使用时,可不可以直接删掉?
  • 两个 Pass 之间何时需要 GPU synchronization?
  • 哪些 Pass 可以合并为更高效的原生 Render Pass?

这就是 Render Graph 要解决的问题。

Unity 6 的 URP 17 对自定义 Pass 的写法做了重大调整:默认启用 Render Graph,并建议自定义渲染功能通过 Render Graph API 重写;旧式路径被保留为 Compatibility Mode。1

6.1RecordRenderGraph() 不是 Execute() 的改名

这是最重要的理解。

旧式 Execute() 更像在说:

请按照我给出的顺序执行这些命令。

RecordRenderGraph() 更像在说:

我要注册一个 Pass;它读取这些资源、写入这些资源,真正执行时请调用这个函数。

例如:

using var builder = renderGraph.AddRasterRenderPass<PassData>(
    "My Pass",
    out var passData);

builder.UseTexture(inputTexture, AccessFlags.Read);
builder.SetRenderAttachment(outputTexture, 0);

builder.SetRenderFunc(
    static (PassData data, RasterGraphContext context) =>
    {
        // GPU commands
    });

这形成了两阶段模型:

Loading diagram…

SRP Core 的 Render Graph 文档也把流程描述为:注册 Pass 与资源、编译 Graph、执行 Graph;在编译阶段可以裁掉不贡献最终结果的 Pass,并计算资源生命周期和同步点。6

6.2为什么“显式声明依赖”这么重要?

假设一个 Outline 效果是:

MaskPass:
    Read  = Depth
    Write = Mask

CompositePass:
    Read  = Mask, CameraColor
    Write = CameraColor

Render Graph 看到的是:

Loading diagram…

于是它能知道:

  • Mask 在 MaskPass 之前不需要存在;
  • CompositePass 之后,如果再没人读取 Mask,其物理资源可以回收或复用;
  • 如果 CompositePass 被删掉且没有其他消费者,MaskPass 也可能不再有意义;
  • 某些渲染阶段可以被合并;
  • Graphics / Compute 队列之间的同步可以由系统推导。

Unity 官方对 URP Render Graph 的描述也明确包括:Pass 合并、避免创建未使用资源、裁掉最终结果不需要的 Pass、减少重复资源,以及自动同步 Graphics / Compute 队列。7

Render Graph 的关键不是“Graph”这个名字

真正的设计变化是:从“我手动控制每一步状态”转向“我声明工作与资源依赖,让系统拥有全局优化空间”。

7Renderer Feature、Render Pass 与 Render Graph 到底是什么关系?

这三个词经常一起出现,但它们并不是同一个层次的竞争方案。

可以把它们分别理解为:

层次回答的问题
Renderer Feature“这个自定义功能怎样接入 URP、包含哪些 Pass?”
ScriptableRenderPass“其中这一道渲染工作具体是什么?”
Render Graph“这些 Pass 读写什么资源,彼此如何依赖和调度?”

因此一个 Outline Feature 可以是:

OutlineRendererFeature
├─ MaskPass
│  ├─ Read: Depth
│  └─ Write: Mask
│
└─ CompositePass
   ├─ Read: Mask
   ├─ Read: CameraColor
   └─ Write: CameraColor

Render Graph 并没有取代 Renderer Feature。

如果把 Renderer Feature 删掉,你仍然需要某种机制回答:

“这些自定义 Pass 什么时候、针对哪些 Camera、以什么配置加入 URP?”

反过来,Renderer Feature 也无法替代 Render Graph,因为它不负责全局资源生命周期和依赖优化。

7.1一个 Pass 能否随意读写同一纹理?

Render Graph 会比旧式 CommandBuffer 模型更严格,因为严格正是优化的前提。

例如当前 URP 文档明确指出,在普通 Render Graph Pass 中不能把同一个纹理同时作为读取和写入对象;常见做法是使用临时 Texture,或者在确实需要低层控制时使用 UnsafePass。但 UnsafePass 会减少 URP 能做的优化,例如 Pass 合并。8

这类限制不是单纯“API 变麻烦了”,而是在强迫依赖关系变得可分析。

8URP 中几个典型用例

8.1Outline:一个 Feature,多个 Pass

Outline 是最适合理解整个体系的例子之一。

一种常见方案:

Loading diagram…

在 URP 中它可以自然组织为:

OutlineRendererFeature
├─ MaskPass : ScriptableRenderPass
├─ EdgePass : ScriptableRenderPass
└─ CompositePass : ScriptableRenderPass

每个 Pass 在 RecordRenderGraph() 中声明自己的输入输出。

这样设计的价值并不仅仅是“代码整洁”。对于 Render Graph 来说,三道工作拥有明确边界,它才有机会分析资源生命周期和无效 Pass。

8.2X-Ray:重新绘制被遮挡对象

X-Ray 的核心通常不是复杂光照,而是:

同一个对象,再画一次,但改变 Depth Test、Material 或 Stencil 行为。

流程可能是:

正常场景完成 Opaque
↓
筛选 Character Layer
↓
使用 Override Material 重新 Draw
↓
通过 Depth / Stencil 只保留被遮挡部分

如果需求只涉及“在某个时间重新绘制某些 Layer,并覆盖材质或深度/模板状态”,甚至不必自己实现完整 Renderer Feature;URP 已提供 Render Objects Renderer Feature,可按 Render Queue、Layer Mask、Shader Pass 等筛选对象并覆盖部分渲染状态。9

8.3Object ID Buffer:不要把所有问题都塞进 Camera Color

编辑器 Picking、对象选中描边、语义分割或后续 GPU 识别常常需要一个独立 Buffer:

Object A → ID 1
Object B → ID 2
Object C → ID 3

这时自定义 Pass 的目标不是“直接产生最终画面”,而是生成中间数据:

Loading diagram…

这正是 Render Graph 的优势场景:中间 Texture 的消费者非常明确。

8.4Fullscreen Effect:先看看是否根本不用写代码

如果你的需求只是:

Camera Color
↓
Fullscreen Shader
↓
Camera Color

例如:

  • 黑白;
  • Damage Flash;
  • Glitch;
  • Night Vision;
  • 简单 Scanner Overlay;
  • 自定义后处理;

那么 URP 已有 Full Screen Pass Renderer Feature,可以把全屏材质插入预定义 Injection Point,并按需请求 Color、Depth、Normal 或 Motion 等输入。10

工程上更重要的问题

不要因为“会写 Renderer Feature”就什么都自己写。能用预构建 Render Objects / Full Screen Pass 清晰表达需求时,它们通常比自定义代码更容易维护。

8.5Compute Shader:Render Pass 并不只等于 Raster

Render Graph 中的 Pass 不一定是传统 Draw Call。

一个效果也可能是:

Depth / Normal
↓
Compute Pass
↓
Result Texture
↓
Composite Raster Pass

因此“Render Pass”更适合理解成 一段图形工作单元,而不是“画 Mesh 的步骤”。

9HDRP:为什么没有 Renderer Feature?

理解到这里,再看 HDRP 就很容易了。

HDRP 没有 ScriptableRendererFeature,也没有 URP 的 ScriptableRenderPass,最根本的原因是:

它们本来就是 URP 自己的扩展 API,而不是 SRP 规定的统一接口。

HDRP 自己提供的主要用户扩展机制叫 Custom Pass。当前 HDRP 官方主干文档将其描述为:可以在渲染管线的特定位置注入 Shader 和 C#,用于绘制对象、覆盖对象渲染属性、执行 Fullscreen Pass,以及读取 Color、Depth、Normal、Motion Vector 等 Camera Buffer。11

9.1Custom Pass Volume 到底是什么?需要继承吗?

CustomPass 只描述“这一道自定义渲染工作做什么”,HDRP 还需要另外一层信息:

  • 这组 Pass 在哪个 Injection Point 执行?
  • 它对所有 Camera 生效,还是只在某个空间区域生效?
  • 多组 Custom Pass 同时存在时,执行顺序是什么?
  • 这一组效果包含哪些 CustomPass?

这正是 CustomPassVolume 的职责。它本身是一个 MonoBehaviour,用于承载和调度一组 Custom Pass。12

大多数情况下,你不需要继承 CustomPassVolume。最常见的工作流完全可以在 Editor 中完成:

GameObject
↓
Volume
↓
Custom Pass
↓
Custom Pass Volume

然后在 Inspector 里配置:

Mode
Injection Point
Priority
Custom Passes[]

在 Custom Passes 列表中,你可以直接添加 HDRP 已经提供的 Pass,例如 Draw Renderers、FullScreen 等;只有当这些现成能力表达不了需求时,才需要自己继承 CustomPass 写 C#。11

因此可以先把二者记成:

Custom Pass Volume
= “在哪里、什么时候、对哪些 Camera、以什么顺序执行”

CustomPass
= “这一道自定义渲染具体做什么”

例如,一个始终存在的角色描边,可以使用一个 Global Custom Pass Volume:

Custom Pass Volume
Mode = Global
Injection Point = Before Post Process
│
├─ Draw Renderers Custom Pass
│    └─ 把 Character Layer 写入 Mask / Custom Buffer
│
└─ FullScreen Custom Pass
     └─ 根据 Mask 合成 Outline

而一个只在玩家进入水下区域后生效的效果,可以使用 Local 模式:

Custom Pass Volume
Mode = Local
Collider = 水下区域
Fade Radius = 2 m
Injection Point = Before Post Process
│
└─ FullScreen Custom Pass
     └─ 水下颜色、扭曲或其他屏幕效果

Local Volume 需要空间边界;Camera 进入这个区域后,相应 Custom Pass 才参与渲染。Fade Radius 还可以把距离转换成 Custom Pass 可使用的淡入因子。Global 模式则没有空间边界,可以影响启用了 Custom Pass 的 Camera。12

另一个典型例子是 X-Ray:

Custom Pass Volume
Mode = Global
│
└─ Draw Renderers Custom Pass
     ├─ Layer Mask = Character
     ├─ Override Material = XRay
     └─ Override Depth State

这个场景甚至可能一行自定义 C# 都不需要写:如果 Draw Renderers Custom Pass 已经能表达对象筛选、材质覆盖和 Depth 状态,直接在 Inspector 中配置通常更简单。

先配置,再写代码

HDRP 的 Custom Pass 工作流并不意味着“每个效果都要继承 CustomPass”。先看 Draw Renderers / FullScreen 等现成 Pass 能否表达需求;只有在需要特殊 Draw、Compute、多 Buffer 协作或额外运行时逻辑时,再写自定义 CustomPass。

9.2Global Custom Pass 又是什么?

这里特别容易因为名字相似而混淆。HDRP 里至少要区分两个概念。

9.2.1Custom Pass Volume 的 Mode = Global

它仍然是一个普通的场景 GameObject:

Scene
└─ GameObject
   └─ Custom Pass Volume
       Mode = Global

“Global”只是表示这个 Volume 没有空间边界,而不是说“不需要 Volume”。Unity 官方文档说明,Global Custom Pass Volume 可以影响场景中的 Camera;Local 模式则通过 Collider 确定空间范围。12

这适合:

  • 某个 Scene 固有的描边;
  • Scene 作者希望直接在 Inspector 调整的全屏效果;
  • 与场景内容一起保存、一起编辑的自定义渲染。

9.2.2Global Custom Pass API

HDRP 还提供真正不依赖 Custom Pass Volume GameObject 的注册方式:Global Custom Pass API。官方说明它可以直接把一个 CustomPass 注册进 render loop,而不用修改用户场景或在运行时动态创建 Custom Pass Volume。13

例如:

var pass = new MyCustomPass();

CustomPassVolume.RegisterGlobalCustomPass(
    CustomPassInjectionPoint.BeforePostProcess,
    pass,
    priority: 0f);

这种方式更适合:

  • 渲染插件;
  • 项目级 Outline / Selection 系统;
  • Debug Visualization;
  • 不希望每个 Scene 都手动放一个 GameObject 的基础设施。

所以这三个概念最好分开:

机制有无场景 GameObject主要用途
Custom Pass Volume, Mode = Global有Scene 中全局生效、Inspector 配置
Custom Pass Volume, Mode = Local有与空间区域绑定的效果
Global Custom Pass API无由代码或插件直接注册项目级 Pass

它们最终承载的工作单元仍然都是 CustomPass:

Loading diagram…

因此,如果一定要和 URP 做近似映射,可以写成:

URP                              HDRP

Renderer Feature          ≈      Custom Pass Volume 的部分组织职责
ScriptableRenderPass      ≈      CustomPass
renderPassEvent           ≈      Injection Point

但这只是工作流上的类比,不是 API 一一对应。尤其是 Global Custom Pass API 说明 HDRP 并不要求所有 Custom Pass 都必须经过场景里的 Volume。

9.3HDRP 的 CustomPass 长什么样?

当前 HDRP 官方主干文档提供的 C# 模板仍然是:

using UnityEngine.Rendering;
using UnityEngine.Rendering.HighDefinition;

class MyCustomPass : CustomPass
{
    protected override void Setup(
        ScriptableRenderContext renderContext,
        CommandBuffer cmd)
    {
    }

    protected override void Execute(CustomPassContext ctx)
    {
        // Draw / Fullscreen / Compute commands
    }

    protected override void Cleanup()
    {
    }
}

三个入口的职责非常直接:

  • Setup():分配长期需要的资源;
  • Execute():描述这个 Custom Pass 在当前 Camera 上执行什么;
  • Cleanup():释放 Setup() 中创建的资源。14

CustomPassContext 则给 Execute() 提供 Command Buffer、Camera Buffer、Culling Results、ScriptableRenderContext、HDRP Camera 数据等运行时上下文。

一个极简的 Fullscreen 思路可以写成:

class TintCustomPass : CustomPass
{
    public Material material;

    protected override void Execute(CustomPassContext ctx)
    {
        if (material == null)
            return;

        CoreUtils.SetRenderTarget(
            ctx.cmd,
            ctx.cameraColorBuffer,
            ctx.cameraDepthBuffer);

        CoreUtils.DrawFullScreen(ctx.cmd, material);
    }
}

实际项目还需要根据 Injection Point、目标 Buffer、MSAA、XR 与所用 Shader 的约束调整,但它很好地展示了 HDRP API 的编程风格:用户在 Execute() 中记录具体绘制或计算命令。

10HDRP CustomPass 为什么像旧式 ScriptableRenderPass.Execute?

这正是 URP 和 HDRP 今天最容易让人困惑的地方。

URP 的现代扩展入口是:

ScriptableRenderPass
↓
RecordRenderGraph()
↓
声明 Graph Pass 与资源依赖

HDRP 的公开 Custom Pass API 则仍然是:

CustomPass
↓
Execute(CustomPassContext)
↓
Draw / Dispatch / Copy 等具体命令

这里最重要的一点是:这两个函数并不处在相同的抽象层。

10.1Render Graph 是更高层的工作描述

Render Graph 的核心工作不是“直接 Draw”,而是先记录:

有哪些 Pass?
↓
每个 Pass 读取什么资源?
↓
写入什么资源?
↓
彼此有什么依赖?

SRP Core 的 Render Graph 文档把生命周期概括为 Record → Compile → Execute:先记录 Pass 与资源,编译依赖和资源生命周期,最后才执行各个 Pass。6

真正执行某个 Pass 时,才会进一步产生:

Draw
Dispatch
Copy
Set Render Target
...

所以抽象层应该理解为:

Loading diagram…

CommandBuffer、Raster/Compute Command Context 等都位于 Render Graph 下面更具体的执行层,而不是位于它上面。

10.2那 URP 和 HDRP 的差异在哪里?

这里不需要把两条 Pipeline 的内部调用链强行画成同一种结构。对使用者来说,更重要的是它们公开出来的扩展接口不同:

URP
ScriptableRendererFeature
↓
ScriptableRenderPass
↓
RecordRenderGraph()
HDRP
Custom Pass Volume / Global Custom Pass API
↓
CustomPass
↓
Execute(CustomPassContext)

URP 在 Unity 6 的现代工作流中,允许自定义 ScriptableRenderPass 通过 RecordRenderGraph() 直接声明 Render Graph Pass 及其资源依赖。

HDRP 的公开 CustomPass API 则仍然以 Setup / Execute / Cleanup 为主要入口;Execute(CustomPassContext) 中记录具体的 Draw、Dispatch、Copy 等工作。14

这并不表示 HDRP 没有 Render Graph。HDRP 本身同样建立在 Unity 的 Render Graph 基础设施之上,只是 Custom Pass 没有向用户暴露与 URP RecordRenderGraph() 对等的公开入口。15

因此,理解这一节时只需要记住两个层次:

Loading diagram…

Render Graph 描述更高层的 Pass、资源与依赖;真正执行某个 Pass 时,最终仍会落到 Draw、Dispatch、Copy 等具体图形或计算命令。

不必把两套 API 强行一一对应

URP 与 HDRP 都属于 SRP,也都使用 Unity 的 Render Graph 基础设施,但它们面向用户的自定义渲染 API 是分别设计和演进的。理解实际开发流程时,掌握各自公开的扩展入口,比追踪内部每一层如何桥接更有用。

11为什么 URP 和 HDRP 的扩展模型不同?

从设计上,可以把二者的思路粗略概括成:

11.1URP:Renderer + Feature

URP 的心智模型比较像:

Universal Renderer
├─ Built-in Passes
├─ Renderer Feature A
├─ Renderer Feature B
└─ Renderer Feature C

因此“Renderer Feature”这个名字很合理:它是挂在某个 URP Renderer 上的功能扩展。

11.2HDRP:固定的高复杂 Render Loop + Injection Point

HDRP 的内部 render loop 本身就非常复杂,包含大量相互依赖的阶段和中间 Buffer。对用户来说,HDRP 更强调:

你想在哪个已经定义好的位置插进去?这个位置允许你访问哪些 Buffer?

因此它的扩展模型更偏向:

HDRP Render Loop
├─ ...
├─ Injection Point
│  └─ Custom Passes
├─ ...
├─ Injection Point
│  └─ Custom Passes
└─ ...

Injection Point 并不仅仅决定“先后顺序”,还影响当前时刻哪些 Camera Buffer 已经存在、能否读取或写入,以及修改 Depth / Normal 后是否还能被后续 HDRP 阶段利用。HDRP 官方文档也专门把 Injection Point 与可访问 Buffer 的关系作为 Custom Pass 的核心约束来解释。16

因此,HDRP 没有 Renderer Feature 并不是“少了一层功能”,而是选择了另一种扩展边界。

12URP 与 HDRP:怎样建立正确的对应关系?

下面这张表比“Renderer Feature = CustomPass”更准确。

关注点URPHDRP
所属 PipelineUniversal Render PipelineHigh Definition Render Pipeline
功能组织ScriptableRendererFeatureCustomPassVolume / Global Custom Pass API
单个自定义工作单元ScriptableRenderPassCustomPass
大致执行位置RenderPassEventCustom Pass Injection Point
用户参与 Render GraphRecordRenderGraph() 直接描述自定义 Graph Pass / ResourceCustomPass 不暴露对应的 RecordRenderGraph();HDRP 内部构建 Graph
对象重绘RendererList / Render Objects FeatureDraw Renderers Custom Pass / 自定义 CustomPass
全屏效果Full Screen Pass Renderer Feature / 自定义 PassFullscreen Custom Pass / 自定义 CustomPass
Render Graph用户自定义 Pass 可直接录入 GraphHDRP 内部使用 Graph,CustomPass API 仍是 Execute 风格

不要把对应关系看得太死

CustomPassVolume 并不是 HDRP 版 ScriptableRendererFeature 的同名替代物;它们只是都承担了一部分“组织自定义工作并选择注入时机”的职责。真正接近“一道工作单元”的对应关系是 ScriptableRenderPass ↔ CustomPass。

13面对一个效果,应该选择什么?

学习这些 API 的目的不是记住类名,而是能从需求反推正确的扩展层。

需求URP 优先考虑HDRP 优先考虑
单纯全屏 ShaderFull Screen Pass Renderer FeatureFullscreen Custom Pass
重新绘制某些 LayerRender Objects Renderer FeatureDraw Renderers Custom Pass
Outline Mask自定义 ScriptableRenderPassCustomPass
X-Ray / Occluded CharacterRender Objects 或自定义 PassDraw Renderers Custom Pass
Object ID Buffer自定义 Render Graph Pass自定义 CustomPass
多阶段 Blur / Composite一个 Feature + 多个 Pass一个 Volume / Global 注册 + 多个 CustomPass
Compute + Raster 组合Render Graph Compute / Raster PassCustomPass 中 Dispatch + 后续合成,或更深入 HDRP 扩展
只想改 Material 本身的视觉先不要写自定义 Pass先不要写自定义 Pass

13.1只是改表面效果?先别碰 Render Pass

如果需求只是:

  • 改颜色;
  • 改 BRDF;
  • 加 Dissolve;
  • 改法线;
  • 做顶点动画;

那么 Shader / Shader Graph 往往已经足够。

自定义 Render Pass 的信号通常是:

你需要“额外画一次”“额外生成一个 Buffer”“换一个时机画”“在两个阶段之间插一段工作”。

13.2只重画一批对象?先看预构建 Feature / Pass

如果需求是:

Layer A
↓
Override Material
↓
在某个时间再画一次

优先检查:

  • URP:Render Objects Renderer Feature;
  • HDRP:Draw Renderers Custom Pass。

13.3只是全屏处理?优先用 Fullscreen 工具

如果你的算法本质是:

Cout(x,y)=f(Cin(x,y),D(x,y),N(x,y),…)C_{out}(x,y)=f(C_{in}(x,y),D(x,y),N(x,y),\ldots)

而不需要重新遍历并绘制某些 Mesh,那么它更像 Fullscreen Pass,而不是 Object Draw Pass。

13.4需要多个中间结果?这时 Render Graph 的价值开始明显

例如:

Depth
↓
Downsample
↓
Compute Blur
↓
Temporal Accumulation
↓
Composite

Pass 越多、中间资源越多,显式描述依赖就越有价值。

14常见误区

14.1“Renderer Feature 就是 Render Pass”

不是。

Renderer Feature = 功能 / 配置 / Pass 组织
ScriptableRenderPass = 一道工作

一个 Feature 可以包含多个 Pass。

14.2“Render Graph 取代了 Renderer Feature”

也不是。

Render Graph 解决的是 Pass 与资源的描述、生命周期和调度;Renderer Feature 解决的是自定义功能如何进入 URP。

二者处在不同层。

14.3“Render Graph Pass 就等于 ScriptableRenderPass”

也不能简单画等号。

ScriptableRenderPass 是 URP 暴露给用户的 Pass 抽象;一个 ScriptableRenderPass.RecordRenderGraph() 内部会向 Render Graph 注册实际的 Raster / Compute / Unsafe 等 Graph Pass。

因此概念上更接近:

ScriptableRenderPass
↓
RecordRenderGraph()
↓
一个或多个 Render Graph Pass

而不是永远严格的一对一。

14.4“HDRP 没有 Renderer Feature,所以自定义能力更弱”

错误。

HDRP 有自己的 Custom Pass 扩展模型;差别是 API 设计与注入方式不同。

14.5“HDRP 使用 Render Graph,所以 CustomPass 一定有 RecordRenderGraph”

也错误。

HDRP 内部使用 Render Graph,但当前公开的 CustomPass C# 模板仍然通过 Execute(CustomPassContext) 暴露用户工作。1415

14.6“SRP Batcher 和 SRP 是同一个东西”

不是。

SRP 是 Scriptable Render Pipeline 架构;SRP Batcher 是针对兼容 Shader 的 CPU 侧渲染状态/数据提交优化。名字相近,但本文讨论的 Renderer Feature、Custom Pass、Render Graph 与“是否启用 SRP Batcher”不是同一个问题。

15最后建立一张完整的心智地图

理解这套体系时,需要区分两个维度:公开扩展 API 的结构,以及渲染工作的抽象层级。

15.1公开扩展 API 的结构

Loading diagram…

这张图回答的是:开发者从哪里进入 URP / HDRP,自定义工作由哪些公开类型组织?

15.2渲染工作的抽象层级

Loading diagram…

这张图回答的是:一次渲染工作从高层依赖描述,到真正发出 GPU 命令,抽象层如何逐步降低?

URP 的 RecordRenderGraph() 直接面向 Render Graph 的描述阶段;HDRP 的公开 CustomPass API 则主要让开发者在 Execute(CustomPassContext) 中记录具体渲染工作。这里不需要把二者强行画成同一条内部调用链。

这里可以得到几个最终结论。

第一,SRP 是框架,URP / HDRP 是两种具体实现。Renderer Feature 并不是 SRP 标准,而是 URP 自己的扩展体系。

第二,Renderer Feature 和 ScriptableRenderPass 不是同一个层次。Feature 组织功能,Pass 表示工作单元。

第三,Render Graph 也不是另一种 Renderer Feature。它描述 Pass 与 GPU 资源之间的依赖,并给 Pipeline 留出全局优化空间。

第四,HDRP 的 CustomPass 更接近 URP 的 ScriptableRenderPass,而 Custom Pass Volume 承担了一部分 Feature 式的组织与注入职责。

第五,也是最容易误解的一点:HDRP 内部使用 Render Graph,并不意味着公开的 CustomPass API 必须暴露 RecordRenderGraph()。 当前 HDRP 用户扩展依然主要通过 Execute(CustomPassContext) 工作,而 URP 已经把 Render Graph 更直接地带进 ScriptableRenderPass 的公开编程模型。

从需求出发,而不是从 API 出发

当你遇到一个新效果时,先问:它只是 Shader 变化,还是需要增加新的渲染步骤?如果需要新步骤,再问:它是重绘对象、全屏处理、生成中间 Buffer,还是多个 Pass 的组合?最后才去选择 Renderer Feature、ScriptableRenderPass、Custom Pass 或 Render Graph API。

如果继续向下追问“URP 和 HDRP 自己又是怎么通过 RenderPipeline、ScriptableRenderContext、Culling、RendererList 和 CommandBuffer 构成一整条管线的”,那已经进入 SRP 本身;而如果继续追问 Render Graph 如何做 Pass Culling、资源生命周期分析、Transient Resource 复用和同步,则是另一篇更专门的主题。

这两个方向,也正好构成理解 Unity Rendering Architecture 的下一层。

注释

  1. Unity Manual,Upgrade to URP 17 (Unity 6)。官方说明 URP 17 引入 Render Graph,Unity 6 新 URP 项目默认启用它,同时保留 Compatibility Mode;非 Render Graph 路径不再继续开发或改进。 ↩ ↩2

  2. Unity Manual,Scriptable Render Pipeline fundamentals。官方说明 SRP 是允许 C# 调度和配置渲染命令的薄 API 层,并明确指出 URP、HDRP 都建立在 SRP 之上。 ↩

  3. Unity Manual,Custom render pass workflow in URP。官方工作流要求自定义 Pass 继承 ScriptableRenderPass,并通过 Renderer Feature 或 RenderPipelineManager 注入帧循环。 ↩

  4. Unity Manual,Scriptable Renderer Feature API reference。该页说明 Create、AddRenderPasses、SetupRenderPasses 等方法的职责,以及 AddRenderPasses 针对 Camera 的调用方式。 ↩

  5. Unity Manual,Draw objects in the render graph system in URP。官方示例展示了 RendererListParams、RendererListHandle、UseRendererList、Color/Depth Attachment 与 DrawRendererList 的组合方式。 ↩

  6. Unity Graphics / SRP Core,Render graph system in SRP Core。官方仓库说明 Render Graph 的记录、编译、执行阶段,以及 Pass Culling、资源生命周期和同步点分析。 ↩ ↩2

  7. Unity Manual,Introduction to the render graph system in URP。官方列出了 URP 可基于 Render Graph 执行的 Pass 合并、无用资源避免、无用 Pass 裁剪、资源复用与 Graphics/Compute 队列同步等优化。 ↩

  8. Unity Manual,Use a texture in a render pass in URP 与 Use compatibility-mode APIs in a render graph pass。官方说明普通 Render Graph Pass 对纹理读写与 Render Target 切换的约束,以及 UnsafePass 会削弱部分优化能力。 ↩

  9. Unity Manual,Render Objects Renderer Feature reference。官方说明该 Feature 可按 Render Queue、Layer Mask、Shader Pass 等筛选对象,并覆盖部分渲染行为。 ↩

  10. Unity Manual,Full Screen Pass Renderer Feature reference。官方说明 Full Screen Pass 可在预定义 Injection Point 注入全屏效果,并按需请求 Depth、Normal、Color、Motion 等输入。 ↩

  11. Unity Technologies Graphics repository,HDRP feature overview — Custom Passes。当前官方主干文档说明 Custom Pass 可在 HDRP 的指定位置注入 C# / Shader、绘制对象、覆盖渲染属性、执行全屏 Pass 并读取 Camera Buffer。 ↩ ↩2

  12. Unity Technologies Graphics repository,Understand custom pass volumes 与 Custom Pass reference。官方主干文档说明 Global / Local、Injection Point、Priority 等 Custom Pass Volume 行为。 ↩ ↩2 ↩3

  13. Unity HDRP Documentation 17.0,Global Custom Pass API 与 CustomPassVolume API。官方说明 Global Custom Pass API 可以在不创建 Custom Pass Volume GameObject、不修改用户场景的情况下,通过 RegisterGlobalCustomPass 等 API 把 CustomPass 注册到指定 Injection Point;这与 Custom Pass Volume 的 Mode = Global 是两个不同概念。 ↩

  14. Unity Technologies Graphics repository,Create a Custom Pass in a C# script。当前官方主干模板公开 Setup、Execute(CustomPassContext) 与 Cleanup 三个入口,并说明 Execute 用于描述 HDRP 在 Custom Pass 中渲染什么。 ↩ ↩2 ↩3

  15. Unity Technologies Graphics repository,HDRP feature overview — Render Graph。当前官方主干文档明确写明 HDRP 内部使用 Render Graph API 描述 render loop,并利用它优化 GPU Memory 与裁剪未使用 Pass。 ↩ ↩2

  16. Unity Manual,HDRP Custom Pass Injection Points。该官方文档说明 Injection Point 会影响 Custom Pass 执行时机以及各 Camera Buffer 的可读写状态;具体 Injection Point 集合会随 HDRP 版本演进,应以当前项目所用 HDRP 包文档为准。 ↩

CONTINUE EXPLORING

沿着这篇文章继续

在地图中查看

On this page

1为什么需要“自定义渲染”?2先理解 Render Pass2.1Pass 是渲染流程中的一个工作单元2.2Render Pass 不等于 Shader Pass3这些机制和 SRP 是什么关系?4URP:Renderer Feature 与 ScriptableRenderPass4.1Renderer Feature 是什么?4.2ScriptableRenderPass 是什么?4.3为什么 Feature 和 Pass 要分开?5一个完整的 URP 自定义 Pass 是怎样工作的?5.1第一步:Feature 创建并注入 Pass5.2第二步:理解这段代码,而不是背 API1. “哪些对象要画?”2. “这些对象用什么方式画?”3. “画到哪里?”4. “真正执行什么 GPU 命令?”6从 Execute 到 RecordRenderGraph6.1RecordRenderGraph() 不是 Execute() 的改名6.2为什么“显式声明依赖”这么重要?7Renderer Feature、Render Pass 与 Render Graph 到底是什么关系?7.1一个 Pass 能否随意读写同一纹理?8URP 中几个典型用例8.1Outline:一个 Feature,多个 Pass8.2X-Ray:重新绘制被遮挡对象8.3Object ID Buffer:不要把所有问题都塞进 Camera Color8.4Fullscreen Effect:先看看是否根本不用写代码8.5Compute Shader:Render Pass 并不只等于 Raster9HDRP:为什么没有 Renderer Feature?9.1Custom Pass Volume 到底是什么?需要继承吗?9.2Global Custom Pass 又是什么?9.2.1Custom Pass Volume 的 Mode = Global9.2.2Global Custom Pass API9.3HDRP 的 CustomPass 长什么样?10HDRP CustomPass 为什么像旧式 ScriptableRenderPass.Execute?10.1Render Graph 是更高层的工作描述10.2那 URP 和 HDRP 的差异在哪里?11为什么 URP 和 HDRP 的扩展模型不同?11.1URP:Renderer + Feature11.2HDRP:固定的高复杂 Render Loop + Injection Point12URP 与 HDRP:怎样建立正确的对应关系?13面对一个效果,应该选择什么?13.1只是改表面效果?先别碰 Render Pass13.2只重画一批对象?先看预构建 Feature / Pass13.3只是全屏处理?优先用 Fullscreen 工具13.4需要多个中间结果?这时 Render Graph 的价值开始明显14常见误区14.1“Renderer Feature 就是 Render Pass”14.2“Render Graph 取代了 Renderer Feature”14.3“Render Graph Pass 就等于 ScriptableRenderPass”14.4“HDRP 没有 Renderer Feature,所以自定义能力更弱”14.5“HDRP 使用 Render Graph,所以 CustomPass 一定有 RecordRenderGraph”14.6“SRP Batcher 和 SRP 是同一个东西”15最后建立一张完整的心智地图15.1公开扩展 API 的结构15.2渲染工作的抽象层级注释