GameTech/URP 渲染实战
CHAPTER 25第 25 章

Renderer Feature 与 RTHandle

前面二十多章,我们写的都是“挂在材质上的 Shader”:物体被画的时候它才运行。这一章换一个角度——不改任何物体的材质,而是往 URP 每一帧的渲染流程里插入自己的步骤:全屏改色、只给某一层物体描边、把一组物体单独画到一张纹理里再合成回来。做这些事的入口就是 Renderer Feature。

本章你会学到
ScriptableRendererFeature / ScriptableRenderPass 的完整生命周期与调用时机
RTHandle、ReAllocateIfNeeded、Blitter 与 Blit.hlsl 的正确用法
URP 自带的 Full Screen Pass 与 Render Objects 两个“免代码” Feature
用 DrawRenderers 只重画某个 Layer:遮罩、描边、离屏合成,以及 URP 17 Render Graph 的变化

25.1 Renderer Feature 在 URP 里的位置

先回忆第 2 章的结构:项目用的 URP Asset 里有一个 Renderer 列表,每个相机选其中一个 Renderer Data(通常是 Universal Renderer Data)。运行时,URP 根据 Renderer Data 创建一个 ScriptableRenderer 对象(具体是 UniversalRenderer),由它决定这一帧要执行哪些 Pass:阴影、深度预渲染、不透明物体、天空盒、透明物体、后处理、最终拷贝……每个 Pass 都是一个 ScriptableRenderPass 子类的实例。

Renderer Data 的 Inspector 最下面有一个 Add Renderer Feature 按钮,这里添加的每一项都是一个 ScriptableRendererFeature。它本身不画任何东西,它的职责是:在每个相机渲染前,决定要不要往 Renderer 的 Pass 队列里塞几个自己的 Pass。URP 自带的 SSAO、Decal、Screen Space Shadows、Render Objects、Full Screen Pass 都是这样实现的——它们和你写的 Feature 地位完全相同。

URP AssetRenderer 列表 Renderer DatarendererFeatures Feature AFeature B(你写的) UniversalRenderer运行时对象 创建 AddRenderPassesEnqueuePass 本帧 Pass 队列(按事件排序) MainLightShadowDepthPrepassDrawOpaqueObjectsDrawSkyboxDrawTransparentObjectsPostProcess / FinalBlit Feature A 的 PassFeature B 的 Pass
图 25-1Renderer Feature 的位置:它挂在 Renderer Data 上,每帧把自己的 Pass 放进 Renderer 的队列,和 URP 内置 Pass 一起按 RenderPassEvent 排序执行

和 Built-in 管线对比一下更容易理解。在 Built-in 里,往渲染流程中插东西靠 Camera.AddCommandBuffer(CameraEvent.xxx, cmd),或者在脚本里写 OnRenderImage。这两种写法在 URP 中都不再生效:URP 不调用 OnRenderImage,相机上挂的 CommandBuffer 也不会被执行。所有“插入渲染步骤”的需求,在 URP 里统一走 Renderer Feature。代价是写起来比 OnRenderImage 啰嗦;好处是插入点精确、能声明依赖(需要深度图、法线图)、能被 Frame Debugger 和 Profiler 正确标记,而且可以只对某个 Renderer(比如只对主相机用的那个)生效。

Built-in 管线URP 14 对应写法说明
OnRenderImage(src, dst)Feature + Pass,事件 BeforeRenderingPostProcessingURP 不调用 OnRenderImage
Camera.AddCommandBuffer(CameraEvent.AfterForwardOpaque, cmd)renderPassEvent = AfterRenderingOpaques相机上的 CommandBuffer 在 URP 中不执行
Graphics.Blit / cmd.Blit,源纹理叫 _MainTexBlitter.BlitCameraTexture,源纹理叫 _BlitTexture全屏三角形,支持 RTHandle 缩放与 XR
cmd.GetTemporaryRT / ReleaseTemporaryRTRenderingUtils.ReAllocateIfNeeded + RTHandle.Release持有句柄跨帧复用,Dispose 时释放
Camera.depthTextureMode |= DepthNormalsConfigureInput(Depth | Normal)由 Pass 声明需求,URP 决定如何生成
替换着色器 Camera.RenderWithShaderDrawRenderers + overrideMaterial见 25.5 节

25.2 一帧之内的调用顺序

写 Feature 最容易出错的地方不是 Shader,而是“这个东西该在哪个回调里做”。URP 14 给 Feature 和 Pass 提供了一串虚函数,它们的调用时机差别很大。下面先给出源码层面的顺序(摘自 URP 14 Runtime/UniversalRenderPipeline.cs 的 RenderSingleCamera 与 Runtime/ScriptableRenderer.cs 的 Execute,删去了与本题无关的代码):

// UniversalRenderPipeline.RenderSingleCamera(节选)
renderer.Clear(cameraData.renderType);
renderer.OnPreCullRenderPasses(in cameraData);        // → 每个 Feature.OnCameraPreCull
var cullResults = context.Cull(ref cullingParameters);
InitializeRenderingData(asset, ref cameraData, ref cullResults, cmd, out var renderingData);
renderer.AddRenderPasses(ref renderingData);          // → 每个 Feature.AddRenderPasses
renderer.Setup(context, ref renderingData);           // UniversalRenderer 决定中间纹理、深度/法线预渲染、入队内置 Pass
renderer.Execute(context, ref renderingData);

// ScriptableRenderer.Execute(节选)
m_IsPipelineExecuting = true;
SetupRenderPasses(in renderingData);                  // → 每个 Feature.SetupRenderPasses
InternalStartRendering(context, ref renderingData);   // → 每个 Pass.OnCameraSetup
SortStable(m_ActiveRenderPassQueue);                  // 按 renderPassEvent 稳定排序
foreach (var pass in activeRenderPassQueue)
    pass.Configure(cmd, cameraData.cameraTargetDescriptor);
// …按事件分块,依次 ExecuteRenderPass → 每个 Pass.Execute
InternalFinishRendering(...);                         // → Pass.OnCameraCleanup,栈中最后一个相机再调 OnFinishCameraStackRendering

把它画成时序图,并标出 Feature 级别只调用一次的 Create / Dispose:

URP 管线FeaturePass Create():建材质、new Pass OnEnable / 建 Renderer 每个相机、每帧 剔除前OnCameraPreCull Cull 之后AddRenderPasses→ EnqueuePass、ConfigureInput renderer.Setup Execute 开始SetupRenderPasses相机目标此时可用 OnCameraSetup Configure Execute OnCameraCleanup OnFinishCameraStackRendering (相机栈最后一个相机) Dispose():释放 RT、材质 Renderer 销毁时
图 25-2URP 14 中 Feature 与 Pass 各回调的调用顺序。虚线框内的部分对每个相机、每一帧都会执行一次

下表把每个回调“适合做什么、不能做什么”列清楚,后面所有案例都按这张表来写:

回调调用频率适合做不要做
Feature.CreateOnEnable、Inspector 改值(OnValidate)、Renderer 被创建时创建材质、new 出 Pass 对象假设它只调一次;分配和屏幕尺寸相关的 RT
Feature.AddRenderPasses每相机每帧按相机类型/设置/Volume 决定是否入队;设置 renderPassEvent;调用 ConfigureInput读取 cameraColorTargetHandle(会报错并返回 null)
Feature.SetupRenderPasses每相机每帧把 cameraColorTargetHandle / cameraDepthTargetHandle 交给 Pass在这里才 ConfigureInput(本帧的 Setup 已经算完,晚了一帧)
Pass.OnCameraSetup每相机每帧(Execute 开始时,对队列里所有 Pass)ReAllocateIfNeeded 分配 RT;ConfigureTarget / ConfigureClear录制绘制命令
Pass.Configure每相机每帧(排序之后)旧写法的兼容入口,作用同上——新代码用 OnCameraSetup 即可
Pass.Execute每相机每帧录制命令:Blit、DrawRenderers、SetGlobalXxx分配/释放持久资源
Pass.OnCameraCleanup每相机每帧释放“只活一帧”的临时资源(如 cmd.GetTemporaryRT 的旧写法)释放 RTHandle(下一帧还要复用)
Feature.DisposeRenderer 被销毁时释放所有 RTHandle、销毁材质——忘了写就会泄漏显存
注意Create() 被调用的次数比直觉多。ScriptableRendererFeature 的 OnEnable 和 OnValidate 都直接调用 Create(),而 ScriptableRenderer 的构造函数又会对每个 Feature 调一次 Create()(见 ScriptableRenderer.cs 构造函数里的 feature.Create())。在 Inspector 里拖一下滑条就会触发一次。所以 Create() 里必须先清理上一次创建的材质和 Pass,否则每改一次参数泄漏一份。本章所有 Feature 的 Create() 第一行都是先 Dispose 旧资源。

RenderPassEvent:Pass 插在哪里

renderPassEvent 决定 Pass 在队列里的位置。它是一个整数枚举,内置值之间刻意留了间隔(源码注释写明“Spaced built-in events so we can add events in between them”),所以可以写 RenderPassEvent.AfterRenderingOpaques + 1 这样的值,表示“紧跟在不透明之后、但在同一事件的其他 Pass 之后”。URP 用稳定排序(SortStable),事件值相同的 Pass 保持入队顺序——也就是 Renderer Data 里 Feature 列表的先后。

事件值此时画面上有什么 / 典型用途
BeforeRendering0相机矩阵尚未设置;生成与相机无关的输入(LUT、自定义阴影图)
BeforeRenderingShadows / AfterRenderingShadows50 / 100阴影图前后
BeforeRenderingPrePasses / AfterRenderingPrePasses150 / 200深度预渲染(Depth/DepthNormals Prepass)前后
BeforeRenderingGbuffer … AfterRenderingDeferredLights210–240只在延迟路径下有意义
BeforeRenderingOpaques / AfterRenderingOpaques250 / 300不透明物体前后;“重画某层物体”常用 300
BeforeRenderingSkybox / AfterRenderingSkybox350 / 400天空盒前后
BeforeRenderingTransparents / AfterRenderingTransparents450 / 500透明物体前后;描边、覆盖类效果常用 500
BeforeRenderingPostProcessing550场景已画完,仍是 HDR 线性颜色;自定义后处理首选
AfterRenderingPostProcessing600URP 后处理(含色调映射)之后,颜色已是 LDR;风格化滤镜、UI 前的最终处理
AfterRendering1000所有渲染结束(含最终拷贝)

案例 25.1最小可用的 Feature:全屏反色、去色与染色入门单独打开 ↗

目标:写一个可以直接放进项目的全屏颜色效果:可调的反色、去色和染色。通过这个案例把“Feature + Pass + 全屏 Shader”的标准骨架完整走一遍,后面所有案例都在它的基础上变化。

原理:全屏效果的本质是“读相机颜色 → 每个像素算一次 → 写回相机颜色”。GPU 上一张纹理不能在同一次绘制里既当输入又当输出(结果未定义,在部分平台上会直接出错),所以要借一张临时 RT 中转:

相机颜色cameraColorTargetHandle 临时 RTm_Temp(RTHandle) ① BlitCameraTexture(cmd, color, temp, 材质, 0) ② BlitCameraTexture(cmd, temp, color) 原样拷回 ③ CoreUtils.SetRenderTarget(cmd, color, depth) 恢复渲染目标
图 25-3“拷出去处理、再拷回来”:读写分离的最基本模式

先看 Shader。全屏 Pass 不需要自己写顶点着色器:core 包的 Runtime/Utilities/Blit.hlsl 已经提供了 Vert,它生成一个覆盖全屏的三角形,并把输入纹理统一命名为 _BlitTexture(25.4 节详细拆解)。我们只写片元着色器:

