GameTech/URP 渲染实战

案例 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');
  }
}

逐段说明:

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');
  }
}
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 直观。本书统一使用显式写法,读者理解原理后可以自行替换。