Assets/Cookbook/Ch25/Tint.shader
// 案例 25.1:最小的全屏效果——反色 / 去色 / 染色。
// 顶点着色器直接用 core 包 Blit.hlsl 里的 Vert(全屏三角形),输入纹理名固定为 _BlitTexture。
Shader "Cookbook/Ch25/Tint"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
        // 全屏 Pass 的标配:不测深度、不写深度、不剔除
        ZTest Always ZWrite Off Cull Off

        Pass
        {
            Name "Tint"

            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            // Blit.hlsl 提供:_BlitTexture、_BlitScaleBias、Attributes/Varyings、Vert()、sampler_LinearClamp 等
            #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

            // 这些参数由 C# 每帧写入材质,不放 Properties 块
            float4 _TintColor;    // 乘色
            float  _Invert;       // 0..1 反色程度
            float  _Desaturate;   // 0..1 去色程度

            half4 Frag(Varyings input) : SV_Target
            {
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
                float2 uv = input.texcoord;
                half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);

                // 1) 去色:用 Rec.709 亮度权重(相机颜色缓冲是线性空间)
                half luma = dot(color.rgb, half3(0.2126, 0.7152, 0.0722));
                color.rgb = lerp(color.rgb, luma.xxx, _Desaturate);

                // 2) 反色:在 sRGB(感知)空间里做 1-x 才像“底片”,在线性空间直接反会整体发白
                half3 srgb = LinearToSRGB(saturate(color.rgb));
                half3 inverted = SRGBToLinear(1.0 - srgb);
                color.rgb = lerp(color.rgb, inverted, _Invert);

                // 3) 染色
                color.rgb *= _TintColor.rgb;
                return color;
            }
            ENDHLSL
        }
    }
}
// 案例 25.1:最小的全屏效果——反色 / 去色 / 染色。
// 顶点着色器对应 core 包 Blit.hlsl 里的 Vert(全屏三角形),输入纹理名固定为 _BlitTexture。
Shader "Cookbook/Ch25/Tint"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
        // 全屏 Pass 的标配:不测深度、不写深度、不剔除
        ZTest Always ZWrite Off Cull Off

        Pass
        {
            Name "Tint"

            GLSLPROGRAM
            // _BlitTexture、_BlitScaleBias 由引擎(Core.glsl)提供,相当于 Blit.hlsl
            VARYING vec2 texcoord;
            #ifdef VERTEX
            // Blit.hlsl 的 Vert:用 gl_VertexID 生成一个盖住全屏的大三角形
            void main()
            {
                vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
                texcoord = p * _BlitScaleBias.xy + _BlitScaleBias.zw;
                gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
            }
            #endif

            #ifdef FRAGMENT
            // 这些参数由 C# 每帧写入材质,不放 Properties 块
            uniform vec4  _TintColor;    // 乘色
            uniform float _Invert;       // 0..1 反色程度
            uniform float _Desaturate;   // 0..1 去色程度

            vec4 Frag()
            {
                vec2 uv = texcoord;
                vec4 color = texture(_BlitTexture, uv);

                // 1) 去色:用 Rec.709 亮度权重(相机颜色缓冲是线性空间)
                float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
                color.rgb = mix(color.rgb, vec3(luma), _Desaturate);

                // 2) 反色:在 sRGB(感知)空间里做 1-x 才像“底片”,在线性空间直接反会整体发白
                vec3 srgb = LinearToSRGB(saturate(color.rgb));
                vec3 inverted = SRGBToLinear(1.0 - srgb);
                color.rgb = mix(color.rgb, inverted, _Invert);

                // 3) 染色
                color.rgb *= _TintColor.rgb;
                return color;
            }
            void main() { fragColor = Frag(); }
            #endif
            ENDGLSL
        }
    }
}

反色这一步值得多看一眼。相机颜色缓冲在线性空间里(项目是 Linear 色彩空间),如果直接 1 - color,中灰 0.18 会变成 0.82,显示出来是接近白色的浅灰——整张图发白、发灰,不像底片。正确做法是先转到感知均匀的 sRGB 空间再取反,再转回线性:LinearToSRGB / SRGBToLinear 来自 core 的 Color.hlsl,Blit.hlsl 已经 include 了它。另外,HDR 颜色可能大于 1,取反前要先 saturate,否则会得到负数。

然后是 Feature。注意它只做三件事:建材质与 Pass、决定是否入队、把相机目标交给 Pass:

Assets/Cookbook/Ch25/TintFeature.cs(节选:feature)
public class TintFeature : ScriptableRendererFeature
{
    [System.Serializable]
    public class Settings
    {
        public RenderPassEvent passEvent = RenderPassEvent.BeforeRenderingPostProcessing;
        public Color tint = Color.white;
        [Range(0, 1)] public float invert = 1f;
        [Range(0, 1)] public float desaturate = 0f;
    }

    public Settings settings = new Settings();
    // 在 Inspector 里拖入 Cookbook/Ch25/Tint。直接引用能保证打包时 Shader 不被剔除;
    // 留空时退回 Shader.Find(只在编辑器或 Shader 已被别处引用时可靠)。
    public Shader shader;

    Material m_Material;
    TintPass m_Pass;

    // Create 在 OnEnable、以及 Inspector 每次改值(OnValidate)时都会被调用,
    // 所以这里必须先清理上一次创建的东西,否则每改一次参数就泄漏一份材质和 RT。
    public override void Create()
    {
        m_Pass?.Dispose();
        CoreUtils.Destroy(m_Material);

        if (shader == null) shader = Shader.Find("Cookbook/Ch25/Tint");
        m_Material = shader != null ? CoreUtils.CreateEngineMaterial(shader) : null;
        m_Pass = new TintPass(m_Material);
    }

    // 每个相机、每帧调用一次:决定这一帧要不要把 Pass 放进队列
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (m_Material == null) return;
        var camType = renderingData.cameraData.cameraType;
        // 材质球预览、反射探针烘焙用的相机不需要全屏效果
        if (camType == CameraType.Preview || camType == CameraType.Reflection) return;

        m_Pass.renderPassEvent = settings.passEvent;
        m_Pass.Setup(settings);
        renderer.EnqueuePass(m_Pass);
    }

    // 相机的颜色/深度目标在这里才已分配好;在 AddRenderPasses 里取会报错
    public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
    {
        m_Pass.SetTarget(renderer.cameraColorTargetHandle, renderer.cameraDepthTargetHandle);
    }

    // Renderer 被销毁(切换管线资产、改 Renderer 列表、退出)时调用
    protected override void Dispose(bool disposing)
    {
        m_Pass?.Dispose();
        CoreUtils.Destroy(m_Material);
    }
}
// 案例 25.1 / 25.2:TintFeature 的网页版(对应 Ch25/TintFeature.cs)。
// 结构和 C# 一样:Feature 负责建材质、按 settings.passEvent 入队;Pass 负责“相机颜色 →(材质)→ 临时 RT → 拷回相机颜色”。
import { Material } from '../engine/shader.js';
import { RenderPassEvent } from '../engine/features.js';

export default class TintFeature {
  constructor(engine, data, scene) {
    this.engine = engine;
    this.scene = scene;
    this.data = data || {};
    // C# Settings 的默认值
    this.settings = { passEvent: RenderPassEvent.BeforeRenderingPostProcessing, tint: [1, 1, 1, 1], invert: 1, desaturate: 0, ...(this.data.settings || {}) };
  }

  async init() {
    // Create():shader 留空时退回 Shader.Find("Cookbook/Ch25/Tint")
    const name = (this.data.shader && this.data.shader.shader) || 'Cookbook/Ch25/Tint';
    this.material = new Material(await this.engine.shaders.get(name));
  }

  addPasses(frame, enqueue) {
    if (!this.material) return;
    const s = this.settings, m = this.material;
    // TintPass.Setup:这些值在一帧内不变,直接写材质
    // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector
    m.setVector('_TintColor', s.tint);
    m.setFloat('_Invert', s.invert);
    m.setFloat('_Desaturate', s.desaturate);
    enqueue(s.passEvent, (fr) => {
      // OnCameraSetup:临时 RT 和相机颜色同尺寸、同格式,不要 MSAA、不要深度
      const temp = fr.getTemporaryRT('_Ch25TintTemp', fr.width, fr.height);
      // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回
      fr.blit(fr.cameraColorTexture(), temp, m, 0);
      fr.blit(temp.texture, 'camera');
      // 恢复相机的颜色 + 深度目标
      fr.bindCamera();
    }, 'Ch25 Tint');
  }
}

逐段说明:

  • Shader 引用:public Shader shader 让使用者在 Inspector 里直接拖入 Shader。这不只是方便——Shader 被资源引用后,打包时才会被包含进去;只靠 Shader.Find 的话,如果没有任何材质用到它,打包后会找不到。教学代码保留了 Shader.Find 兜底,正式项目建议拖引用,或者把 Shader 加进 Project Settings › Graphics › Always Included Shaders。
  • 跳过不需要的相机:CameraType.Preview 是材质球、模型预览窗口的相机,CameraType.Reflection 是反射探针烘焙用的相机。全屏改色效果对它们没有意义,还会让预览窗口里的材质球也被反色。Scene 视图相机(CameraType.SceneView)要不要处理看需求,这里保留,方便在 Scene 视图里直接看到效果。
  • AddRenderPasses 与 SetupRenderPasses 的分工:在 AddRenderPasses 被调用时,UniversalRenderer 还没决定这一帧要不要创建中间颜色纹理,cameraColorTargetHandle 没有意义。源码里的保护是这样的(摘自 URP 14 ScriptableRenderer.cs):
public RTHandle cameraColorTargetHandle
{
    get
    {
        if (!m_IsPipelineExecuting)
        {
            Debug.LogError("You can only call cameraColorTarget inside the scope of a ScriptableRenderPass. ...");
            return null;
        }
        return m_CameraColorTarget.handle;
    }
}

m_IsPipelineExecuting 在 ScriptableRenderer.Execute 的第一行才被置为 true,紧接着就是 SetupRenderPasses。所以 URP 14 的规矩是:相机目标只能在 SetupRenderPasses(或之后的 Pass 回调)里取。很多从 URP 12 迁移过来的代码在 AddRenderPasses 里写 renderer.cameraColorTarget,升级到 14 后就会刷屏报错。

再看 Pass:

Assets/Cookbook/Ch25/TintFeature.cs(节选:pass)
class TintPass : ScriptableRenderPass
{
    static readonly int s_TintColor = Shader.PropertyToID("_TintColor");
    static readonly int s_Invert = Shader.PropertyToID("_Invert");
    static readonly int s_Desaturate = Shader.PropertyToID("_Desaturate");

    readonly Material m_Material;
    RTHandle m_CameraColor, m_CameraDepth;   // 借来的,不归我们释放
    RTHandle m_Temp;                         // 自己分配的,Dispose 里释放

    public TintPass(Material material)
    {
        m_Material = material;
        profilingSampler = new ProfilingSampler("Ch25 Tint");   // Frame Debugger 里显示的名字
    }

    public void Setup(TintFeature.Settings s)
    {
        // 这些值在一帧内不变,直接写材质即可
        m_Material.SetColor(s_TintColor, s.tint);
        m_Material.SetFloat(s_Invert, s.invert);
        m_Material.SetFloat(s_Desaturate, s.desaturate);
    }

    public void SetTarget(RTHandle color, RTHandle depth)
    {
        m_CameraColor = color;
        m_CameraDepth = depth;
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        // 临时 RT 和相机颜色同尺寸、同格式(HDR 时是浮点格式),但不要 MSAA、不要深度
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.msaaSamples = 1;
        desc.depthBufferBits = 0;
        RenderingUtils.ReAllocateIfNeeded(ref m_Temp, desc, FilterMode.Bilinear,
            TextureWrapMode.Clamp, name: "_Ch25TintTemp");
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回
            Blitter.BlitCameraTexture(cmd, m_CameraColor, m_Temp, m_Material, 0);
            Blitter.BlitCameraTexture(cmd, m_Temp, m_CameraColor);
            // BlitCameraTexture 只绑定了颜色。URP 以为相机的颜色+深度还绑着,
            // 后面的天空盒/透明物体会直接画,所以由我们把深度一起恢复回去。
            CoreUtils.SetRenderTarget(cmd, m_CameraColor, m_CameraDepth);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public void Dispose()
    {
        m_Temp?.Release();
        m_Temp = null;
    }
}
// 案例 25.1 / 25.2:TintFeature 的网页版(对应 Ch25/TintFeature.cs)。
// 结构和 C# 一样:Feature 负责建材质、按 settings.passEvent 入队;Pass 负责“相机颜色 →(材质)→ 临时 RT → 拷回相机颜色”。
import { Material } from '../engine/shader.js';
import { RenderPassEvent } from '../engine/features.js';

export default class TintFeature {
  constructor(engine, data, scene) {
    this.engine = engine;
    this.scene = scene;
    this.data = data || {};
    // C# Settings 的默认值
    this.settings = { passEvent: RenderPassEvent.BeforeRenderingPostProcessing, tint: [1, 1, 1, 1], invert: 1, desaturate: 0, ...(this.data.settings || {}) };
  }

  async init() {
    // Create():shader 留空时退回 Shader.Find("Cookbook/Ch25/Tint")
    const name = (this.data.shader && this.data.shader.shader) || 'Cookbook/Ch25/Tint';
    this.material = new Material(await this.engine.shaders.get(name));
  }

  addPasses(frame, enqueue) {
    if (!this.material) return;
    const s = this.settings, m = this.material;
    // TintPass.Setup:这些值在一帧内不变,直接写材质
    // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector
    m.setVector('_TintColor', s.tint);
    m.setFloat('_Invert', s.invert);
    m.setFloat('_Desaturate', s.desaturate);
    enqueue(s.passEvent, (fr) => {
      // OnCameraSetup:临时 RT 和相机颜色同尺寸、同格式,不要 MSAA、不要深度
      const temp = fr.getTemporaryRT('_Ch25TintTemp', fr.width, fr.height);
      // 不能边读边写同一张纹理:先“相机颜色 → 临时 RT(经过材质)”,再拷回
      fr.blit(fr.cameraColorTexture(), temp, m, 0);
      fr.blit(temp.texture, 'camera');
      // 恢复相机的颜色 + 深度目标
      fr.bindCamera();
    }, 'Ch25 Tint');
  }
}
  • 描述符:临时 RT 直接从 cameraData.cameraTargetDescriptor 复制,保证尺寸(已乘以 Render Scale)和格式(HDR 时是浮点格式)与相机颜色一致;再去掉 MSAA 和深度——全屏后处理既不需要多重采样也不需要深度缓冲,留着只会白白占用显存和带宽。
  • ReAllocateIfNeeded:每帧都调用,但只有描述符变化(比如窗口大小改变)时才真正重新分配,详见 25.3 节。
  • 命令缓冲:URP 14 里 RenderingData 内部有一个 commandBuffer 字段,但它是 internal 的,只有 URP 自己的 Pass 能用。我们的 Pass 用 CommandBufferPool.Get() 取一个,录完交给 context.ExecuteCommandBuffer,再还回池子。
  • ProfilingScope:让这段命令在 Frame Debugger 和 Profiler 里显示为 “Ch25 Tint”,名字来自构造函数里设置的 profilingSampler。
  • 最后一行的恢复:这是很多教程漏掉的一步,案例 25.2 会用实验说明为什么必须有它。
Assets/Cookbook/Ch25/Editor/Ch25Shots.cs(节选:shot-tint)
[BookShot("ch25-tint", W = 640, H = 400, Panels = 3)]
static void Tint(Shot s)
{
    Stage(s);
    if (s.Panel == 0) return;                       // 第 1 格:不加 Feature
    var f = s.Feature<TintFeature>();
    if (s.Panel == 1) { f.settings.invert = 1; f.settings.desaturate = 0; }
    else { f.settings.invert = 0; f.settings.desaturate = 1; f.settings.tint = new Color(1.25f, 0.95f, 0.65f); }
}
图 25-4左:没有 Feature 的原图;中:invert = 1(在 sRGB 空间反色,像彩色底片);右:desaturate = 1、tint = (1.25, 0.95, 0.65),去色后染成旧照片的暖褐色

在编辑器里使用:

  1. 在 Project 窗口选中项目使用的 Renderer Data(新建 URP 项目里通常叫 xxx_Renderer)。
  2. Inspector 最下方 Add Renderer Feature › Tint Feature。
  3. 把 Cookbook/Ch25/Tint 拖到 Shader 槽位,调整 Settings。Game 视图和 Scene 视图会立即生效。
  4. 打开 Window › Analysis › Frame Debugger,可以看到 “Ch25 Tint” 下面挂着两次 Draw Procedural(两次 Blit)。
提示URP 14 的 ScriptableRenderPass 还提供了一个 Blit(CommandBuffer cmd, ref RenderingData data, Material material, int passIndex),它利用 UniversalRenderer 内部的双缓冲(_CameraColorAttachmentA/B):从当前颜色缓冲画到另一块,再交换前后台,省掉了“拷回”这一步。它确实更省一次全屏拷贝,但依赖内部的 SwapColorBuffer 行为,调试时也不如显式的临时 RT 直观。本书统一使用显式写法,读者理解原理后可以自行替换。

25.3 RTHandle:URP 14 的渲染纹理句柄

URP 12 之前,Pass 里分配临时纹理的标准写法是 cmd.GetTemporaryRT(id, desc) 加 RenderTargetIdentifier;URP 13 起全面换成了 RTHandle,URP 14 中旧接口大多标了 [Obsolete](例如 ScriptableRenderPass.ConfigureTarget(RenderTargetIdentifier))。RTHandle 来自 core 包(Runtime/Textures/RTHandle.cs),是对 RenderTexture 的一层包装,多了两样东西:

  • 两种尺寸模式:显式尺寸(给定宽高)和缩放尺寸(相对于一个“参考尺寸”的比例或函数)。缩放模式下,RTHandle 系统按所有相机里最大的分辨率分配一次,较小的相机只用其中左下角的一部分,从而避免窗口大小频繁变化时反复分配。URP 在每个相机渲染前调用 RTHandles.SetReferenceSize(width, height) 更新参考尺寸。
  • 与采样相关的缩放信息:因为缩放模式下纹理可能只用了一部分,采样时 UV 要乘一个比例。rtHandleProperties.rtHandleScale 就是这个比例,Blitter.BlitCameraTexture 会自动把它作为 _BlitScaleBias 传给 Shader。

在 Renderer Feature 里我们几乎总是用 RenderingUtils.ReAllocateIfNeeded 来管理 RTHandle。它的核心逻辑(摘自 URP 14 Runtime/RenderingUtils.cs):

public static bool ReAllocateIfNeeded(ref RTHandle handle, in RenderTextureDescriptor descriptor,
    FilterMode filterMode = FilterMode.Point, TextureWrapMode wrapMode = TextureWrapMode.Repeat,
    bool isShadowMap = false, int anisoLevel = 1, float mipMapBias = 0, string name = "")
{
    TextureDesc requestRTDesc = RTHandleResourcePool.CreateTextureDesc(descriptor, TextureSizeMode.Explicit, ...);
    if (RTHandleNeedsReAlloc(handle, requestRTDesc, false))
    {
        if (handle != null && handle.rt != null)
            AddStaleResourceToPoolOrRelease(currentRTDesc, handle);       // 旧的放回池子或释放
        if (UniversalRenderPipeline.s_RTHandlePool.TryGetResource(requestRTDesc, out handle))
            return true;                                                  // 池子里有现成的就复用
        handle = RTHandles.Alloc(descriptor, filterMode, wrapMode, isShadowMap, anisoLevel, mipMapBias, name);
        return true;
    }
    return false;                                                         // 描述符没变:什么都不做
}

要点:

  • 传 ref:第一次调用时 handle 为 null,会被分配;之后每帧调用,只要描述符(宽高、格式、MSAA、深度位数、过滤、寻址、名字)没变就直接返回 false,开销很小。所以放在 OnCameraSetup 里每帧调用是正确用法。
  • 默认过滤是 Point、默认寻址是 Repeat。全屏效果几乎都应该显式传 FilterMode.Bilinear 与 TextureWrapMode.Clamp:模糊、缩放需要双线性;Repeat 会让屏幕边缘采样“绕”到另一边,产生一条奇怪的亮边。
  • name 会显示在 Frame Debugger 和 Memory Profiler 里,起个能认出来的名字,排查显存问题时会感谢自己。
  • 释放:自己分配的 RTHandle 在 Dispose 里调用 handle.Release()(等价于 RTHandles.Release(handle))。相机颜色、深度这些从 Renderer 借来的 RTHandle 绝不能释放。

多相机时的一个细节

同一个 Pass 对象会被多个相机复用(Game 视图相机、Scene 视图相机、相机栈里的每个相机)。如果它们分辨率不同,ReAllocateIfNeeded 会在相机之间来回重新分配——URP 内部的 RTHandle 资源池(s_RTHandlePool)正是为了缓解这种抖动:被替换掉的旧 RT 先进池子,下一个相机要同样规格时直接取回。即便如此,编辑器里同时开 Game 和 Scene 视图时,Feature 的临时 RT 数量翻倍是正常现象,不是泄漏。

25.4 Blitter 与 Blit.hlsl:全屏绘制的标准件

Built-in 时代全屏绘制靠 Graphics.Blit / cmd.Blit,它们会画一个全屏四边形,并把源纹理绑到 _MainTex。在 URP 14 里这两个函数仍能用,但官方推荐改用 core 包的 Blitter(Runtime/Utilities/Blitter.cs),原因有三:它和 RTHandle 的缩放模式配合(自动处理 rtHandleScale);它支持 XR 的纹理数组(TEXTURE2D_X);它画的是一个覆盖全屏的大三角形而不是两个三角形组成的四边形,避免对角线上的像素被重复着色。

屏幕 [-1,1]² id 0:(-1,-1) uv(0,0)id 1:(3,-1) uv(2,0)id 2:(-1,3) uv(0,2) 顶点坐标只由 SV_VertexID 算出: uv = ((id<<1)&2, id&2) pos = uv*2-1 屏幕之外的部分被裁剪掉, 屏幕内每个像素只着色一次, 也不需要任何顶点缓冲。
图 25-5全屏三角形:三个顶点覆盖 [-1,3] 范围,裁剪后恰好铺满屏幕,UV 在屏幕内正好是 0~1

Blit.hlsl 里的 Vert 很短,正好看清所有约定(摘自 core 14 Runtime/Utilities/Blit.hlsl,删去了 GLES 分支):

TEXTURE2D_X(_BlitTexture);
uniform float4 _BlitScaleBias;

struct Attributes { uint vertexID : SV_VertexID; UNITY_VERTEX_INPUT_INSTANCE_ID };
struct Varyings   { float4 positionCS : SV_POSITION; float2 texcoord : TEXCOORD0; UNITY_VERTEX_OUTPUT_STEREO };

Varyings Vert(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
    float4 pos = GetFullScreenTriangleVertexPosition(input.vertexID);
    float2 uv  = GetFullScreenTriangleTexCoord(input.vertexID);
    output.positionCS = pos;
    output.texcoord   = uv * _BlitScaleBias.xy + _BlitScaleBias.zw;
    return output;
}

从中可以读出写全屏 Shader 的全部约定:

  • 输入纹理名固定是 _BlitTexture,用 TEXTURE2D_X 声明、SAMPLE_TEXTURE2D_X 采样。_X 后缀在非 XR 平台上就是普通 2D 纹理,在单通道立体渲染(Single Pass Instanced)下会变成纹理数组,片元着色器开头的 UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input) 负责选对眼睛。不做 XR 也照着写,代价为零。
  • UV 在 Varyings.texcoord 里,已经乘过 _BlitScaleBias。GetFullScreenTriangleTexCoord 内部按 UNITY_UV_STARTS_AT_TOP 翻转了 V,所以在 D3D/Metal/Vulkan 和 OpenGL 上都不需要再手动翻转。
  • 采样器用 GlobalSamplers.hlsl 里预定义的 sampler_LinearClamp、sampler_PointClamp 等,不用为 _BlitTexture 再声明一个 sampler_BlitTexture(URP 自己的 Blit.shader 声明了,但你的 Shader 里不必)。
  • 纹素大小:URP 的 Bloom.shader 里直接声明 float4 _BlitTexture_TexelSize; 并使用。Unity 在通过属性块绑定纹理时会同时填写 名字_TexelSize,本书的模糊、描边 Shader 也沿用这个做法。

C# 侧常用的几个重载:

调用做了什么
Blitter.BlitCameraTexture(cmd, src, dst, mipLevel, bilinear)设置目标为 dst,用内置 Blit 材质原样拷贝(bilinear 选择点/双线性 Pass)
Blitter.BlitCameraTexture(cmd, src, dst, material, pass)设置目标为 dst,用你的材质的第 pass 个 Pass 画全屏三角形,src 绑到 _BlitTexture
Blitter.BlitTexture(cmd, src, scaleBias, material, pass)不切换目标,在当前已绑定的目标上画;适合“叠加到相机颜色上”的混合类 Pass
Blitter.BlitCameraTexture(cmd, src, dst, loadAction, storeAction, material, pass)同上,额外指定加载/存储动作(移动端 TBDR 优化用)
注意Blitter 内部用一个静态的 MaterialPropertyBlock 传 _BlitTexture 和 _BlitScaleBias。属性块在录制时被拷贝,所以多次 Blit 之间互不影响;但你自己用 material.SetFloat 设置的参数不一样——材质属性在命令真正执行时才被读取。同一帧内对同一材质多次 SetFloat,所有绘制都会看到最后一次的值。第 26 章的多次迭代模糊会专门处理这个问题(用 cmd.SetGlobalFloat)。

案例 25.2生命周期探针与 RenderPassEvent 实验入门单独打开 ↗

目标:亲眼看到 25.2 节的调用顺序;再用同一个“去色” Pass 放在三个不同事件上,观察插入点对结果的影响,顺便理解案例 25.1 最后那行“恢复渲染目标”的必要性。

探针 Feature 什么也不画,只在每个回调里打印一行:

Assets/Cookbook/Ch25/LifecycleProbeFeature.cs(节选:probe)
public override void Create()
{
    Log("Feature.Create");
    m_Pass = new ProbePass();
}

public override void OnCameraPreCull(ScriptableRenderer renderer, in CameraData cameraData)
{
    s_Frame++;
    Log($"Feature.OnCameraPreCull  camera={cameraData.camera.name}");
}

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
    Log("Feature.AddRenderPasses");
    m_Pass.renderPassEvent = passEvent;
    m_Pass.ConfigureInput(ScriptableRenderPassInput.Depth);   // 必须在这里声明输入
    renderer.EnqueuePass(m_Pass);
}

public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
{
    // 此时相机目标已分配,可以安全读取
    Log($"Feature.SetupRenderPasses  color={renderer.cameraColorTargetHandle.name}");
}

protected override void Dispose(bool disposing) => Log("Feature.Dispose");

class ProbePass : ScriptableRenderPass
{
    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
        => Log("  Pass.OnCameraSetup");
    public override void Configure(CommandBuffer cmd, RenderTextureDescriptor desc)
        => Log($"  Pass.Configure  {desc.width}x{desc.height} msaa={desc.msaaSamples}");
    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        => Log($"  Pass.Execute  event={renderPassEvent}");
    public override void OnCameraCleanup(CommandBuffer cmd)
        => Log("  Pass.OnCameraCleanup");
    public override void OnFinishCameraStackRendering(CommandBuffer cmd)
        => Log("  Pass.OnFinishCameraStackRendering");
}
// 案例 25.2:生命周期探针的网页版(对应 Ch25/LifecycleProbeFeature.cs)。
// 它什么也不画,只把回调顺序打到浏览器控制台(console.debug),用来观察 Feature / Pass 在一帧里被调用的顺序。
import { RenderPassEvent } from '../engine/features.js';

let s_Frame = 0;
const log = (s) => console.debug(`[帧${s_Frame}] ${s}`);

export default class LifecycleProbeFeature {
  constructor(engine, data) {
    this.passEvent = (data && data.passEvent) ?? RenderPassEvent.AfterRenderingOpaques;
    log('Feature.Create');
  }

  addPasses(frame, enqueue) {
    s_Frame++;
    log(`Feature.OnCameraPreCull  camera=${frame.camera.json.name || 'Camera'}`);
    log('Feature.AddRenderPasses');
    frame.requireDepth = true;   // ConfigureInput(ScriptableRenderPassInput.Depth)
    log('Feature.SetupRenderPasses  color=_CameraColorAttachment');
    enqueue(this.passEvent, (fr) => {
      log('  Pass.OnCameraSetup');
      log(`  Pass.Configure  ${fr.width}x${fr.height} msaa=${fr.msaa}`);
      log(`  Pass.Execute  event=${this.passEvent}`);
      log('  Pass.OnCameraCleanup');
    }, 'Ch25 Lifecycle Probe');
  }
}

下面是配图脚本渲染一张图(连续渲染 3 帧)时真实写出的日志,删去了重复的第 2 帧:

[帧0] Feature.Create
[帧0] Feature.Create
[帧1] Feature.OnCameraPreCull  camera=Main Camera
[帧1] Feature.AddRenderPasses
[帧1] Feature.SetupRenderPasses  color=_CameraColorAttachmentA
[帧1]   Pass.OnCameraSetup
[帧1]   Pass.Configure  640x400 msaa=1
[帧1]   Pass.Execute  event=AfterRenderingOpaques
[帧1]   Pass.OnCameraCleanup
[帧1]   Pass.OnFinishCameraStackRendering
……
[帧3]   Pass.OnFinishCameraStackRendering
[帧3] Feature.Dispose

几个观察:

  • Create 被调用了两次:一次来自 ScriptableObject.CreateInstance 触发的 OnEnable,一次来自 Renderer 构造函数。这验证了“Create 不只调一次”。
  • 相机颜色目标的名字是 _CameraColorAttachmentA:UniversalRenderer 的颜色缓冲是 A/B 两块(RenderTargetBufferSystem),后处理会在两块之间切换,所以每帧都要重新取 cameraColorTargetHandle,不能在第一帧缓存起来一直用。
  • OnCameraSetup 在 Configure 之前;两者都在同一帧的 Execute 之前完成,此时所有 Pass 都已入队。
  • 单相机(无相机栈)时,OnFinishCameraStackRendering 每帧都会调用——单个相机本身就是“只有一个成员的相机栈”。

接下来是事件实验。场景里有不透明物体、天空和两个半透明物体(青色球、品红方块),把案例 25.1 的 Feature 设为 desaturate = 1,只改 passEvent:

图 25-6同一个去色 Pass 放在不同事件上。左:AfterRenderingOpaques,天空和半透明物体在它之后才画,所以仍是彩色;中:AfterRenderingSkybox,天空也变灰了,半透明物体仍是彩色;右:AfterRenderingTransparents,全部变灰

左图能正确显示,依赖的正是 Pass 最后那一行 CoreUtils.SetRenderTarget(cmd, color, depth)。原因藏在 ScriptableRenderer.SetRenderPassAttachments 里:Renderer 会记住“当前绑定的是哪张颜色、哪张深度”(m_ActiveColorAttachments、m_ActiveDepthAttachment),下一个 Pass 要用的目标如果和记录的一样,就不再重新绑定。而 Blitter.BlitCameraTexture 内部用 CoreUtils.SetRenderTarget(cmd, destination) 只绑定了颜色——深度被解绑了,但 Renderer 不知道。如果不恢复,紧接着的天空盒 Pass 会在“没有深度缓冲”的状态下绘制,结果是天空盖住整个画面;透明物体也失去深度测试,会画到不透明物体前面。

✓ 推荐
自己切换过渲染目标的 Pass,结束前把目标恢复成相机颜色+深度;或者用 ConfigureTarget 明确告诉 Renderer 本 Pass 画到哪里(案例 25.5、25.6 的做法),让 Renderer 自己负责后续的重新绑定。
✗ 避免
在 Execute 里随意 cmd.SetRenderTarget 后直接返回。效果放在 BeforeRenderingPostProcessing 时也许看不出问题(后处理会自己绑定目标),换一个事件就出错,而且这种错很难从现象倒推原因。

案例 25.3不写 C#:Full Screen Pass Renderer Feature入门单独打开 ↗

目标:学会使用 URP 14 自带的 FullScreenPassRendererFeature,并为它手写两个兼容的 Shader:一个读屏幕颜色的“夜视仪”,一个读深度图的“距离雾”。

原理:这个 Feature 的源码只有两百多行(位于 Runtime/RendererFeatures 目录下的 FullScreenPassRendererFeature.cs),做的事和案例 25.1 几乎一样:如果勾选了 fetchColorBuffer,先把相机颜色拷一份到 _FullscreenPassColorCopy,然后用你的材质在相机颜色上画一个全屏三角形。关键的执行部分如下(摘自 URP 14 源码):

private static void ExecuteMainPass(CommandBuffer cmd, RTHandle sourceTexture, Material material, int passIndex)
{
    s_SharedPropertyBlock.Clear();
    if (sourceTexture != null)
        s_SharedPropertyBlock.SetTexture(ShaderPropertyId.blitTexture, sourceTexture);       // _BlitTexture
    // We need to set the "_BlitScaleBias" uniform for user materials with shaders relying on core Blit.hlsl to work
    s_SharedPropertyBlock.SetVector(ShaderPropertyId.blitScaleBias, new Vector4(1, 1, 0, 0));
    cmd.DrawProcedural(Matrix4x4.identity, material, passIndex, MeshTopology.Triangles, 3, 1, s_SharedPropertyBlock);
}

源码注释说得很清楚:它把拷贝出来的颜色绑定为 _BlitTexture,并设置 _BlitScaleBias,专门为了让“基于 core Blit.hlsl 的用户 Shader”能直接工作。这正是我们手写 Shader 的方式——和案例 25.1 的 Shader 完全同构,Vert 直接用 Blit.hlsl 的。Shader Graph 的 Fullscreen 目标生成的也是同样的约定,所以手写版和 Shader Graph 版可以互换。

Inspector 上的几个设置:

字段含义
Injection Point只有三个选项:Before Rendering Transparents、Before Rendering Post Processing、After Rendering Post Processing(枚举值直接映射到对应的 RenderPassEvent)
Fetch Color Buffer是否先拷贝相机颜色作为 _BlitTexture。只做“叠加/混合”而不读屏幕时可以关掉,省一次全屏拷贝
Requirements需要的输入:Depth、Normal、Color、Motion。它直接传给 ConfigureInput
Pass Material / Pass Index用哪个材质的哪个 Pass
Bind Depth Stencil Attachment绘制时是否同时绑定相机深度/模板,打开后材质里的 ZTest、Stencil 才有效

夜视仪的片元着色器:亮度放大并压缩、哈希噪点、扫描线、圆形镜筒暗角,全部是逐像素的独立计算:

Assets/Cookbook/Ch25/FullscreenNightVision.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    half3 c = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv).rgb;

    // 亮度放大(模拟像增强管),再压一条曲线防止大片死白
    half luma = dot(c, half3(0.2126, 0.7152, 0.0722)) * _Gain;
    luma = luma / (1.0 + luma);

    // 颗粒噪点:按像素坐标取哈希,_Time 让它在运行时闪动(截图时 _Time≈0)
    float2 pixel = uv * _ScreenParams.xy;
    half grain = Hash12(pixel + frac(_Time.y * 13.0) * 517.0) - 0.5;
    luma = saturate(luma + grain * 0.12);

    // 扫描线:每 3 像素一条暗线
    half scan = 1.0 - _ScanlineStrength * step(2.0, fmod(pixel.y, 3.0));

    // 圆形镜筒暗角(按宽高比校正成正圆)
    float2 d = (uv - 0.5) * float2(_ScreenParams.x / _ScreenParams.y, 1.0);
    half vignette = smoothstep(_VignetteRadius, _VignetteRadius - 0.25, length(d));

    half3 result = _Tint.rgb * luma * scan * vignette;
    return half4(result, 1.0);
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec3 c = texture(_BlitTexture, uv).rgb;

    // 亮度放大(模拟像增强管),再压一条曲线防止大片死白
    float luma = dot(c, vec3(0.2126, 0.7152, 0.0722)) * _Gain;
    luma = luma / (1.0 + luma);

    // 颗粒噪点:按像素坐标取哈希,_Time 让它在运行时闪动(截图时 _Time≈0)
    vec2 pixel = uv * _ScreenParams.xy;
    float grain = Hash12(pixel + fract(_Time.y * 13.0) * 517.0) - 0.5;
    luma = saturate(luma + grain * 0.12);

    // 扫描线:每 3 像素一条暗线(pixel.y 为正,mod 与 HLSL 的 fmod 结果相同)
    float scan = 1.0 - _ScanlineStrength * step(2.0, mod(pixel.y, 3.0));

    // 圆形镜筒暗角(按宽高比校正成正圆)
    vec2 d = (uv - 0.5) * vec2(_ScreenParams.x / _ScreenParams.y, 1.0);
    float vignette = smoothstep(_VignetteRadius, _VignetteRadius - 0.25, length(d));

    vec3 result = _Tint.rgb * luma * scan * vignette;
    return vec4(result, 1.0);
}

距离雾需要深度图:include URP 的 DeclareDepthTexture.hlsl,用 SampleSceneDepth 读原始深度,再用 LinearEyeDepth(raw, _ZBufferParams) 转成观察空间的线性距离(单位:米)。_ZBufferParams 已经考虑了 Reversed-Z,所以这段代码在各平台上通用:

Assets/Cookbook/Ch25/FullscreenDepthFog.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);

    // 原始深度 → 观察空间线性深度(米)。Reversed-Z 的差异由 _ZBufferParams 处理
    float rawDepth = SampleSceneDepth(uv);
    float eyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams);

    // 指数雾:f = 1 - e^(-density * 距离)
    float dist = max(eyeDepth - _FogStart, 0.0);
    half fog = 1.0 - exp(-_FogDensity * dist);
    color.rgb = lerp(color.rgb, _FogColor.rgb, fog);
    return color;
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec4 color = texture(_BlitTexture, uv);

    // 原始深度 → 观察空间线性深度(米)。Reversed-Z 的差异由 _ZBufferParams 处理
    float rawDepth = SampleSceneDepth(uv);
    float eyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams);

    // 指数雾:f = 1 - e^(-density * 距离)
    float dist = max(eyeDepth - _FogStart, 0.0);
    float fog = 1.0 - exp(-_FogDensity * dist);
    color.rgb = mix(color.rgb, _FogColor.rgb, fog);
    return color;
}

用代码配置这个 Feature(Inspector 里的操作完全对应):

Assets/Cookbook/Ch25/Editor/Ch25Shots.cs(节选:shot-fullscreen)
[BookShot("ch25-fullscreen", W = 640, H = 400, Panels = 3)]
static void FullScreen(Shot s)
{
    Stage(s);
    // 加一排远处的柱子,让深度雾有层次
    for (int i = 0; i < 6; i++)
        s.Cylinder(s.Lit(new Color(0.8f, 0.78f, 0.72f), 0, 0.3f), new Vector3(-5f + i * 2f, 1.5f, 4f + i * 2.5f), 1f)
            .transform.localScale = new Vector3(0.6f, 1.5f, 0.6f);
    if (s.Panel == 0) return;

    var f = s.Feature<FullScreenPassRendererFeature>();
    if (s.Panel == 1)
    {
        f.passMaterial = s.Mat("Cookbook/Ch25/FullscreenNightVision");
        f.injectionPoint = FullScreenPassRendererFeature.InjectionPoint.AfterRenderingPostProcessing;
        f.fetchColorBuffer = true;                               // 需要读屏幕颜色
        f.requirements = ScriptableRenderPassInput.None;
    }
    else
    {
        f.passMaterial = s.Mat("Cookbook/Ch25/FullscreenDepthFog");
        f.injectionPoint = FullScreenPassRendererFeature.InjectionPoint.BeforeRenderingPostProcessing;
        f.fetchColorBuffer = true;
        f.requirements = ScriptableRenderPassInput.Depth;        // 需要 _CameraDepthTexture
    }
}
图 25-7左:原图;中:夜视仪(After Rendering Post Processing,Fetch Color Buffer 打开);右:距离雾(Before Rendering Post Processing,Requirements = Depth),远处的柱子逐渐隐入雾中
提示Requirements 勾了 Depth,URP 才保证 _CameraDepthTexture 在这个时间点之前已经生成(必要时会插入 Depth Prepass 或 Copy Depth)。如果忘了勾,在 URP Asset 打开了 Depth Texture 的项目里效果看起来也“正常”,换到一个关闭了 Depth Texture 的平台配置就全错——这是典型的“在我电脑上没问题”。

Full Screen Pass 的局限也很明显:只能单 Pass、只有三个插入点、不能分配自己的中间 RT、不能读 Volume。多 Pass 的模糊、Bloom,以及要接 Volume 的后处理,都需要像第 26 章那样自己写 Feature。

案例 25.4Render Objects:把某个 Layer 再画一遍入门单独打开 ↗

目标:用 URP 自带的 RenderObjects Feature,在不改物体材质的前提下,给某个 Layer 的物体叠加一层菲涅尔边缘光(例如“可交互物体高亮”)。

原理:RenderObjects 本质上就是一个可配置的 DrawRenderers 调用:在指定事件上,筛选指定 Layer、指定队列(Opaque/Transparent)、指定 LightMode 标签的物体,用原材质或替换材质再画一遍,还可以覆盖深度测试、模板测试,甚至临时修改相机的 FOV 和偏移。第 16 章用它做了“被遮挡部分显示轮廓色”(深度测试设为 Greater),这里换一个用法:同深度叠加。

替换材质用的 Shader:在不透明 Pass 写下的深度上再画一次,ZTest LEqual 让它恰好通过;Blend One One 加法混合,只增亮不遮盖:

Assets/Cookbook/Ch25/RimOverlay.shader
// 案例 25.4:给 RenderObjects 当 Override Material 用的“叠加边缘光”。
// 物体已经在不透明 Pass 里画过一次,这里在同一深度上再画一遍,用加法混合叠上菲涅尔边缘光。
Shader "Cookbook/Ch25/RimOverlay"
{
    Properties
    {
        [HDR] _RimColor ("Rim Color", Color) = (0.2, 0.8, 1.6, 1)
        _RimPower ("Rim Power", Range(0.5, 8)) = 2.5
        _FillAlpha ("Fill", Range(0, 1)) = 0.12
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" }

        Pass
        {
            Name "RimOverlay"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Blend One One          // 加法:只增亮,不遮盖原来的颜色
            ZWrite Off
            ZTest LEqual           // 与自己在不透明 Pass 写下的深度相等,能通过

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            CBUFFER_START(UnityPerMaterial)
                float4 _RimColor;
                float  _RimPower;
                float  _FillAlpha;
            CBUFFER_END

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 normalWS   : TEXCOORD0;
                float3 positionWS : TEXCOORD1;
            };

            Varyings vert(Attributes v)
            {
                Varyings o;
                VertexPositionInputs p = GetVertexPositionInputs(v.positionOS.xyz);
                o.positionCS = p.positionCS;
                o.positionWS = p.positionWS;
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                float3 n = normalize(i.normalWS);
                float3 v = GetWorldSpaceNormalizeViewDir(i.positionWS);
                half rim = pow(1.0 - saturate(dot(n, v)), _RimPower);
                return half4(_RimColor.rgb * (rim + _FillAlpha), 1.0);
            }
            ENDHLSL
        }
    }
}
// 案例 25.4:给 RenderObjects 当 Override Material 用的“叠加边缘光”。
// 物体已经在不透明 Pass 里画过一次,这里在同一深度上再画一遍,用加法混合叠上菲涅尔边缘光。
Shader "Cookbook/Ch25/RimOverlay"
{
    SubShader
    {
        Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" }

        Pass
        {
            Name "RimOverlay"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Blend One One          // 加法:只增亮,不遮盖原来的颜色
            ZWrite Off
            ZTest LEqual           // 与自己在不透明 Pass 写下的深度相等,能通过

            GLSLPROGRAM
            uniform vec4  _RimColor;
            uniform float _RimPower;
            uniform float _FillAlpha;

            VARYING vec3 normalWS;
            VARYING vec3 positionWS;

            #ifdef VERTEX
            void main()
            {
                VertexPositionInputs p = GetVertexPositionInputs(positionOS.xyz);
                gl_Position = p.positionCS;
                positionWS = p.positionWS;
                normalWS = TransformObjectToWorldNormal(normalOS);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec3 n = normalize(normalWS);
                vec3 v = GetWorldSpaceNormalizeViewDir(positionWS);
                float rim = pow(1.0 - saturate(dot(n, v)), _RimPower);
                fragColor = vec4(_RimColor.rgb * (rim + _FillAlpha), 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}
Assets/Cookbook/Ch25/Editor/Ch25Shots.cs(节选:shot-renderobjects)
[BookShot("ch25-renderobjects", W = 640, H = 400, Panels = 2)]
static void RenderObjectsShot(Shot s)
{
    Stage(s, LayerA);          // 纽结和红球放到第 8 层
    if (s.Panel == 0) return;

    var f = s.Feature<RenderObjects>();
    f.settings.Event = RenderPassEvent.AfterRenderingOpaques;
    f.settings.filterSettings.RenderQueueType = RenderQueueType.Opaque;
    f.settings.filterSettings.LayerMask = 1 << LayerA;
    f.settings.overrideMode = RenderObjects.RenderObjectsSettings.OverrideMaterialMode.Material;
    f.settings.overrideMaterial = s.Mat("Cookbook/Ch25/RimOverlay");
    f.settings.overrideMaterialPassIndex = 0;
    // RenderObjects 只在 Create() 里把 settings 转成 Pass 参数。这里 Renderer 尚未创建,
    // 它创建时会调用每个 Feature 的 Create();若 Renderer 已在运行,改完 settings 必须手动再调一次。
    f.Create();
}
图 25-8左:原图;右:RenderObjects 只对第 8 层(纽结与红球)用 RimOverlay 再画一遍,边缘泛起青白色的光,其他物体不受影响

几个容易踩的点:

  • settings 只在 Create 里生效。RenderObjects.Create() 用 settings 构造 RenderObjectsPass,之后 AddRenderPasses 只是把它入队。在 Inspector 里改值会触发 OnValidate → Create,所以感觉不到;但运行时用代码改 settings 后,必须手动调用 Create(),否则不生效。
  • Filter 里的 LightMode(PassNames)留空时,默认匹配 SRPDefaultUnlit、UniversalForward、UniversalForwardOnly。替换材质时,这个过滤看的是物体原来的Shader 有没有这些 Pass,而真正绘制用的是替换材质的第 overrideMaterialPassIndex 个 Pass。
  • 不想让物体被画两次:如果目标是“这一层只用 RenderObjects 画”(比如第一人称武器要用另一个 FOV 画),要在 Renderer Data 的 Filtering › Opaque Layer Mask 里把这一层去掉。
  • 事件不能早于 BeforeRenderingPrePasses:源码在 Create 里会把更早的事件强行改成 BeforeRenderingPrePasses,因为那之前相机矩阵还没设置。

25.5 DrawRenderers:自己写“重画某些物体”的 Pass

RenderObjects 能做的,都可以用 context.DrawRenderers 自己写,而且自由得多:可以画到自己的 RT 里、可以和别的 Pass 组合、可以在 Execute 里按条件切换。它需要三样东西:

  • DrawingSettings:画哪些 Pass(一组 ShaderTagId,对应 Shader 里 Pass 的 LightMode 标签)、怎么排序(SortingCriteria,不透明一般用 CommonOpaque 或相机的 defaultOpaqueSortFlags,透明用 CommonTransparent),以及可选的替换材质 overrideMaterial。用 Pass 自带的 CreateDrawingSettings(tags, ref renderingData, sorting) 创建,它会顺便填好每物体数据(光照探针、光照贴图等)的配置。
  • FilteringSettings:画哪些物体——RenderQueueRange(opaque = 0~2500,transparent = 2501~5000,all)、layerMask,还可以按 renderingLayerMask 过滤。
  • RenderStateBlock(可选):覆盖渲染状态,例如深度测试、深度写入、模板、混合。它比在 Shader 里写死更灵活:同一个替换材质,可以在 C# 里决定用哪种深度测试。

还有两个前提:被画的物体必须在本相机的剔除结果(renderingData.cullResults)里——也就是相机的 Culling Mask 包含它所在的层;以及调用 context.DrawRenderers 之前,要先把命令缓冲里已录制的命令 ExecuteCommandBuffer 出去,保证顺序正确(URP 自己的 RenderObjectsPass 也是这么做的)。

案例 25.5只给某个 Layer 画一张遮罩进阶单独打开 ↗

目标:把第 8 层的物体画成纯白剪影,写进一张单通道遮罩 RT。支持两种模式:“透视”(被别的物体挡住的部分也写入)和“只写可见部分”。这张遮罩是案例 25.6 描边的输入,也可以用来做选中高亮、局部后处理等。

原理:遮罩 RT 和相机同尺寸;绘制时绑定遮罩做颜色、相机深度做深度。用 RenderStateBlock 在 C# 里切换深度测试:透视模式 ZTest Always,否则 ZTest LEqual——物体自己已经在不透明 Pass 里写过深度,等于自己时通过;被别的物体挡住的部分深度更大,不通过。替换材质的 Shader 只输出 1,并且 ZWrite Off,保证绝不修改相机深度:

Assets/Cookbook/Ch25/LayerMaskSolid.shader
// 案例 25.5:DrawRenderers 的 Override Material——把物体画成纯白剪影,写进遮罩 RT。
Shader "Cookbook/Ch25/LayerMaskSolid"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
        Pass
        {
            Name "Mask"
            ZWrite Off      // 绝不改动相机深度;深度比较方式由 C# 的 RenderStateBlock 决定
            Cull Back

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes { float4 positionOS : POSITION; };
            struct Varyings { float4 positionCS : SV_POSITION; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
                return o;
            }

            half4 frag(Varyings i) : SV_Target { return 1.0; }
            ENDHLSL
        }
    }
}
// 案例 25.5:DrawRenderers 的 Override Material——把物体画成纯白剪影,写进遮罩 RT。
Shader "Cookbook/Ch25/LayerMaskSolid"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
        Pass
        {
            Name "Mask"
            ZWrite Off      // 绝不改动相机深度;深度比较方式由 C# 的 RenderStateBlock 决定
            Cull Back

            GLSLPROGRAM
            #ifdef VERTEX
            void main()
            {
                // 对应 HLSL 的 TransformObjectToHClip。这里拆成“物体→世界→裁剪”两步,和 Lit 的顶点着色器
                // (GetVertexPositionInputs)运算顺序一致,保证算出的深度逐位相同,LEqual 测试不会闪烁
                gl_Position = TransformWorldToHClip(TransformObjectToWorld(positionOS.xyz));
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = vec4(1.0); }
            #endif
            ENDGLSL
        }
    }
}
Assets/Cookbook/Ch25/LayerOutlineFeature.cs(节选:mask-pass)
class MaskPass : ScriptableRenderPass
{
    // 只画原材质里有这些 LightMode 的物体(URP 前向渲染用到的三个标签)
    static readonly List<ShaderTagId> s_ShaderTags = new List<ShaderTagId>
    {
        new ShaderTagId("UniversalForward"),
        new ShaderTagId("UniversalForwardOnly"),
        new ShaderTagId("SRPDefaultUnlit"),
    };

    readonly Material m_Material;
    FilteringSettings m_Filtering;
    RenderStateBlock m_StateBlock;
    public RTHandle mask;           // 遮罩 RT(自己分配)
    public RTHandle cameraDepth;    // 借用相机深度做遮挡测试

    public MaskPass(Material material)
    {
        m_Material = material;
        profilingSampler = new ProfilingSampler("Ch25 Layer Mask");
        m_Filtering = new FilteringSettings(RenderQueueRange.all);
    }

    public void Setup(Settings s)
    {
        m_Filtering.layerMask = s.layerMask;
        // 用 RenderStateBlock 覆盖材质里的深度状态:透视模式 ZTest Always,否则 LEqual
        m_StateBlock = new RenderStateBlock(RenderStateMask.Depth)
        {
            depthState = new DepthState(false, s.seeThrough ? CompareFunction.Always : CompareFunction.LessEqual)
        };
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        // 遮罩与相机同尺寸、同 MSAA(要和相机深度一起绑定,两者采样数必须一致),单通道 8 位即可
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.graphicsFormat = GraphicsFormat.R8_UNorm;
        desc.depthBufferBits = 0;
        RenderingUtils.ReAllocateIfNeeded(ref mask, desc, FilterMode.Bilinear,
            TextureWrapMode.Clamp, name: "_Ch25LayerMask");

        // 告诉 URP:这个 Pass 画到 mask 上,深度用相机深度;开始前只清颜色(深度绝不能清)
        ConfigureTarget(mask, cameraDepth);
        ConfigureClear(ClearFlag.Color, Color.clear);
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            // 先把命令缓冲里已有的命令提交,再调用 context 级别的 DrawRenderers
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();

            var drawing = CreateDrawingSettings(s_ShaderTags, ref renderingData, SortingCriteria.CommonOpaque);
            drawing.overrideMaterial = m_Material;          // 所有物体都换成纯白材质
            drawing.overrideMaterialPassIndex = 0;
            context.DrawRenderers(renderingData.cullResults, ref drawing, ref m_Filtering, ref m_StateBlock);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public void Dispose()
    {
        mask?.Release();
        mask = null;
    }
}
maskPass(fr) {
  const E = fr.engine, gl = fr.gl, s = this.settings;
  // 遮罩与相机同尺寸、同 MSAA(要和相机深度一起绑定,两者采样数必须一致),单通道 8 位即可
  const rt = E.rt('ch25:_Ch25LayerMask', fr.width, fr.height, { color: 'R8', depth: null, samples: fr.msaa, renderbuffers: true });
  // ConfigureTarget(mask, cameraDepth):把相机的深度缓冲挂到遮罩的 FBO 上(借用,不清除)
  gl.bindFramebuffer(gl.FRAMEBUFFER, rt.fbo);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, fr.cameraTarget.depthRB);
  E.bindTarget(rt);
  // ConfigureClear(ClearFlag.Color, Color.clear):只清颜色,深度绝不能清
  gl.colorMask(true, true, true, true);
  gl.clearColor(0, 0, 0, 0);
  gl.clear(gl.COLOR_BUFFER_BIT);
  // RenderStateBlock 覆盖深度状态:透视模式 ZTest Always,否则 LEqual;不写深度
  const pass = this.maskMaterial.shader.passes[0];
  fr.drawRenderers({
    queue: 'all', lightModes: SHADER_TAGS, layerMask: layerMask(s.layerMask),
    overrideMaterial: this.maskMaterial, overridePass: 0,
    state: { ...pass.state, zwrite: 'Off', ztest: s.seeThrough ? 'Always' : 'LEqual' },
  });
  E.glc.resolve(rt, { color: true });
  this.mask = rt.texture;
}

这里和案例 25.1 最大的不同是用了 ConfigureTarget 与 ConfigureClear,而不是在 Execute 里手动设置目标:

  • ConfigureTarget(mask, cameraDepth) 告诉 Renderer:执行这个 Pass 之前,把颜色绑定为遮罩、深度绑定为相机深度。Renderer 记录下这次绑定,下一个 Pass 要用相机颜色时,它知道目标变了,会自动重新绑定——不需要我们手动恢复。
  • ConfigureClear(ClearFlag.Color, Color.clear) 只清颜色。千万不能写 ClearFlag.All:那会把相机的深度缓冲清掉,后面的天空盒、透明物体全部出错。
  • 遮罩描述符的 MSAA 采样数沿用相机描述符。原因是颜色和深度一起绑定时,两者的采样数必须一致;相机开了 4x MSAA 时,相机深度也是 4x 的。采样这张多重采样遮罩时,Unity 会自动解析(resolve)。
  • 格式用 GraphicsFormat.R8_UNorm:遮罩只需要一个通道 8 位,比相机颜色格式省 3/4 的带宽。
图 25-9左:场景,红球前面挡了一块板;中:透视模式的遮罩(ZTest Always),被挡住的红球也完整写入;右:只写可见部分(ZTest LEqual),红球只剩露出板子上沿的一小块

案例 25.6按遮罩给选中层描边进阶单独打开 ↗

目标:用案例 25.5 的遮罩,给第 8 层物体画出等宽的外描边,并可选半透明纯色填充。这是编辑器里“选中物体橙色描边”的同款做法,和第 14 章“背面法线外扩”描边相比,它不依赖模型的法线质量、线宽在屏幕上恒定,而且对任何 Shader 的物体都有效。

原理:对遮罩做一次膨胀(Dilation)——每个像素取周围半径 r 内遮罩的最大值;膨胀后的区域减去原区域,就是物体外侧宽度为 r 的一圈。然后用 Alpha 混合把这一圈(和可选的内部填充)叠到相机颜色上:

outline=saturate(max|d|≤rM(p+d)−M(p))

实际实现用两圈各 12 个方向近似圆形邻域(半径 r 和 r/2),双线性采样让边缘有一点抗锯齿:

Assets/Cookbook/Ch25/LayerOutline.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    half center = Mask(uv);

    // 膨胀(Dilation):在半径 = 线宽、线宽/2 的两圈圆周上各取 12 个样本,求最大值
    float2 r = _BlitTexture_TexelSize.xy * _OutlineWidth;
    half dilated = center;
    [unroll] for (int k = 0; k < 12; k++)
    {
        float a = k * (TWO_PI / 12.0);
        float2 dir = float2(cos(a), sin(a));
        dilated = max(dilated, Mask(uv + dir * r));
        dilated = max(dilated, Mask(uv + dir * r * 0.5));
    }

    // 膨胀后的区域减去原区域 = 物体外侧一圈
    half outline = saturate(dilated - center);
    half3 rgb = lerp(_FillColor.rgb, _OutlineColor.rgb, outline);
    half alpha = outline * _OutlineColor.a + center * _FillColor.a;
    return half4(rgb, alpha);
}
vec4 Frag()
{
    vec2 uv = texcoord;
    float center = Mask(uv);

    // 膨胀(Dilation):在半径 = 线宽、线宽/2 的两圈圆周上各取 12 个样本,求最大值
    vec2 r = _BlitTexture_TexelSize.xy * _OutlineWidth;
    float dilated = center;
    for (int k = 0; k < 12; k++)
    {
        float a = float(k) * (TWO_PI / 12.0);
        vec2 dir = vec2(cos(a), sin(a));
        dilated = max(dilated, Mask(uv + dir * r));
        dilated = max(dilated, Mask(uv + dir * r * 0.5));
    }

    // 膨胀后的区域减去原区域 = 物体外侧一圈
    float outline = saturate(dilated - center);
    vec3 rgb = mix(_FillColor.rgb, _OutlineColor.rgb, outline);
    float alpha = outline * _OutlineColor.a + center * _FillColor.a;
    return vec4(rgb, alpha);
}

合成 Pass 用 ConfigureTarget(相机颜色, 相机深度) 声明目标,然后用 Blitter.BlitTexture(不切换目标的那个重载)把遮罩作为 _BlitTexture 画一个全屏三角形,靠 Shader 里的 Blend SrcAlpha OneMinusSrcAlpha 叠加上去。和案例 25.1 比,这里不需要临时 RT:我们不读相机颜色,只往上面混合,读写分离的问题根本不存在:

Assets/Cookbook/Ch25/LayerOutlineFeature.cs(节选:composite-pass)
class CompositePass : ScriptableRenderPass
{
    static readonly int s_OutlineColor = Shader.PropertyToID("_OutlineColor");
    static readonly int s_FillColor = Shader.PropertyToID("_FillColor");
    static readonly int s_OutlineWidth = Shader.PropertyToID("_OutlineWidth");

    readonly Material m_Material;
    MaskPass m_MaskPass;
    RTHandle m_CameraColor, m_CameraDepth;
    int m_ShaderPass;

    public CompositePass(Material material)
    {
        m_Material = material;
        profilingSampler = new ProfilingSampler("Ch25 Outline Composite");
    }

    public void Setup(Settings s, MaskPass maskPass)
    {
        m_MaskPass = maskPass;
        m_ShaderPass = s.mode == Mode.Outline ? 0 : 1;
        m_Material.SetColor(s_OutlineColor, s.outlineColor);
        m_Material.SetColor(s_FillColor, s.fillColor);
        m_Material.SetFloat(s_OutlineWidth, s.width);
    }

    public void SetTarget(RTHandle color, RTHandle depth)
    {
        m_CameraColor = color;
        m_CameraDepth = depth;
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        // 画回相机颜色。显式 ConfigureTarget,URP 会在 MaskPass 之后重新绑定相机目标
        ConfigureTarget(m_CameraColor, m_CameraDepth);
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            // 不切换目标,直接在当前目标(相机颜色)上画一个全屏三角形,靠混合叠加上去
            Blitter.BlitTexture(cmd, m_MaskPass.mask, new Vector4(1, 1, 0, 0), m_Material, m_ShaderPass);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
}
// 案例 25.5 / 25.6:只给某个 Layer 的物体画遮罩,再根据遮罩画描边(对应 Ch25/LayerOutlineFeature.cs)。
// 两个 Pass:MaskPass 用 DrawRenderers 把选中层画进一张 R8 遮罩;CompositePass 读遮罩、膨胀、叠到屏幕上。
import { Material } from '../engine/shader.js';
import { RenderPassEvent, layerMask } from '../engine/features.js';

// 只画原材质里有这些 LightMode 的物体(URP 前向渲染用到的三个标签)
const SHADER_TAGS = ['UniversalForward', 'UniversalForwardOnly', 'SRPDefaultUnlit'];

export default class LayerOutlineFeature {
  constructor(engine, data, scene) {
    this.engine = engine;
    this.data = data || {};
    this.settings = {
      layerMask: 1 << 8, seeThrough: true, outlineColor: [1, 0.55, 0.1, 1], fillColor: [1, 0.55, 0.1, 0],
      width: 4, mode: 0, passEvent: RenderPassEvent.AfterRenderingTransparents, ...(this.data.settings || {}),
    };
  }

  async init() {
    const sh = (ref, def) => this.engine.shaders.get((ref && ref.shader) || def);
    this.maskMaterial = new Material(await sh(this.data.maskShader, 'Cookbook/Ch25/LayerMaskSolid'));
    this.outlineMaterial = new Material(await sh(this.data.outlineShader, 'Cookbook/Ch25/LayerOutline'));
  }

  addPasses(frame, enqueue) {
    const s = this.settings;
    // CompositePass.Setup
    // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector
    this.outlineMaterial.setVector('_OutlineColor', s.outlineColor);
    this.outlineMaterial.setVector('_FillColor', s.fillColor);
    this.outlineMaterial.setFloat('_OutlineWidth', s.width);
    const shaderPass = s.mode === 0 ? 0 : 1;
    // 两个 Pass 同一个事件:入队顺序就是执行顺序
    enqueue(s.passEvent, (fr) => this.maskPass(fr), 'Ch25 Layer Mask');
    enqueue(s.passEvent, (fr) => {
      // 画回相机颜色:在相机目标上画一个全屏三角形,靠混合叠加上去
      fr.blit(this.mask, 'camera', this.outlineMaterial, shaderPass);
    }, 'Ch25 Outline Composite');
  }

  maskPass(fr) {
    const E = fr.engine, gl = fr.gl, s = this.settings;
    // 遮罩与相机同尺寸、同 MSAA(要和相机深度一起绑定,两者采样数必须一致),单通道 8 位即可
    const rt = E.rt('ch25:_Ch25LayerMask', fr.width, fr.height, { color: 'R8', depth: null, samples: fr.msaa, renderbuffers: true });
    // ConfigureTarget(mask, cameraDepth):把相机的深度缓冲挂到遮罩的 FBO 上(借用,不清除)
    gl.bindFramebuffer(gl.FRAMEBUFFER, rt.fbo);
    gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, fr.cameraTarget.depthRB);
    E.bindTarget(rt);
    // ConfigureClear(ClearFlag.Color, Color.clear):只清颜色,深度绝不能清
    gl.colorMask(true, true, true, true);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    // RenderStateBlock 覆盖深度状态:透视模式 ZTest Always,否则 LEqual;不写深度
    const pass = this.maskMaterial.shader.passes[0];
    fr.drawRenderers({
      queue: 'all', lightModes: SHADER_TAGS, layerMask: layerMask(s.layerMask),
      overrideMaterial: this.maskMaterial, overridePass: 0,
      state: { ...pass.state, zwrite: 'Off', ztest: s.seeThrough ? 'Always' : 'LEqual' },
    });
    E.glc.resolve(rt, { color: true });
    this.mask = rt.texture;
  }
}

两个 Pass 都设在 AfterRenderingTransparents,入队顺序是先遮罩后合成,稳定排序保证它们按这个顺序执行。

用 Frame Debugger 验证时,会在 AfterRenderingTransparents 的位置看到 “Ch25 Layer Mask” 和 “Ch25 Outline Composite” 两个节点。点开前者,右侧的 RenderTarget 显示为 _Ch25LayerMask,绘制列表里只有第 8 层的两个物体,Shader 显示为替换材质 LayerMaskSolid;点开后者只有一次全屏的 Draw Procedural。如果遮罩节点里一个物体都没有,先检查相机的 Culling Mask 是否包含这一层(不在剔除结果里的物体 DrawRenderers 画不出来),再检查物体原来的 Shader 是否有 UniversalForward 等被过滤的 LightMode。

Assets/Cookbook/Ch25/Editor/Ch25Shots.cs(节选:shot-outline)
[BookShot("ch25-layer-outline", W = 640, H = 400, Panels = 3)]
static void LayerOutlineShot(Shot s)
{
    Stage(s, LayerA);
    s.Cube(s.Lit(new Color(0.55f, 0.5f, 0.45f), 0, 0.3f), new Vector3(-1.2f, 0.45f, -1.25f), 1f)
        .transform.localScale = new Vector3(0.9f, 0.9f, 0.08f);
    var f = s.Feature<LayerOutlineFeature>();
    f.settings.layerMask = 1 << LayerA;
    f.settings.width = 4;
    switch (s.Panel)
    {
        case 0: f.settings.seeThrough = true; break;
        case 1: f.settings.seeThrough = false; break;
        default:
            f.settings.seeThrough = true;
            f.settings.outlineColor = new Color(0.2f, 0.9f, 1f, 1f);
            f.settings.fillColor = new Color(0.2f, 0.9f, 1f, 0.35f);   // 纯色填充 + 描边
            f.settings.width = 6;
            break;
    }
}
图 25-10左:透视描边,红球被板子挡住的轮廓也画了出来;中:只描可见部分;右:青色描边 + 35% 纯色填充,线宽 6 像素

调参与扩展:

  • 开销:遮罩 Pass 只画选中的物体,很便宜;合成 Pass 每像素 25 次采样,在 1080p 上是一次不小的全屏操作。线宽很大时(超过十几像素),圆周采样会出现空隙,可以改用“先降采样再膨胀”或跳跃泛洪算法(Jump Flooding)求距离场,再按距离画线,线宽几乎不影响开销。
  • 只在需要时入队:选中物体为空时,在 AddRenderPasses 里直接 return,两个 Pass 都不执行。
  • 多种颜色:遮罩改成 RGBA,替换材质根据 renderingLayerMask 或 MaterialPropertyBlock 输出不同通道,就能一次画出“敌人红、队友绿”多组描边。

案例 25.7把一个 Layer 渲染到 RT 再合成:整体半透明的“幽灵”高阶单独打开 ↗

目标:让一个由多个部件拼成的角色整体半透明(隐身、灵魂、回放残影等),并且看不到它内部互相穿插的结构,同时被场景里的物体正确遮挡。

问题:最直接的做法是给每个部件用半透明材质。但半透明物体逐个混合,手臂和身体重叠的地方被叠加了两次,看起来更不透明;身体内部被手臂“插进去”的部分也能透过表面看到——这就是图 25-12 左边的样子。根本原因是:半透明是逐物体、逐表面的,而我们要的是“这个角色作为一张图”整体半透明。

原理:先把这个 Layer 的物体用它们原本的不透明材质,画到一张单独的离屏 RT 上(自带深度缓冲,所以内部穿插的面已经被深度测试正确剔除,RT 里只剩最外层表面);再把这张 RT 当成一张图,统一乘以透明度合成回屏幕。为了被场景正确遮挡,合成时比较“离屏深度”和“场景深度”:

主不透明 Pass不画幽灵层 LayerPassDrawRenderers 幽灵层 相机颜色 + 相机深度→ _CameraDepthTexture _Ch25GhostColor (RGBA)_Ch25GhostDepth (D32) CompositePass深度比较 + 统一透明度
图 25-11幽灵层的数据流:主 Pass 不画它,LayerPass 把它画到自己的颜色+深度 RT,CompositePass 与场景深度比较后整体半透明地叠回相机颜色

LayerPass:颜色用 R16G16B16A16_SFloat(HDR 光照结果 + Alpha 作为覆盖率),深度用纯深度格式 D32_SFloat(之后要在 Shader 里当纹理采样)。ConfigureClear(ClearFlag.All, Color.clear) 把颜色清成全透明、深度清成最远——这次清的是我们自己的 RT,可以放心用 All。绘制时不设置替换材质,物体用自己的 Lit 材质正常打光。URP Lit 的不透明模式输出 Alpha 恒为 1(OutputAlpha 对非透明表面返回 1),所以 RT 的 Alpha 通道就是覆盖率:

Assets/Cookbook/Ch25/GhostLayerFeature.cs(节选:layer-pass)
class LayerPass : ScriptableRenderPass
{
    static readonly List<ShaderTagId> s_ShaderTags = new List<ShaderTagId>
    {
        new ShaderTagId("UniversalForward"),
        new ShaderTagId("UniversalForwardOnly"),
        new ShaderTagId("SRPDefaultUnlit"),
    };

    public LayerMask layerMask;
    public RTHandle color, depth;

    public LayerPass() { profilingSampler = new ProfilingSampler("Ch25 Ghost Layer"); }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.msaaSamples = 1;

        // 颜色:带 Alpha 的半精度浮点(HDR 光照结果 + 覆盖率)
        var colorDesc = desc;
        colorDesc.graphicsFormat = GraphicsFormat.R16G16B16A16_SFloat;
        colorDesc.depthBufferBits = 0;
        RenderingUtils.ReAllocateIfNeeded(ref color, colorDesc, FilterMode.Bilinear,
            TextureWrapMode.Clamp, name: "_Ch25GhostColor");

        // 深度:纯深度格式的 RT,之后还要在合成 Shader 里当纹理采样
        var depthDesc = desc;
        depthDesc.graphicsFormat = GraphicsFormat.None;
        depthDesc.depthStencilFormat = GraphicsFormat.D32_SFloat;
        depthDesc.depthBufferBits = 32;
        RenderingUtils.ReAllocateIfNeeded(ref depth, depthDesc, FilterMode.Point,
            TextureWrapMode.Clamp, name: "_Ch25GhostDepth");

        ConfigureTarget(color, depth);
        ConfigureClear(ClearFlag.All, Color.clear);   // 颜色清成全透明,深度清成最远
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();
            // 不覆盖材质:用物体自己的 Lit 材质正常打光,只是画到了另一张 RT 上
            var drawing = CreateDrawingSettings(s_ShaderTags, ref renderingData,
                renderingData.cameraData.defaultOpaqueSortFlags);
            var filtering = new FilteringSettings(RenderQueueRange.opaque, layerMask);
            context.DrawRenderers(renderingData.cullResults, ref drawing, ref filtering);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public void Dispose()
    {
        color?.Release(); color = null;
        depth?.Release(); depth = null;
    }
}
layerPass(fr) {
  const gl = fr.gl;
  // 颜色:带 Alpha 的半精度浮点(HDR 光照结果 + 覆盖率);深度:之后还要在合成 Shader 里当纹理采样
  const rt = fr.getTemporaryRT('_Ch25GhostColor', fr.width, fr.height, { color: 'RGBA16F', depth: 'DEPTH24' });
  fr.engine.bindTarget(rt);
  // ConfigureClear(ClearFlag.All, Color.clear):颜色清成全透明,深度清成最远
  gl.colorMask(true, true, true, true); gl.depthMask(true);
  gl.clearColor(0, 0, 0, 0); gl.clearDepth(1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  // 不覆盖材质:用物体自己的 Lit 材质正常打光,只是画到了另一张 RT 上
  fr.drawRenderers({ queue: 'opaque', lightModes: SHADER_TAGS, layerMask: layerMask(this.settings.layerMask) });
  this.layer = rt;
  fr.bindCamera();
}

合成 Shader:深度比较统一换算成线性深度,避免和 Reversed-Z 纠缠;再用 Alpha 在邻域里的最小值找出剪影边缘,加一圈轮廓光:

Assets/Cookbook/Ch25/GhostComposite.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    half4 ghost = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv);

    // 1) 遮挡:幽灵层深度 与 场景深度 都换成线性深度再比较,与平台的 Reversed-Z 无关
    float ghostEye = LinearEyeDepth(SAMPLE_TEXTURE2D_X(_GhostDepth, sampler_PointClamp, uv).r, _ZBufferParams);
    float sceneEye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
    half visible = step(ghostEye, sceneEye + 0.01);

    // 2) 轮廓光:覆盖率(alpha)在邻域里的最小值 < 1 说明靠近剪影边缘
    float2 r = _BlitTexture_TexelSize.xy * _RimWidth;
    half minA = ghost.a;
    minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2( r.x, 0)).a);
    minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(-r.x, 0)).a);
    minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0,  r.y)).a);
    minA = min(minA, SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + float2(0, -r.y)).a);
    half rim = ghost.a * (1.0 - minA);

    // 3) 整层统一透明度:内部的穿插面在离屏 RT 里已被深度测试剔除,合成时看不到
    half3 rgb = ghost.rgb * _Tint.rgb + _RimColor.rgb * rim;
    half alpha = saturate(ghost.a * _Opacity + rim) * visible;
    return half4(rgb, alpha);
}
vec4 Frag()
{
    vec2 uv = texcoord;
    // sampler_PointClamp:按纹素整数坐标取,不做双线性
    ivec2 pix = ivec2(uv * _BlitTexture_TexelSize.zw);
    vec4 ghost = texelFetch(_BlitTexture, pix, 0);

    // 1) 遮挡:幽灵层深度 与 场景深度 都换成线性深度再比较,与平台的 Reversed-Z 无关
    float ghostEye = LinearEyeDepth(texelFetch(_GhostDepth, pix, 0).r, _ZBufferParams);
    float sceneEye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
    float visible = step(ghostEye, sceneEye + 0.01);

    // 2) 轮廓光:覆盖率(alpha)在邻域里的最小值 < 1 说明靠近剪影边缘
    vec2 r = _BlitTexture_TexelSize.xy * _RimWidth;
    float minA = ghost.a;
    minA = min(minA, texture(_BlitTexture, uv + vec2( r.x, 0.0)).a);
    minA = min(minA, texture(_BlitTexture, uv + vec2(-r.x, 0.0)).a);
    minA = min(minA, texture(_BlitTexture, uv + vec2(0.0,  r.y)).a);
    minA = min(minA, texture(_BlitTexture, uv + vec2(0.0, -r.y)).a);
    float rim = ghost.a * (1.0 - minA);

    // 3) 整层统一透明度:内部的穿插面在离屏 RT 里已被深度测试剔除,合成时看不到
    vec3 rgb = ghost.rgb * _Tint.rgb + _RimColor.rgb * rim;
    float alpha = saturate(ghost.a * _Opacity + rim) * visible;
    return vec4(rgb, alpha);
}
Assets/Cookbook/Ch25/GhostLayerFeature.cs(节选:composite-pass)
class CompositePass : ScriptableRenderPass
{
    static readonly int s_GhostDepth = Shader.PropertyToID("_GhostDepth");
    static readonly int s_Opacity = Shader.PropertyToID("_Opacity");
    static readonly int s_Tint = Shader.PropertyToID("_Tint");
    static readonly int s_RimColor = Shader.PropertyToID("_RimColor");
    static readonly int s_RimWidth = Shader.PropertyToID("_RimWidth");

    readonly Material m_Material;
    LayerPass m_Layer;
    RTHandle m_CameraColor, m_CameraDepth;

    public CompositePass(Material m)
    {
        m_Material = m;
        profilingSampler = new ProfilingSampler("Ch25 Ghost Composite");
    }

    public void Setup(Settings s, LayerPass layer)
    {
        m_Layer = layer;
        m_Material.SetFloat(s_Opacity, s.opacity);
        m_Material.SetColor(s_Tint, s.tint);
        m_Material.SetColor(s_RimColor, s.rimColor);
        m_Material.SetFloat(s_RimWidth, s.rimWidth);
    }

    public void SetTarget(RTHandle color, RTHandle depth) { m_CameraColor = color; m_CameraDepth = depth; }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
        => ConfigureTarget(m_CameraColor, m_CameraDepth);

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            cmd.SetGlobalTexture(s_GhostDepth, m_Layer.depth);
            Blitter.BlitTexture(cmd, m_Layer.color, new Vector4(1, 1, 0, 0), m_Material, 0);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
}
// 案例 25.7:把某个 Layer 单独渲染到离屏 RT(颜色+自己的深度),再整体半透明合成回屏幕(对应 Ch25/GhostLayerFeature.cs)。
// 用法:把该 Layer 从 Renderer 的 Opaque Layer Mask 里去掉(否则它会在主 Pass 里再画一次)。
import { Material } from '../engine/shader.js';
import { RenderPassEvent, layerMask } from '../engine/features.js';

const SHADER_TAGS = ['UniversalForward', 'UniversalForwardOnly', 'SRPDefaultUnlit'];

export default class GhostLayerFeature {
  constructor(engine, data, scene) {
    this.engine = engine;
    this.data = data || {};
    this.settings = { layerMask: 1 << 9, opacity: 0.45, tint: [0.75, 0.95, 1.1, 1], rimColor: [0.3, 1.2, 2.0, 1], rimWidth: 3, ...(this.data.settings || {}) };
  }

  async init() {
    const name = (this.data.compositeShader && this.data.compositeShader.shader) || 'Cookbook/Ch25/GhostComposite';
    this.material = new Material(await this.engine.shaders.get(name));
  }

  addPasses(frame, enqueue) {
    const s = this.settings, m = this.material;
    // CompositePass.Setup
    m.setFloat('_Opacity', s.opacity);
    // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector
    m.setVector('_Tint', s.tint);
    m.setVector('_RimColor', s.rimColor);
    m.setFloat('_RimWidth', s.rimWidth);
    // 合成时要和场景深度比较 → 需要 _CameraDepthTexture
    frame.requireDepth = true;
    enqueue(RenderPassEvent.AfterRenderingOpaques, (fr) => this.layerPass(fr), 'Ch25 Ghost Layer');
    enqueue(RenderPassEvent.AfterRenderingTransparents, (fr) => {
      fr.blit(this.layer.texture, 'camera', m, 0, { _GhostDepth: this.layer.depthTexture });
    }, 'Ch25 Ghost Composite');
  }

  layerPass(fr) {
    const gl = fr.gl;
    // 颜色:带 Alpha 的半精度浮点(HDR 光照结果 + 覆盖率);深度:之后还要在合成 Shader 里当纹理采样
    const rt = fr.getTemporaryRT('_Ch25GhostColor', fr.width, fr.height, { color: 'RGBA16F', depth: 'DEPTH24' });
    fr.engine.bindTarget(rt);
    // ConfigureClear(ClearFlag.All, Color.clear):颜色清成全透明,深度清成最远
    gl.colorMask(true, true, true, true); gl.depthMask(true);
    gl.clearColor(0, 0, 0, 0); gl.clearDepth(1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    // 不覆盖材质:用物体自己的 Lit 材质正常打光,只是画到了另一张 RT 上
    fr.drawRenderers({ queue: 'opaque', lightModes: SHADER_TAGS, layerMask: layerMask(this.settings.layerMask) });
    this.layer = rt;
    fr.bindCamera();
  }
}
Assets/Cookbook/Ch25/Editor/Ch25Shots.cs(节选:shot-ghost)
[BookShot("ch25-ghost", W = 640, H = 440, Panels = 3)]
static void Ghost(Shot s)
{
    GhostStage(s, naive: s.Panel == 0);
    if (s.Panel == 0) return;    // 第 1 格:逐物体半透明(对照组)

    // 幽灵层不参与主不透明 Pass,只由 Feature 单独绘制
    s.Renderer.opaqueLayerMask = ~(1 << LayerB);
    var f = s.Feature<GhostLayerFeature>();
    f.settings.layerMask = 1 << LayerB;
    if (s.Panel == 1) { f.settings.rimWidth = 0; f.settings.opacity = 0.45f; }
    else { f.settings.rimWidth = 3; f.settings.opacity = 0.3f; }
}
图 25-12左:每个部件用普通半透明材质,手臂与身体的重叠处更不透明,腿插进身体的部分也透了出来;中:离屏渲染后整体 45% 不透明度合成,内部结构消失,右臂被柱子正确遮挡;右:30% 不透明度 + 剪影轮廓光

要点与扩展:

  • 一定要从 Opaque Layer Mask 里去掉这一层,否则它会先被主 Pass 不透明地画一次,合成就没有意义了。代码里是 s.Renderer.opaqueLayerMask = ~(1 << LayerB),编辑器里在 Renderer Data 的 Filtering 区域设置。
  • 深度比较加了 1 厘米的偏移(sceneEye + 0.01),防止幽灵贴着地面时因为精度问题闪烁。
  • 阴影:这一层仍会投射阴影(阴影 Pass 不看 Opaque Layer Mask),如果不希望半透明的幽灵投出实心阴影,就像配图脚本那样把它的 Shadow Casting Mode 关掉,或在合成时按透明度处理阴影(需要更复杂的方案)。
  • 降分辨率:离屏 RT 可以用半分辨率,合成时双线性放大,对于本来就半透明、边缘模糊的效果几乎看不出差别,开销降到 1/4。此时深度比较要注意低分辨率深度与全分辨率深度的不一致(可以取 2×2 邻域里最近的深度)。
  • 同样的“分层离屏渲染 + 合成”结构还能做:只给角色做的局部后处理(比如角色单独做描边或色调)、画中画小地图、第一人称武器层等。

25.6 中间纹理、相机栈与性能

没有中间纹理时,相机颜色就是屏幕

本章所有“读相机颜色”的效果都隐含一个前提:相机先画到一张中间颜色纹理(_CameraColorAttachmentA/B),最后再由 FinalBlit 拷到屏幕。只有这样,cameraColorTargetHandle 才是一张可以被采样的 RT。Universal Renderer Data 上有一个 Intermediate Texture 选项(源码字段 m_IntermediateTextureMode),默认值是 Always:只要 Renderer 上挂了任何 Feature,就一定创建中间纹理(见 UniversalRenderer.Setup 中 rendererFeatures.Count != 0 && m_IntermediateTextureMode == IntermediateTextureMode.Always 这一判断)。

移动端项目常把它改成 Auto 以省掉一次全屏拷贝。此时 URP 只在确有需要时(开了后处理、HDR、Render Scale 不为 1、MSAA 需要显式解析、需要 Opaque Texture、Scene 视图等,见 RequiresIntermediateColorTexture)才创建中间纹理,否则直接画到屏幕上。如果你的 Feature 要读相机颜色,而上述条件恰好都不满足,cameraColorTargetHandle 就会指向屏幕本身,Blit 读出来的是错误内容。解决办法是在 AddRenderPasses 里 ConfigureInput(ScriptableRenderPassInput.Color):它会让 URP 把“需要颜色纹理”计入判断,从而创建中间纹理——代价是同时会多一次拷贝到 _CameraOpaqueTexture 的 CopyColor Pass。用 Always 还是 Auto + ConfigureInput,要看项目里有多少 Feature 真的需要读颜色。

相机栈与 Overlay 相机

Feature 对使用该 Renderer 的每一个相机都会执行,包括相机栈里的 Overlay 相机。Overlay 相机和 Base 相机共用同一块颜色缓冲,所以一个挂在两者共用的 Renderer 上的全屏去色效果,会在 Base 相机结束时执行一次、在 Overlay 相机结束时再执行一次——如果效果不是幂等的(比如“亮度乘 1.2”),结果就会被叠加两次。常见的处理方式有三种:给 Overlay 相机单独配一个不挂该 Feature 的 Renderer;在 AddRenderPasses 里用 renderingData.cameraData.renderType 只处理 Base 相机;或者用 cameraData.resolveFinalTarget 只在栈中最后一个相机上执行(这正是 URP 自己的后处理只在最后一个相机上做色调映射等收尾工作的方式)。

开销估算

全屏 Pass 的成本主要是带宽而不是计算:一次 1920×1080、RGBA16F 的全屏读写,就要搬运约 2×1920×1080×8 字节 ≈ 33 MB 的数据。案例 25.1 的写法每帧做两次(拷出、拷回),在移动端上这是值得在意的开销。几个省带宽的方向:

  • 能用混合叠加的效果(描边、覆盖层、暗角)不要“拷出再拷回”,像案例 25.6 那样直接在相机颜色上混合。
  • 多个全屏颜色效果合并成一个 Pass(一个 Shader 里依次做),或者像第 26 章那样按 Volume 判断,未启用的效果根本不入队。
  • 遮罩、中间结果用最小够用的格式(R8、R16F),能降分辨率就降。
  • TBDR 移动 GPU 上,使用带 load/store action 的 Blit 重载,对不需要保留的目标用 DontCare,避免把 tile 内容无谓地写回或读入显存。

25.7 常见坑清单

现象原因解决
控制台刷 “You can only call cameraColorTarget inside the scope of a ScriptableRenderPass”在 AddRenderPasses 或 Create 里读了 cameraColorTargetHandle移到 SetupRenderPasses
需要的深度/法线图第一帧是空的,或在某些平台上一直为空ConfigureInput 写在 SetupRenderPasses/OnCameraSetup 里,renderer.Setup 早已算完本帧需求在 AddRenderPasses 里、入队前调用
每次在 Inspector 调参数,Memory Profiler 里材质/RT 数量就涨一点Create 重复创建资源却不释放Create 开头先释放旧资源;Dispose 里释放全部
加了 Feature 后天空盖住画面、透明物体跑到前面Pass 手动切换了渲染目标,没有恢复深度用 ConfigureTarget,或结束时 CoreUtils.SetRenderTarget(cmd, color, depth)
多次迭代的效果每次参数都一样同一帧多次 material.SetFloat,执行时只读到最后一次逐次变化的参数用 cmd.SetGlobalFloat 等记录进命令缓冲
材质球预览、反射探针也被加了效果没有按 cameraType 过滤跳过 Preview、Reflection(必要时也跳过 SceneView)
编辑器里正常,打包后效果消失Shader 只靠 Shader.Find,打包时被剔除Feature 上直接引用 Shader,或加入 Always Included Shaders
屏幕边缘出现一条另一侧的颜色临时 RT 寻址模式是默认的 RepeatReAllocateIfNeeded 传 TextureWrapMode.Clamp
开 MSAA 后报“颜色与深度采样数不一致”自己的颜色 RT 去掉了 MSAA,却和相机深度一起绑定与相机深度一起绑定的 RT 沿用相机的 msaaSamples
VR 下只有一只眼睛有效果Shader 用了 TEXTURE2D 而非 TEXTURE2D_X,或漏了 UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX照 Blit.hlsl 的约定写

25.8 URP 17 Render Graph 带来了什么变化

本书锁定 URP 14,但读者迟早会升级到 Unity 6(URP 17)。在那里,本章的写法会遇到一次较大的变化:Render Graph 成为默认的执行路径。这里只讲概念上的差异,帮助你判断迁移工作量,不给出 17 的代码。

其实 URP 14 的源码里已经能看到它的影子:ScriptableRenderPass 有一个 internal virtual void RecordRenderGraph(RenderGraph renderGraph, ref RenderingData renderingData),注释写着 “TODO RENDERGRAPH”;UniversalRenderPipeline.Render 里根据一个内部开关 useRenderGraph 选择 RecordAndExecuteRenderGraph 或传统的 Setup + Execute。在 14 里这条路径是内部实验性质的,用户的 Feature 不会走它。

方面URP 14(本书)URP 17 Render Graph(概念)
Pass 的核心方法OnCameraSetup + Execute,直接录制命令RecordRenderGraph:先“声明”这个 Pass 读哪些纹理、写哪些纹理,再提供一个渲染函数,由 Render Graph 统一编排执行
相机颜色/深度SetupRenderPasses 里取 cameraColorTargetHandle从帧数据(frame data)里取当前的颜色/深度纹理句柄,句柄只在本帧图中有效
临时纹理自己 ReAllocateIfNeeded 持有 RTHandle,Dispose 里释放在图中创建“瞬时”纹理,生命周期由 Render Graph 管理,可与其他 Pass 复用同一块显存
渲染目标ConfigureTarget 或手动 SetRenderTarget,需要自己保证恢复声明写入的附件,由图负责绑定;不再存在“忘了恢复目标”的问题
输入依赖ConfigureInput仍有类似的输入声明,同时每个 Pass 显式声明读取的纹理,图据此自动排序、剔除无用 Pass
移动端优化手动 load/store action、可选 Native RenderPass图能自动合并相邻 Pass 为同一个原生 Render Pass(subpass),减少 TBDR 上的带宽

迁移时,本章的知识大多仍然有效:Feature 的职责划分、RenderPassEvent 的含义、Blit.hlsl 的 Shader 约定(_BlitTexture、Vert)、DrawRenderers 的三件套概念、Volume 的读取方式都没有变。需要重写的是 Pass 的 C# 部分:从“我在 Execute 里直接下命令”变成“我向图声明资源依赖并提供渲染回调”。Unity 6 还提供了关闭 Render Graph 的兼容模式,旧写法在兼容模式下可以继续工作,但官方将其定位为过渡方案。具体 API 名称与细节请以你使用的 URP 版本文档和源码为准。

本章小结

  • Renderer Feature 挂在 Renderer Data 上,每帧通过 AddRenderPasses 把自己的 Pass 放进队列;Pass 按 renderPassEvent 稳定排序后与内置 Pass 一起执行。
  • 回调分工:Create 建材质和 Pass(会被多次调用)、AddRenderPasses 决定入队并 ConfigureInput、SetupRenderPasses 取相机目标、OnCameraSetup 分配 RT 和配置目标、Execute 录制命令、Dispose 释放一切。
  • RTHandle 用 RenderingUtils.ReAllocateIfNeeded 管理,每帧调用、按需重分配;全屏效果显式指定 Bilinear + Clamp;自己分配的才释放。
  • 全屏 Shader 统一用 core Blit.hlsl 的 Vert、_BlitTexture、TEXTURE2D_X;C# 侧用 Blitter.BlitCameraTexture,读写同一张纹理时借助临时 RT。
  • 手动切换渲染目标后要恢复颜色+深度,或用 ConfigureTarget 让 Renderer 跟踪。
  • Full Screen Pass 适合单 Pass 全屏效果;Render Objects 适合按层重画;更复杂的需求用 DrawRenderers + 自己的 RT 实现遮罩、描边、离屏合成。
  • URP 17 的 Render Graph 改变了 Pass 的 C# 写法(声明资源 + 渲染回调),但 Shader 约定和整体思路不变。

练习

  1. 给 TintFeature 增加一个 bool onlyGameCamera 选项,勾选时 Scene 视图不显示效果。
  2. 把案例 25.1 改写为使用 ScriptableRenderPass.Blit(cmd, ref renderingData, material, 0) 的双缓冲版本,用 Frame Debugger 对比两种写法的绘制次数。
  3. 修改 LayerOutlineFeature:遮罩 RT 用半分辨率,合成时观察线宽和锯齿的变化,并思考此时为什么不能再和相机深度一起绑定。
  4. 为幽灵层增加“受击时闪白”:在合成 Shader 里加一个 _Flash 参数,由 C# 每帧写入。
  5. 用 RenderObjects 的 Camera 覆盖功能实现第一人称武器:武器所在层从 Opaque Layer Mask 去掉,再用一个 FOV 更小的 RenderObjects 画出来。观察武器插进墙里时会发生什么,并思考需要再加一个什么 Pass 来解决。