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

自定义后处理

URP 14 没有提供“自定义后处理”的专用接口,但第 25 章的 Renderer Feature 加上 Volume 框架,就能做出和内置 Bloom、Vignette 用起来一模一样的效果:在 Volume 上添加覆盖项、按区域混合、按权重淡入淡出。本章先把 Volume 的工作机制讲透,再搭一个小框架,然后用它实现十种常见的全屏效果。

本章你会学到
Volume、VolumeProfile、VolumeComponent、VolumeParameter 如何按优先级与权重混合
VolumeComponent + IPostProcessComponent + Feature 读取 VolumeStack 的标准写法
乒乓 RT、降采样金字塔、命令缓冲里逐次参数的正确传法
高斯 / Kawase / Dual Kawase 模糊、Bloom、径向模糊、色差、暗角、像素化、CRT、Sobel 描边、受击红屏

26.1 Volume 框架是怎么工作的

先在场景里观察一下 URP 自带后处理的用法:给物体加 Volume 组件,指定一个 VolumeProfile 资产,在 Profile 里 Add Override › Post-processing › Bloom,勾选要覆盖的参数并填值。这一套由 core 包的四个类型组成:

类型是什么源码位置(core 14)
Volume场景里的 MonoBehaviour:全局或局部(靠 Collider 定义范围)、优先级 priority、权重 weight、混合距离 blendDistanceRuntime/Volume/Volume.cs
VolumeProfileScriptableObject 资产,装着若干个 VolumeComponentVolumeProfile.cs
VolumeComponent一组相关参数(例如 Bloom 的阈值、强度、散射……)。我们要继承的就是它VolumeComponent.cs
VolumeParameter<T>单个参数:值 + 是否覆盖(overrideState)+ 如何插值VolumeParameter.cs

渲染每个相机之前,URP 调用 VolumeManager.instance.Update(trigger, layerMask)(在 UniversalRenderPipeline.UpdateVolumeFramework 里),把场景中所有 Volume 混合成一个结果,存进 VolumeManager.instance.stack。混合规则读源码最清楚(摘自 core 14 VolumeManager.Update,略有删减):

ReplaceData(stack);                         // 1) 栈里每个参数先重置为“默认值”,overrideState = false
var volumes = GrabVolumes(layerMask);        // 2) 取出相机 Volume Mask 内的 Volume,按 priority 从低到高排序
foreach (var volume in volumes)
{
    if (!volume.enabled || volume.profileRef == null || volume.weight <= 0f) continue;
    if (volume.isGlobal)
    {   // 3) 全局 Volume:直接按权重插值
        OverrideData(stack, volume.profileRef.components, Mathf.Clamp01(volume.weight));
        continue;
    }
    // 4) 局部 Volume:求相机到 Collider 的最近距离 d,超出 blendDistance 就跳过
    float interpFactor = 1f;
    if (blendDistSqr > 0f) interpFactor = 1f - (closestDistanceSqr / blendDistSqr);
    OverrideData(stack, volume.profileRef.components, interpFactor * Mathf.Clamp01(volume.weight));
}

OverrideData 对 Profile 里每个启用的组件调用 component.Override(stackState, t),后者只处理 overrideState 为 true 的参数:stackValue = Interp(stackValue, profileValue, t)。由此得出几条非常实用的结论:

  • 没有被覆盖的参数保持默认值。所以 VolumeComponent 里每个参数的默认值必须是“没有效果”的值——比如 Bloom 的强度默认 0。场景里没有任何 Volume 时,栈里拿到的就是这组默认值。
  • 优先级高的后处理:按 priority 从低到高依次插值,后插值的会“盖过”前面的结果,所以优先级高者胜出。权重小于 1 时,它只把当前值往自己的值拉过去一部分。
  • 插值方式由参数类型决定:FloatParameter、ColorParameter、Vector2Parameter 线性插值;IntParameter 插值后截断为整数;BoolParameter 和枚举参数是“跳变”——只要 t > 0 就取目标值。设计参数时要考虑这一点:想要能平滑淡入淡出的量,就做成浮点。
  • 局部 Volume 的衰减按距离平方的比例计算(1 - d²/blend²),不是线性的:刚进入混合范围时变化慢,靠近边界时变化快。
默认值(ReplaceData)saturation = 1 全局 Volume,priority 0saturation = 0.8,weight 1 局部 Volume,priority 1saturation = 0,t = 0.5 s = 1 s = lerp(1, 0.8, 1) = 0.8 s = lerp(0.8, 0, 0.5) = 0.4 VolumeStacksaturation = 0.4 每个相机渲染前重新算一遍(按相机位置)
图 26-1VolumeStack 的计算:从默认值出发,按优先级从低到高,用每个 Volume 的有效权重 t 依次插值
说明相机的 Volume Update Mode 设为 Via Scripting 时,URP 不会每帧更新这个相机的栈(源码里改用相机自己缓存的 volumeStack),需要你在参数变化后手动调用 camera.UpdateVolumeStack()。对于静态后处理设置,这能省下每帧的混合开销;但如果你做了受击红屏这类动态效果,记得别用这个模式,或者在触发时手动更新。编辑器非运行状态和 Scene 视图相机总是每帧更新。

26.2 自定义后处理的整体结构

URP 的内置后处理全部在一个大 Pass(PostProcessPass)里执行,它不认识我们写的 VolumeComponent。所以自定义后处理的结构是:Volume 组件只负责“存参数和被混合”,一个 Renderer Feature 负责“每帧从栈里读出混合结果,决定要不要画、怎么画”。

Volume ×NMyEffectVolume VolumeManager.stack(混合结果) FeatureAddRenderPasses PassExecute:Blit stack.GetComponent<T>() IsActive() 为假 → 不入队 为真 → 参数写入材质,入队 UpdateVolumeFramework(每个相机渲染前)
图 26-2自定义后处理的数据流:Volume 被混合进栈,Feature 在 AddRenderPasses 里读取栈中的组件,按 IsActive 决定是否让 Pass 入队

这里的 IPostProcessComponent 接口(定义在 URP 14 Runtime/Passes/PostProcessPass.cs 的开头)只有两个方法:IsActive() 和 IsTileCompatible()。前者是这个架构的开关:栈里的参数等于默认值时返回 false,Feature 就不入队,效果关闭时零开销。后者在 URP 14 源码中并没有被调用,按接口要求返回 false 即可。

和 Built-in 的 Post Processing Stack v2 对比

在 Built-in 管线里写自定义后处理,常用的是 Post Processing Stack v2(PPv2):继承 PostProcessEffectSettings 存参数,继承 PostProcessEffectRenderer<T> 写渲染,用特性标注插入点。URP 14 的源码里已经没有任何对 PPv2 的引用,PPv2 的效果在 URP 下不会执行。两者概念上可以一一对应:PostProcessEffectSettings ↔ VolumeComponent,IsEnabledAndSupported ↔ IsActive,PostProcessEffectRenderer.Render ↔ 我们的 Pass,插入点 BeforeStack / AfterStack ↔ BeforeRenderingPostProcessing / AfterRenderingPostProcessing。迁移旧效果时,参数类与 Shader 算法大都可以保留,主要工作是把渲染部分改写成 Feature + Pass,并把 _MainTex 换成 _BlitTexture。

案例 26.1饱和度与对比度:第一个 Volume 驱动的后处理入门单独打开 ↗

目标:写一个可以在 Volume 上添加的 “Cookbook/Saturation” 覆盖项,支持饱和度(0 = 灰度)与对比度;完整地、不借助任何基类写一遍。

先是 Volume 组件。VolumeComponentMenuForRenderPipeline 让它出现在 Add Override 菜单里,并只在 URP 下显示;ClampedFloatParameter 会在 Inspector 里画成滑条:

Assets/Cookbook/Ch26/SaturationVolume.cs(节选:volume)
// VolumeComponentMenuForRenderPipeline:出现在 Volume 的 Add Override 菜单里,并且只对 URP 显示
[Serializable, VolumeComponentMenuForRenderPipeline("Cookbook/Saturation", typeof(UniversalRenderPipeline))]
public sealed class SaturationVolume : VolumeComponent, IPostProcessComponent
{
    // 默认值要等于“无效果”的值:没有任何 Volume 覆盖时,栈里拿到的就是默认值
    [Tooltip("0 = 灰度,1 = 原图,>1 = 更鲜艳")]
    public ClampedFloatParameter saturation = new ClampedFloatParameter(1f, 0f, 2f);

    [Tooltip("围绕中灰 0.18(线性)缩放的对比度")]
    public ClampedFloatParameter contrast = new ClampedFloatParameter(1f, 0f, 2f);

    // 由 Feature 调用:没有效果时整个 Pass 都不入队,零开销
    public bool IsActive() => saturation.value != 1f || contrast.value != 1f;

    // 接口要求实现;URP 14 源码里并没有调用它,返回 false 即可
    public bool IsTileCompatible() => false;
}

Shader 的饱和度部分很直接:在同亮度的灰与原色之间插值,参数大于 1 时就是外插,颜色更鲜艳。对比度则值得说一下:常见写法是 (c - 0.5) * contrast + 0.5,但这是给 sRGB 编码的 LDR 颜色设计的;在线性 HDR 缓冲里,0.5 并不是视觉上的中灰(线性中灰约为 0.18),而且大于 1 的高光会被推到非常大的值。这里在 log2 空间以 0.18 为支点缩放,等价于对亮度做幂次变换 c′=0.18·(c/0.18)k,暗部和亮部都按“档位”(stop)对称地拉开或压缩,URP 自己的 Color Adjustments 也采用以中灰为支点的思路:

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

    // 饱和度:在“原色”与“同亮度的灰”之间插值,>1 时外插 = 更鲜艳
    half luma = dot(color.rgb, half3(0.2126, 0.7152, 0.0722));
    color.rgb = lerp(luma.xxx, color.rgb, _Saturation);

    // 对比度:在 log2 空间以中灰为支点缩放,HDR 亮部不会被直接截断
    const half midGrey = 0.18;
    half3 logC = log2(max(color.rgb, 1e-5) / midGrey);
    color.rgb = midGrey * exp2(logC * _Contrast);
    return color;
}
vec4 Frag()
{
    vec4 color = texture(_BlitTexture, texcoord);

    // 饱和度:在“原色”与“同亮度的灰”之间插值,>1 时外插 = 更鲜艳
    float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
    color.rgb = mix(vec3(luma), color.rgb, _Saturation);

    // 对比度:在 log2 空间以中灰为支点缩放,HDR 亮部不会被直接截断
    const float midGrey = 0.18;
    vec3 logC = log2(max(color.rgb, 1e-5) / midGrey);
    color.rgb = midGrey * exp2(logC * _Contrast);
    return color;
}

Feature 的关键部分是 AddRenderPasses:

Assets/Cookbook/Ch26/SaturationFeature.cs(节选:add-passes)
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
    if (m_Pass == null) return;
    ref var cameraData = ref renderingData.cameraData;
    if (cameraData.cameraType == CameraType.Preview || cameraData.cameraType == CameraType.Reflection) return;
    // 和 URP 自带后处理保持一致:相机没勾 Post Processing 就不做
    if (!cameraData.postProcessEnabled) return;

    // VolumeManager 已经在渲染这个相机之前,按相机位置/层把所有 Volume 混合进 stack
    var volume = VolumeManager.instance.stack.GetComponent<SaturationVolume>();
    if (volume == null || !volume.IsActive()) return;   // 没效果就不入队

    m_Pass.renderPassEvent = passEvent;
    m_Pass.Setup(volume);
    renderer.EnqueuePass(m_Pass);
}
addPasses(frame, enqueue) {
  if (!this.material) return;
  // 和 URP 自带后处理保持一致:相机没勾 Post Processing 就不做
  if (!frame.camera.postProcessing) return;
  // 按优先级、权重混合后的 Volume 参数
  const volume = frame.getVolume(VOLUME);
  if (!volume || !isActive(volume)) return;   // 没效果就不入队

  // SaturationPass.Setup:.value 是混合后的最终值
  this.material.setFloat('_Saturation', volume.saturation);
  this.material.setFloat('_Contrast', volume.contrast);
  enqueue(this.passEvent, (fr) => {
    const temp = fr.getTemporaryRT('_Ch26SaturationTemp', fr.width, fr.height);
    fr.blit(fr.cameraColorTexture(), temp, this.material, 0);
    fr.blit(temp.texture, 'camera');
    fr.bindCamera();
  }, 'Ch26 Saturation');
}
  • cameraData.postProcessEnabled 对应相机上的 Post Processing 勾选框。和内置后处理保持一致,用户关掉相机的后处理时,我们的效果也应该消失。
  • 栈在这个时间点已经是当前相机的混合结果:URP 先 UpdateVolumeFramework,再走到渲染流程里的 AddRenderPasses,同一帧不同相机看到的是各自的栈。
  • 参数在 Setup(volume) 里写进材质。这些值在本帧内对这个相机不变,用 material.SetFloat 没问题。

Pass 和第 25 章的 TintPass 完全同构(拷出处理、拷回、恢复目标),这里不再重复讲解:

Assets/Cookbook/Ch26/SaturationFeature.cs(节选:pass)
class SaturationPass : ScriptableRenderPass
{
    static readonly int s_Saturation = Shader.PropertyToID("_Saturation");
    static readonly int s_Contrast = Shader.PropertyToID("_Contrast");

    readonly Material m_Material;
    RTHandle m_Color, m_Depth, m_Temp;

    public SaturationPass(Material m)
    {
        m_Material = m;
        profilingSampler = new ProfilingSampler("Ch26 Saturation");
    }

    public void Setup(SaturationVolume v)
    {
        // .value 是混合后的最终值(多个 Volume 按权重插值的结果)
        m_Material.SetFloat(s_Saturation, v.saturation.value);
        m_Material.SetFloat(s_Contrast, v.contrast.value);
    }

    public void SetTarget(RTHandle color, RTHandle depth) { m_Color = color; m_Depth = depth; }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.msaaSamples = 1;
        desc.depthBufferBits = 0;
        RenderingUtils.ReAllocateIfNeeded(ref m_Temp, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_Ch26SaturationTemp");
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            Blitter.BlitCameraTexture(cmd, m_Color, m_Temp, m_Material, 0);
            Blitter.BlitCameraTexture(cmd, m_Temp, m_Color);
            CoreUtils.SetRenderTarget(cmd, m_Color, m_Depth);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public void Dispose()
    {
        m_Temp?.Release();
        m_Temp = null;
    }
}
// 案例 26.1:读取 Volume 的后处理 Feature(对应 Ch26/SaturationFeature.cs + SaturationVolume.cs,不依赖基类)。
import { Material } from '../engine/shader.js';
import { RenderPassEvent } from '../engine/features.js';
import { defineVolume } from './Cookbook.Ch26.PostFeatureBase.js';

// 默认值要等于“无效果”的值:没有任何 Volume 覆盖时,栈里拿到的就是默认值
const VOLUME = 'Cookbook.Ch26.SaturationVolume';
defineVolume(VOLUME, { saturation: 1, contrast: 1 });
const isActive = (v) => v.saturation !== 1 || v.contrast !== 1;

export default class SaturationFeature {
  constructor(engine, data) {
    this.engine = engine;
    this.data = data || {};
    this.passEvent = this.data.passEvent ?? RenderPassEvent.BeforeRenderingPostProcessing;
  }

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

  addPasses(frame, enqueue) {
    if (!this.material) return;
    // 和 URP 自带后处理保持一致:相机没勾 Post Processing 就不做
    if (!frame.camera.postProcessing) return;
    // 按优先级、权重混合后的 Volume 参数
    const volume = frame.getVolume(VOLUME);
    if (!volume || !isActive(volume)) return;   // 没效果就不入队

    // SaturationPass.Setup:.value 是混合后的最终值
    this.material.setFloat('_Saturation', volume.saturation);
    this.material.setFloat('_Contrast', volume.contrast);
    enqueue(this.passEvent, (fr) => {
      const temp = fr.getTemporaryRT('_Ch26SaturationTemp', fr.width, fr.height);
      fr.blit(fr.cameraColorTexture(), temp, this.material, 0);
      fr.blit(temp.texture, 'camera');
      fr.bindCamera();
    }, 'Ch26 Saturation');
  }
}
Assets/Cookbook/Ch26/Editor/Ch26Shots.cs(节选:shot-saturation)
[BookShot("ch26-saturation", W = 640, H = 400, Panels = 3)]
static void Saturation(Shot s)
{
    Stage(s);
    s.Feature<SaturationFeature>();
    var v = s.Post().Add<SaturationVolume>();
    if (s.Panel == 1) v.saturation.Override(0f);
    if (s.Panel == 2) { v.saturation.Override(1.6f); v.contrast.Override(1.25f); }
}
图 26-3左:saturation = 1(IsActive 为假,Pass 不入队);中:saturation = 0,灰度;右:saturation = 1.6、contrast = 1.25

下面这组图只改了全局 Volume 的 weight,Profile 里 saturation 一直是 0。可以看到权重如何把栈里的值从默认值 1 线性地拉向 0:

图 26-4同一个 Profile(saturation = 0),Volume 权重分别为 0.25、0.5、1。栈中的饱和度依次为 0.75、0.5、0

在编辑器里使用:

  1. Renderer Data 上 Add Renderer Feature › Saturation Feature,拖入 Shader。
  2. 场景里选中(或新建)一个 Volume,Add Override › Cookbook › Saturation,勾选 saturation 前的复选框并调整。
  3. 确认相机勾选了 Rendering › Post Processing。

参数类型怎么选

core 包在 VolumeParameter.cs 里预定义了几十种参数类型,最常用的是下面这些。选型时同时考虑三件事:Inspector 里怎么显示、能不能插值、默认值是什么。

类型Inspector插值典型用途
ClampedFloatParameter(v, min, max)滑条线性强度、比例类参数,首选
MinFloatParameter(v, min)数值框,下限截断线性阈值、HDR 强度这类没有上限的量
ClampedIntParameter(v, min, max)整数滑条线性后截断迭代次数、采样数
BoolParameter(v)复选框跳变开关类选项
ColorParameter(v, hdr, showAlpha, showEyeDropper)拾色器RGB 线性染色、暗角颜色;HDR 颜色把第二个参数设为 true
Vector2Parameter(v)两个数值框线性屏幕中心点
TextureParameter(v)纹理槽跳变LUT、镜头污渍贴图
NoInterpClampedFloatParameter 等 NoInterp 系列同对应类型不插值(跳变)“质量档位”之类在两个 Volume 之间渐变没有意义的量
自定义 VolumeParameter<TEnum> 子类下拉框跳变模式选择(见案例 26.3 的 KawaseModeParameter)

枚举参数要声明一个具体的、带 [Serializable] 的子类,而不是直接写 VolumeParameter<KawaseMode> 字段——URP 自己的 TonemappingModeParameter 也是这样做的,保证序列化与 Inspector 绘制在各版本上都稳定。另一个常见失误与反射有关:VolumeComponent 在 OnEnable 里用反射收集所有 VolumeParameter 类型的字段(公有、私有都算,按声明顺序)建立参数列表。写成属性({ get; set; })就不会被收集,也就不参与混合;私有字段虽然会被收集,但没有 [SerializeField] 就不会写进 Profile 资产,Inspector 里调的值保存不下来;字段没有初始化(为 null)时,控制台会出现 “contains a null parameter” 警告,该参数被忽略。最省心的写法就是本章这样:公有字段 + 声明时直接 new 出默认值。

26.3 抽一个小框架

案例 26.1 里有大量每个效果都要重复的代码:建材质、跳过预览相机、检查 postProcessEnabled、读栈、判 IsActive、取相机目标、分配临时 RT、ProfilingScope、恢复目标、Dispose。把它们抽成两个基类,后面每个效果就只剩“参数”和“怎么画”两件事。

Assets/Cookbook/Ch26/PostFeatureBase.cs(节选:feature-base)
public abstract class PostFeatureBase : ScriptableRendererFeature
{
    public RenderPassEvent passEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    [Tooltip("留空则按 ShaderName 查找;正式项目请直接拖入,保证打包时 Shader 不被剔除")]
    public Shader shader;

    protected abstract string ShaderName { get; }
    protected abstract PostPassBase CreatePass(Material material);

    Material m_Material;
    PostPassBase m_Pass;

    public override void Create()
    {
        Dispose(true);
        if (shader == null) shader = Shader.Find(ShaderName);
        if (shader == null) { Debug.LogWarning($"{name}: 找不到 Shader {ShaderName}"); return; }
        m_Material = CoreUtils.CreateEngineMaterial(shader);
        m_Pass = CreatePass(m_Material);
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (m_Pass == null) return;
        ref var cameraData = ref renderingData.cameraData;
        if (cameraData.cameraType == CameraType.Preview || cameraData.cameraType == CameraType.Reflection) return;
        if (!cameraData.postProcessEnabled) return;

        // Prepare:读 VolumeStack、写材质常量;返回 false 表示本帧无效果
        if (!m_Pass.Prepare(VolumeManager.instance.stack, ref renderingData)) return;

        m_Pass.renderPassEvent = passEvent;
        m_Pass.ConfigureInput(m_Pass.RequiredInputs);   // 深度/法线等输入必须在入队前声明
        renderer.EnqueuePass(m_Pass);
    }

    public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
    {
        m_Pass?.SetTarget(renderer.cameraColorTargetHandle, renderer.cameraDepthTargetHandle);
    }

    protected override void Dispose(bool disposing)
    {
        m_Pass?.Dispose();
        m_Pass = null;
        CoreUtils.Destroy(m_Material);
        m_Material = null;
    }
}
export class PostFeatureBase {
  constructor(engine, data, scene) {
    this.engine = engine;
    this.scene = scene;
    this.data = data || {};
    this.passEvent = this.data.passEvent ?? RenderPassEvent.BeforeRenderingPostProcessing;
  }

  get shaderName() { throw new Error('子类要实现 shaderName'); }
  createPass(material) { throw new Error('子类要实现 createPass'); }

  async init() {
    // shader 留空则按 ShaderName 查找
    const name = (this.data.shader && this.data.shader.shader) || this.shaderName;
    this.material = new Material(await this.engine.shaders.get(name));
    this.pass = this.createPass(this.material);
  }

  addPasses(frame, enqueue) {
    if (!this.pass) return;
    if (!frame.camera.postProcessing) return;          // 相机没勾 Post Processing 就不做
    // Prepare:读 Volume、写材质常量;返回 false 表示本帧无效果
    if (!this.pass.prepare(frame)) return;
    // ConfigureInput:深度/法线等输入必须在入队前声明
    const req = this.pass.requiredInputs;
    if (req & PassInput.Depth) frame.requireDepth = true;
    if (req & PassInput.Color) frame.requireOpaque = true;
    if (req & PassInput.Normal && this.pass.renderNormals) {
      // 前向路径下,Normal 输入会让 URP 在不透明物体之前多跑一遍 DepthNormals 预渲染
      enqueue(RenderPassEvent.BeforeRenderingOpaques, (fr) => this.pass.renderNormals(fr), 'DepthNormalPrepass');
    }
    enqueue(this.passEvent, (fr) => {
      this.pass.render(fr);
      fr.bindCamera();                                  // 还给 URP 一个“原样”的目标
    }, this.pass.name);
  }
}

这个 Feature 基类和案例 26.1 的唯一区别是把“读哪个 Volume 组件、写哪些参数”下放给了 Pass 的 Prepare,并在入队前统一调用 ConfigureInput(m_Pass.RequiredInputs)——需要深度、法线的效果(案例 26.9)只要在 Pass 里重写一个属性即可。

Assets/Cookbook/Ch26/PostFeatureBase.cs(节选:pass-base)
public abstract class PostPassBase : ScriptableRenderPass
{
    protected readonly Material material;
    protected RTHandle cameraColor, cameraDepth;

    protected PostPassBase(Material material, string profilerName)
    {
        this.material = material;
        profilingSampler = new ProfilingSampler(profilerName);
    }

    public virtual ScriptableRenderPassInput RequiredInputs => ScriptableRenderPassInput.None;

    public abstract bool Prepare(VolumeStack stack, ref RenderingData renderingData);

    // 真正的绘制:source 就是相机颜色,结果也要写回它
    protected abstract void Render(CommandBuffer cmd, ref RenderingData renderingData);

    public void SetTarget(RTHandle color, RTHandle depth) { cameraColor = color; cameraDepth = depth; }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            Render(cmd, ref renderingData);
            CoreUtils.SetRenderTarget(cmd, cameraColor, cameraDepth);   // 还给 URP 一个“原样”的目标
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public virtual void Dispose() { }

    // ---------- 工具 ----------
    /// 相机颜色同格式、去掉 MSAA 与深度、按 downsample 缩小的描述符
    protected static RenderTextureDescriptor ColorDesc(ref RenderingData renderingData, int downsample = 1)
    {
        var desc = renderingData.cameraData.cameraTargetDescriptor;
        desc.msaaSamples = 1;
        desc.depthBufferBits = 0;
        desc.width = Mathf.Max(1, desc.width / downsample);
        desc.height = Mathf.Max(1, desc.height / downsample);
        return desc;
    }

    protected static void Alloc(ref RTHandle handle, in RenderTextureDescriptor desc, string name,
        FilterMode filter = FilterMode.Bilinear)
    {
        RenderingUtils.ReAllocateIfNeeded(ref handle, desc, filter, TextureWrapMode.Clamp, name: name);
    }

    protected static void Release(ref RTHandle handle)
    {
        handle?.Release();
        handle = null;
    }
}
export class PostPassBase {
  constructor(material, name) { this.material = material; this.name = name; }
  get requiredInputs() { return PassInput.None; }
  prepare(frame) { return false; }
  render(fr) {}

  // 相机颜色同格式、去掉 MSAA 与深度、按 downsample 缩小的临时 RT
  static colorRT(fr, name, downsample = 1) {
    return fr.getTemporaryRT(name, Math.max(1, Math.floor(fr.width / downsample)), Math.max(1, Math.floor(fr.height / downsample)));
  }
}

Pass 基类把 Execute 固定下来,子类只实现 Render:在里面用 cameraColor 作为输入和最终输出,随意使用自己的中间 RT。ColorDesc 给出“和相机颜色同格式、无 MSAA、无深度、可按倍数缩小”的描述符,模糊类效果都要用。

Assets/Cookbook/Ch26/PostFeatureBase.cs(节选:simple-pass)
/// 最常见的“单 Pass 全屏效果”:相机颜色 →(材质)→ 临时 RT → 拷回相机颜色。
public abstract class SimplePostPass<T> : PostPassBase where T : VolumeComponent, IPostProcessComponent
{
    RTHandle m_Temp;

    protected SimplePostPass(Material m, string name) : base(m, name) { }

    /// 把 Volume 参数写进材质
    protected abstract void Apply(T volume, ref RenderingData renderingData);

    public override bool Prepare(VolumeStack stack, ref RenderingData renderingData)
    {
        var v = stack.GetComponent<T>();
        if (v == null || !v.IsActive()) return false;
        Apply(v, ref renderingData);
        return true;
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        Alloc(ref m_Temp, ColorDesc(ref renderingData), "_Ch26Temp_" + typeof(T).Name);
    }

    protected override void Render(CommandBuffer cmd, ref RenderingData renderingData)
    {
        Blitter.BlitCameraTexture(cmd, cameraColor, m_Temp, material, 0);
        Blitter.BlitCameraTexture(cmd, m_Temp, cameraColor);
    }

    public override void Dispose() => Release(ref m_Temp);
}
export class SimplePostPass extends PostPassBase {
  constructor(material, name, volumeType) { super(material, name); this.volumeType = volumeType; }
  isActive(v) { return false; }
  apply(v, frame) {}

  prepare(frame) {
    const v = frame.getVolume(this.volumeType);
    if (!v || !this.isActive(v)) return false;
    this.apply(v, frame);
    return true;
  }

  render(fr) {
    const temp = PostPassBase.colorRT(fr, '_Ch26Temp_' + this.volumeType.split('.').pop());
    fr.blit(fr.cameraColorTexture(), temp, this.material, 0);
    fr.blit(temp.texture, 'camera');
  }
}

大多数效果只有一个 Pass、一个临时 RT,于是再提供一个泛型的 SimplePostPass<T>:泛型参数就是 Volume 组件类型,子类只需在 Apply 里把参数写进材质。使用它的效果,C# 部分通常不超过 20 行。

为什么每个效果一个 Feature,而不是一个 Feature 管所有效果?

两种结构都可行。每效果一个 Feature 的好处是彼此独立、可以单独开关、单独设置事件,适合教学和中小项目。缺点是 N 个效果就有 N 次“拷出、拷回”的全屏读写。URP 内置后处理选择了另一条路:一个 Pass 里按顺序处理所有效果,能合并的(暗角、色差、色调映射、颗粒等)合并进同一个 Uber Shader,只做一次全屏读写。效果多、目标平台是移动端时,值得把自己的颜色类效果也合并成一个 Uber Pass——前提是你已经用本章的方式把每个效果单独验证正确了。

多个效果的顺序

同一事件上的多个 Pass 按入队顺序执行,也就是 Renderer Data 里 Feature 列表从上到下的顺序。需要精确控制时,用事件偏移:例如模糊放在 BeforeRenderingPostProcessing,色差放在 BeforeRenderingPostProcessing + 1。事件选择的一般原则:

  • BeforeRenderingPostProcessing:颜色仍是线性 HDR,适合依赖物理亮度的效果——Bloom、模糊、景深类、描边。在 URP 色调映射之前完成,结果会和场景一起被色调映射。
  • AfterRenderingPostProcessing:URP 的色调映射、调色已经完成,颜色是 LDR(0~1)。适合风格化的“屏幕滤镜”:像素化(希望色阶基于最终颜色)、CRT、受击红屏(希望红色不被色调映射压暗)。注意此时 URP 的 FXAA 等抗锯齿可能已经做过,像素化之类会破坏它的结果。

26.4 乒乓 RT 与命令缓冲中的参数

多 Pass 效果(模糊迭代、Bloom 金字塔)有两个绕不开的技术点。

乒乓(Ping-Pong):一次迭代的输出是下一次的输入,而一张 RT 不能同时读写,于是准备两张 A、B,A→B、B→A 来回倒。C# 里用元组交换引用 (src, dst) = (dst, src),非常干净。

相机颜色 A (½) B (½) A (½) 相机颜色 缩小 水平 竖直 …放大 一次“水平 + 竖直”为一次迭代;迭代 n 次就在 A、B 之间往返 n 次
图 26-5可分离模糊的乒乓 RT 流程

逐次变化的参数:第 25 章提醒过,材质属性在命令执行时才被读取。如果在一个 for 循环里对同一个材质 SetFloat("_Offset", i) 然后 Blit,所有 Blit 最终都读到最后一次的值。正确做法是把参数记录进命令缓冲:cmd.SetGlobalFloat、cmd.SetGlobalVector、cmd.SetGlobalTexture。它们按录制顺序生效,每次 Blit 看到的是它之前最近一次设置的值。要让全局值生效,Shader 里对应的变量不能出现在 Properties 块里,也不能在材质上 Set 过(材质上的同名属性优先级高于全局值)。本章约定:一帧内不变的参数写材质,迭代中变化的参数用 cmd.SetGlobalXxx。

案例 26.2可分离高斯模糊与降采样进阶单独打开 ↗

目标:实现可调强度的全屏高斯模糊,用于暂停菜单背景、UI 毛玻璃等。

原理:二维高斯函数可以拆成两个一维高斯的乘积:

G(x,y)=12πσ2e−x2+y22σ2=g(x)·g(y)

所以一个 N×N 的二维卷积,等价于先做一遍水平 N 抽头卷积、再做一遍竖直 N 抽头卷积:每像素的采样数从 N² 降到 2N。9×9 的核从 81 次降到 18 次。

还能再省一半:利用 GPU 的双线性过滤,在两个相邻纹素之间的某个位置采样一次,硬件会自动按距离给两个纹素加权。只要采样位置选得恰当,一次采样就等于两个权重的加权和。设相邻两个抽头偏移为 o1,o2、权重为 w1,w2,合并后:

w=w1+w2,o=o1w1+o2w2w

用 9 个离散权重 [66, 220, 495, 792, 924, 792, 495, 220, 66]/4070 时,中心单独一次,两侧各把 (1,2)、(3,4) 两对合并,就得到偏移 1.3846 和 3.2308、权重 0.3162 和 0.0703——9 个权重只要 5 次采样。这组权重与 URP 自己 Bloom.shader 的模糊 Pass 相同:

Assets/Cookbook/Ch26/GaussianBlur.shader(节选:blur)
// 9 抽头核 [66 220 495 792 924 792 495 220 66]/4070(二项式第 12 行去掉最外两对后归一化,
// 与 URP Bloom.shader 用的是同一组权重)。相邻两项合并成一次双线性采样:
// 偏移 0, 1.3846, 3.2308(纹素),权重 0.2270, 0.3162, 0.0703 —— 9 个权重只要 5 次采样
half4 Blur1D(float2 uv, float2 dir)
{
    const float o1 = 1.3846153846, o2 = 3.2307692308;
    const half  w0 = 0.2270270270, w1 = 0.3162162162, w2 = 0.0702702703;
    float2 du = dir * _BlitTexture_TexelSize.xy * _Spread;
    half4 c = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv) * w0;
    c += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + du * o1) * w1;
    c += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - du * o1) * w1;
    c += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + du * o2) * w2;
    c += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - du * o2) * w2;
    return c;
}
// 9 抽头核 [66 220 495 792 924 792 495 220 66]/4070(二项式第 12 行去掉最外两对后归一化,
// 与 URP Bloom.shader 用的是同一组权重)。相邻两项合并成一次双线性采样:
// 偏移 0, 1.3846, 3.2308(纹素),权重 0.2270, 0.3162, 0.0703 —— 9 个权重只要 5 次采样
vec4 Blur1D(vec2 uv, vec2 dir)
{
    const float o1 = 1.3846153846, o2 = 3.2307692308;
    const float w0 = 0.2270270270, w1 = 0.3162162162, w2 = 0.0702702703;
    vec2 du = dir * _BlitTexture_TexelSize.xy * _Spread;
    vec4 c = texture(_BlitTexture, uv) * w0;
    c += texture(_BlitTexture, uv + du * o1) * w1;
    c += texture(_BlitTexture, uv - du * o1) * w1;
    c += texture(_BlitTexture, uv + du * o2) * w2;
    c += texture(_BlitTexture, uv - du * o2) * w2;
    return c;
}

C# 侧:先双线性缩小到 1/N 分辨率(缩小本身就是一次低通滤波),在小图上做 n 次“水平+竖直”迭代,再双线性放大回相机颜色。_Spread 放大采样间距,进一步扩大有效半径:

Assets/Cookbook/Ch26/GaussianBlur.cs(节选:pass)
class GaussianBlurPass : PostPassBase
{
    static readonly int s_Spread = Shader.PropertyToID("_Spread");
    const int PassH = 0, PassV = 1;

    RTHandle m_A, m_B;          // 两张低分辨率 RT,来回倒(乒乓)
    int m_Iterations, m_Downsample;

    public GaussianBlurPass(Material m) : base(m, "Ch26 Gaussian Blur") { }

    public override bool Prepare(VolumeStack stack, ref RenderingData renderingData)
    {
        var v = stack.GetComponent<GaussianBlurVolume>();
        if (v == null || !v.IsActive()) return false;
        material.SetFloat(s_Spread, v.spread.value);
        m_Iterations = v.iterations.value;
        m_Downsample = v.downsample.value;
        return true;
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        var desc = ColorDesc(ref renderingData, m_Downsample);
        Alloc(ref m_A, desc, "_Ch26BlurA");
        Alloc(ref m_B, desc, "_Ch26BlurB");
    }

    protected override void Render(CommandBuffer cmd, ref RenderingData renderingData)
    {
        // 1) 降采样:双线性拷贝到小图,本身就相当于一次 2×2 盒式滤波
        Blitter.BlitCameraTexture(cmd, cameraColor, m_A, 0, true);
        // 2) 迭代:A →(水平)→ B →(竖直)→ A
        for (int i = 0; i < m_Iterations; i++)
        {
            Blitter.BlitCameraTexture(cmd, m_A, m_B, material, PassH);
            Blitter.BlitCameraTexture(cmd, m_B, m_A, material, PassV);
        }
        // 3) 放大回全分辨率(双线性),覆盖相机颜色
        Blitter.BlitCameraTexture(cmd, m_A, cameraColor, 0, true);
    }

    public override void Dispose()
    {
        Release(ref m_A);
        Release(ref m_B);
    }
}
图 26-6左:原图;中:全分辨率、1 次迭代、spread = 1,轻微柔化;右:1/4 分辨率、3 次迭代、spread = 1.5,强模糊

调参与开销:模糊半径大致正比于 downsample × spread × √iterations(多次高斯卷积的方差相加,标准差按平方根增长)。在开销上,降采样最划算:1/4 分辨率时像素数只有 1/16,迭代几次都很便宜。spread 不宜超过 2 左右,否则抽头之间的间隙会产生可见的重影条纹——想要更大的半径,加降采样或加迭代。

提示暂停菜单、毛玻璃面板通常只想让 UI 背后的画面模糊,而不是把整个相机颜色替换掉。做法是让模糊结果留在一张 RT 里,并在 Pass 末尾用 cmd.SetGlobalTexture("_UIBlurTexture", m_A) 把它暴露出去,UI 材质在片元着色器里用屏幕 UV 采样这张全局纹理即可;最后那一步“放大回相机颜色”删掉。由于菜单打开时画面通常是静止的,还可以只在打开的那一帧模糊一次、之后复用结果,开销几乎为零。

案例 26.3Kawase 与 Dual Kawase 模糊进阶单独打开 ↗

目标:实现移动端常用的两种廉价大半径模糊,并和高斯对比。

Kawase 模糊:每次迭代只在对角线 4 个点各采样一次,偏移随迭代递增(第 i 次为 i + 0.5 纹素)。每次采样落在 4 个纹素中间,双线性过滤让每个采样本身就是一次 2×2 平均,所以 4 次采样覆盖了 16 个纹素;迭代几次之后,结果非常接近高斯。它的半径随迭代次数大约线性增长,每次迭代固定 4 次采样。

Dual Kawase(Marius Bjørge 在 SIGGRAPH 2015 移动图形课程中提出):把迭代放到降采样金字塔上——下行时每级分辨率减半,用 5 次采样的核;上行时每级分辨率加倍,用 8 次采样的核。因为越往下分辨率越低,每级的像素数呈 1/4 递减,总开销几乎与级数无关,而模糊半径随级数指数增长。

下采样:5 次采样 4 (4·中心 + 4 个对角) / 8 上采样:8 次采样 (菱形 4×1 + 对角 4×2) / 12 金字塔 全分辨率½
图 26-7Dual Kawase 的两个采样核(橙色权重更大,网格为源纹理的纹素)。下采样时输出像素的中心正好落在 4 个源纹素的交点上,中心那次双线性采样就平均了 4 个纹素
Assets/Cookbook/Ch26/KawaseBlur.shader(节选:dual)
// Dual Kawase 下采样:中心权重 4 + 四个对角各 1,共 5 次采样,输出到一半分辨率
half4 FragDown(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset;
    half4 c = Tap(uv) * 4.0;
    c += Tap(uv - h);
    c += Tap(uv + h);
    c += Tap(uv + float2(h.x, -h.y));
    c += Tap(uv - float2(h.x, -h.y));
    return c * (1.0 / 8.0);
}

// Dual Kawase 上采样:菱形 4 点(权重 1)+ 对角 4 点(权重 2),共 8 次采样,输出到两倍分辨率
half4 FragUp(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset;
    half4 c = Tap(uv + float2(-h.x * 2.0, 0.0));
    c += Tap(uv + float2( h.x * 2.0, 0.0));
    c += Tap(uv + float2(0.0, -h.y * 2.0));
    c += Tap(uv + float2(0.0,  h.y * 2.0));
    c += Tap(uv + float2(-h.x,  h.y)) * 2.0;
    c += Tap(uv + float2( h.x,  h.y)) * 2.0;
    c += Tap(uv + float2( h.x, -h.y)) * 2.0;
    c += Tap(uv + float2(-h.x, -h.y)) * 2.0;
    return c * (1.0 / 12.0);
}
// Dual Kawase 下采样:中心权重 4 + 四个对角各 1,共 5 次采样,输出到一半分辨率
vec4 FragDown()
{
    vec2 uv = texcoord;
    vec2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset;
    vec4 c = Tap(uv) * 4.0;
    c += Tap(uv - h);
    c += Tap(uv + h);
    c += Tap(uv + vec2(h.x, -h.y));
    c += Tap(uv - vec2(h.x, -h.y));
    return c * (1.0 / 8.0);
}

// Dual Kawase 上采样:菱形 4 点(权重 1)+ 对角 4 点(权重 2),共 8 次采样,输出到两倍分辨率
vec4 FragUp()
{
    vec2 uv = texcoord;
    vec2 h = _BlitTexture_TexelSize.xy * 0.5 * _KawaseOffset;
    vec4 c = Tap(uv + vec2(-h.x * 2.0, 0.0));
    c += Tap(uv + vec2( h.x * 2.0, 0.0));
    c += Tap(uv + vec2(0.0, -h.y * 2.0));
    c += Tap(uv + vec2(0.0,  h.y * 2.0));
    c += Tap(uv + vec2(-h.x,  h.y)) * 2.0;
    c += Tap(uv + vec2( h.x,  h.y)) * 2.0;
    c += Tap(uv + vec2( h.x, -h.y)) * 2.0;
    c += Tap(uv + vec2(-h.x, -h.y)) * 2.0;
    return c * (1.0 / 12.0);
}
Assets/Cookbook/Ch26/KawaseBlur.cs(节选:dual)
void RenderDualKawase(CommandBuffer cmd)
{
    cmd.SetGlobalFloat(s_Offset, m_Offset);
    // 下行:全分辨率 → 1/2 → 1/4 …,每一步用 5 次采样的 Down 核
    RTHandle prev = cameraColor;
    for (int i = 1; i <= m_Iterations; i++)
    {
        Blitter.BlitCameraTexture(cmd, prev, m_Pyramid[i], material, PassDown);
        prev = m_Pyramid[i];
    }
    // 上行:逐级放大回去,每一步用 8 次采样的 Up 核,最后一步直接写回相机颜色
    for (int i = m_Iterations - 1; i >= 0; i--)
    {
        RTHandle dst = i == 0 ? cameraColor : m_Pyramid[i];
        Blitter.BlitCameraTexture(cmd, m_Pyramid[i + 1], dst, material, PassUp);
    }
}

经典 Kawase 的 C# 循环是“逐次参数必须用 cmd.SetGlobalFloat”的典型例子:每次迭代偏移不同,如果写成 material.SetFloat,5 次迭代全都会用第 5 次的偏移,效果完全不对,而且不会报任何错。

Assets/Cookbook/Ch26/KawaseBlur.cs(节选:kawase)
void RenderKawase(CommandBuffer cmd)
{
    Blitter.BlitCameraTexture(cmd, cameraColor, m_PingA, 0, true);
    var src = m_PingA; var dst = m_PingB;
    for (int i = 0; i < m_Iterations; i++)
    {
        // 每次迭代偏移不同:用 cmd.SetGlobalFloat 记录进命令缓冲。
        // 若用 material.SetFloat,所有迭代最终都会读到最后一次设置的值(见正文)
        cmd.SetGlobalFloat(s_Offset, (i + 0.5f) * m_Offset);
        Blitter.BlitCameraTexture(cmd, src, dst, material, PassKawase);
        (src, dst) = (dst, src);
    }
    Blitter.BlitCameraTexture(cmd, src, cameraColor, 0, true);
}
图 26-8左:经典 Kawase,半分辨率 5 次迭代;中:Dual Kawase 3 级(最低 1/8 分辨率);右:Dual Kawase 5 级(最低 1/32),半径大幅增加,开销只多了很小的两级
方法(1080p)每像素采样(全分辨率等效)半径增长特点
可分离高斯,1/2 分辨率,n 次迭代约 2 + 10n × ¼∝ √n最“正”,形状可控
Kawase,1/2 分辨率,n 次迭代约 2 + 4n × ¼∝ n实现最简单
Dual Kawase,k 级约 8 + 13 × (¼ + 1/16 + …) ≈ 12(最后一级上采样写全分辨率)∝ 2^k大半径最便宜,移动端首选

表中的采样数是按算法结构推算的量级,用来理解趋势;真实耗时取决于带宽、缓存和 GPU 架构,请以目标设备上的 Profiler 为准。

案例 26.4自制 Bloom:预过滤、下采样金字塔、上采样合成高阶单独打开 ↗

目标:从零实现一个与 URP 内置 Bloom 思路一致的泛光:只让足够亮的像素发光,光晕大而柔和、不闪烁,参数可由 Volume 控制。

原理:Bloom 模拟的是镜头和人眼里的光散射——极亮的光源周围出现一圈逐渐衰减的光晕。实现分四步:

  1. 预过滤(Prefilter):从相机颜色中提取亮部,同时降到 1/2 分辨率。
  2. 下采样金字塔:反复减半,得到 1/4、1/8 …… 的一串越来越模糊的图。
  3. 上采样合并:从最小的一级开始往回放大,每级把“放大后的低一级”与“本级”按 scatter 混合。不同尺度的模糊叠在一起,形成中心亮、拖尾长的光晕。
  4. 合成:在线性 HDR 空间里把泛光乘以强度和颜色,加回原图。
相机颜色 Down0 ½ Down1 D2 预过滤 13 次采样下采样 Up0 Up1 Up2 帐篷放大+ scatter 混合 合成:color + bloom × 强度 虚线:上采样时,本级的“下采样结果”作为高频部分参与混合
图 26-9Bloom 的 RT 流转:下行(蓝)逐级减半,上行(橙)逐级放大并与同级下采样结果混合,最后加回相机颜色

软阈值(Soft Knee)

最简单的阈值是 c·max(b−T,0)/b(b 为像素亮度,取 RGB 最大值),但它在 b = T 处有一个折角:亮度在阈值附近轻微变化(比如物体移动、高光闪烁)时,泛光会突然出现或消失。软阈值在 [T − K, T + K] 区间内用一段二次曲线把 0 平滑地接到直线上:

s=clamp(b−T+K,0,2K)24K,contribution=max(b−T,s)b

在 b = T + K 处,二次曲线 s 的值与斜率都恰好等于直线 b − T 的值与斜率(都等于 K 与 1),所以拼接处是光滑的。URP 内置 Bloom 用的是同一个公式(参数里的 Threshold Knee)。我们的 Volume 用“相对阈值的比例”来表示膝宽:K = T × softKnee。

Assets/Cookbook/Ch26/MyBloom.shader(节选:prefilter)
half4 FragPrefilter(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float2 t = _BlitTexture_TexelSize.xy;
    // 4 次双线性 = 4×4 纹素的盒式平均,顺便完成 2 倍降采样
    half3 c = (Tap(uv + t * float2(-1, -1)) + Tap(uv + t * float2(1, -1))
             + Tap(uv + t * float2(-1,  1)) + Tap(uv + t * float2(1,  1))) * 0.25;
    c = min(c, 65000.0);                       // 防止 Inf 一路扩散

    // 软阈值(二次曲线过渡的“膝”):亮度 b 在 [T-K, T+K] 之间平滑过渡
    half threshold = _BloomParams.x, knee = _BloomParams.y;
    half b = Max3(c.r, c.g, c.b);
    half soft = clamp(b - threshold + knee, 0.0, 2.0 * knee);
    soft = soft * soft / (4.0 * knee + 1e-4);
    half contribution = max(b - threshold, soft) / max(b, 1e-4);
    return half4(c * contribution, 1.0);
}
vec4 FragPrefilter()
{
    vec2 uv = texcoord;
    vec2 t = _BlitTexture_TexelSize.xy;
    // 4 次双线性 = 4×4 纹素的盒式平均,顺便完成 2 倍降采样
    vec3 c = (Tap(uv + t * vec2(-1, -1)) + Tap(uv + t * vec2(1, -1))
            + Tap(uv + t * vec2(-1,  1)) + Tap(uv + t * vec2(1,  1))) * 0.25;
    c = min(c, 65000.0);                       // 防止 Inf 一路扩散

    // 软阈值(二次曲线过渡的“膝”):亮度 b 在 [T-K, T+K] 之间平滑过渡
    float threshold = _BloomParams.x, knee = _BloomParams.y;
    float b = Max3(c.r, c.g, c.b);
    float soft = clamp(b - threshold + knee, 0.0, 2.0 * knee);
    soft = soft * soft / (4.0 * knee + 1e-4);
    float contribution = max(b - threshold, soft) / max(b, 1e-4);
    return vec4(c * contribution, 1.0);
}

为什么 Bloom 必须放在色调映射之前、在 HDR 颜色上做?泛光模拟的是光的散射,散出去的能量与光源的真实亮度成正比。一盏亮度为 20 的灯和一块亮度为 1.2 的白墙,在色调映射之后都会接近显示器的白色,但前者应该产生大得多的光晕。如果在 LDR(色调映射之后)做 Bloom,这两者已经无法区分,只能对“接近白色的一切”一视同仁地发光,画面会显得发灰、发糊。所以本章的 Bloom 使用 BeforeRenderingPostProcessing 事件,并且金字塔的 RT 沿用相机的 HDR 格式。

预过滤先用 4 次双线性采样做 4×4 盒式平均(同时完成降采样),再 min(c, 65000):半精度浮点的上限约为 65504,场景里偶尔出现的 Inf/NaN(比如除零的高光)如果不在第一步截断,会随着金字塔扩散成一大块黑色或白色。

抗闪烁的 13 次采样下采样

简单的 2×2 平均在降采样时会“跳过”一些像素。一个只有一两个像素大小的极亮高光,随着相机微动,可能这一帧落在采样点上、下一帧没落上,于是泛光一闪一闪。Jimenez 在 2014 年 SIGGRAPH 的《Next Generation Post Processing in Call of Duty: Advanced Warfare》中给出的 13 次采样下采样,用 5 个互相重叠的 2×2 方块加权,覆盖了 6×6 的源纹素,明显减轻了这种闪烁:

Assets/Cookbook/Ch26/MyBloom.shader(节选:down)
// 13 次采样下采样(Jimenez 2014, Next Generation Post Processing in Call of Duty):
// 中心 4 点权重 0.5,四个角上的 2×2 小方块各 0.125,能明显抑制高亮像素的闪烁
half4 FragDown(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float2 t = _BlitTexture_TexelSize.xy;
    half3 A = Tap(uv + t * float2(-2, -2)), B = Tap(uv + t * float2(0, -2)), C = Tap(uv + t * float2(2, -2));
    half3 D = Tap(uv + t * float2(-1, -1)), E = Tap(uv + t * float2(1, -1));
    half3 F = Tap(uv + t * float2(-2,  0)), G = Tap(uv),                      H = Tap(uv + t * float2(2,  0));
    half3 I = Tap(uv + t * float2(-1,  1)), J = Tap(uv + t * float2(1,  1));
    half3 K = Tap(uv + t * float2(-2,  2)), L = Tap(uv + t * float2(0,  2)), M = Tap(uv + t * float2(2,  2));
    half3 c = (D + E + I + J) * (0.5 / 4.0);
    c += (A + B + G + F) * (0.125 / 4.0);
    c += (B + C + H + G) * (0.125 / 4.0);
    c += (F + G + L + K) * (0.125 / 4.0);
    c += (G + H + M + L) * (0.125 / 4.0);
    return half4(c, 1.0);
}
// 13 次采样下采样(Jimenez 2014, Next Generation Post Processing in Call of Duty):
// 中心 4 点权重 0.5,四个角上的 2×2 小方块各 0.125,能明显抑制高亮像素的闪烁
vec4 FragDown()
{
    vec2 uv = texcoord;
    vec2 t = _BlitTexture_TexelSize.xy;
    vec3 A = Tap(uv + t * vec2(-2, -2)), B = Tap(uv + t * vec2(0, -2)), C = Tap(uv + t * vec2(2, -2));
    vec3 D = Tap(uv + t * vec2(-1, -1)), E = Tap(uv + t * vec2(1, -1));
    vec3 F = Tap(uv + t * vec2(-2,  0)), G = Tap(uv),                    H = Tap(uv + t * vec2(2,  0));
    vec3 I = Tap(uv + t * vec2(-1,  1)), J = Tap(uv + t * vec2(1,  1));
    vec3 K = Tap(uv + t * vec2(-2,  2)), L = Tap(uv + t * vec2(0,  2)), M = Tap(uv + t * vec2(2,  2));
    vec3 c = (D + E + I + J) * (0.5 / 4.0);
    c += (A + B + G + F) * (0.125 / 4.0);
    c += (B + C + H + G) * (0.125 / 4.0);
    c += (F + G + L + K) * (0.125 / 4.0);
    c += (G + H + M + L) * (0.125 / 4.0);
    return vec4(c, 1.0);
}

上采样与 scatter

上采样时,低一级用 3×3 帐篷滤波放大(权重 1-2-1,比直接双线性放大更平滑,没有块状感),再与本级的下采样结果按 scatter 插值。scatter 越大,低频(大尺度)成分占比越高,光晕越大越散;越小,光晕越集中在光源附近:

Assets/Cookbook/Ch26/MyBloom.shader(节选:up)
// 上采样:低一层用 3×3 帐篷滤波放大,再与本层按 scatter 插值
half4 FragUp(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float2 t = _BlitTexture_TexelSize.xy;
    #define LOW(o) SAMPLE_TEXTURE2D_X(_BloomLowMip, sampler_LinearClamp, uv + t * (o)).rgb
    half3 low = LOW(float2(-1, -1)) + LOW(float2(1, -1)) + LOW(float2(-1, 1)) + LOW(float2(1, 1));
    low += (LOW(float2(0, -1)) + LOW(float2(-1, 0)) + LOW(float2(1, 0)) + LOW(float2(0, 1))) * 2.0;
    low += LOW(float2(0, 0)) * 4.0;
    low *= 1.0 / 16.0;
    #undef LOW
    half3 high = Tap(uv);
    return half4(lerp(high, low, _BloomParams.z), 1.0);
}
// 上采样:低一层用 3×3 帐篷滤波放大,再与本层按 scatter 插值
vec4 FragUp()
{
    vec2 uv = texcoord;
    vec2 t = _BlitTexture_TexelSize.xy;
    #define LOW(o) texture(_BloomLowMip, uv + t * (o)).rgb
    vec3 low = LOW(vec2(-1, -1)) + LOW(vec2(1, -1)) + LOW(vec2(-1, 1)) + LOW(vec2(1, 1));
    low += (LOW(vec2(0, -1)) + LOW(vec2(-1, 0)) + LOW(vec2(1, 0)) + LOW(vec2(0, 1))) * 2.0;
    low += LOW(vec2(0, 0)) * 4.0;
    low *= 1.0 / 16.0;
    #undef LOW
    vec3 high = Tap(uv);
    return vec4(mix(high, low, _BloomParams.z), 1.0);
}

注意 _BloomLowMip 是用 cmd.SetGlobalTexture 逐级设置的全局纹理——又一次“逐次参数”的应用。C# 部分负责分配金字塔与组织调用顺序:

Assets/Cookbook/Ch26/MyBloom.cs(节选:alloc)
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
    // 金字塔从半分辨率开始,一直缩到短边只剩几个像素或达到最大层数
    var desc = ColorDesc(ref renderingData, 2);
    int minSide = Mathf.Min(desc.width, desc.height);
    m_Levels = Mathf.Clamp(Mathf.FloorToInt(Mathf.Log(minSide, 2f)) - 1, 1, m_MaxIterations);
    for (int i = 0; i < m_Levels; i++)
    {
        Alloc(ref m_Down[i], desc, "_Ch26BloomDown" + i);
        if (i < m_Levels - 1) Alloc(ref m_Up[i], desc, "_Ch26BloomUp" + i);
        desc.width = Mathf.Max(1, desc.width / 2);
        desc.height = Mathf.Max(1, desc.height / 2);
    }
    Alloc(ref m_Temp, ColorDesc(ref renderingData), "_Ch26BloomTemp");
}
Assets/Cookbook/Ch26/MyBloom.cs(节选:render)
protected override void Render(CommandBuffer cmd, ref RenderingData renderingData)
{
    // 1) 亮部提取 + 第一次降采样(全分辨率 → 1/2)
    Blitter.BlitCameraTexture(cmd, cameraColor, m_Down[0], material, PassPrefilter);

    // 2) 下采样金字塔:1/2 → 1/4 → 1/8 …(13 次采样的抗闪烁下采样核)
    for (int i = 1; i < m_Levels; i++)
        Blitter.BlitCameraTexture(cmd, m_Down[i - 1], m_Down[i], material, PassDown);

    // 3) 上采样:从最小层开始,每级 = lerp(本级下采样结果, 上一级的放大结果, scatter)
    for (int i = m_Levels - 2; i >= 0; i--)
    {
        RTHandle low = (i == m_Levels - 2) ? m_Down[i + 1] : m_Up[i + 1];
        cmd.SetGlobalTexture(s_LowMip, low);      // 每次迭代不同 → 用全局纹理逐次记录
        Blitter.BlitCameraTexture(cmd, m_Down[i], m_Up[i], material, PassUp);
    }

    // 4) 合成:相机颜色 + 泛光 × 强度 × 颜色
    RTHandle bloom = m_Debug == BloomDebugView.Prefilter ? m_Down[0]
                   : (m_Levels > 1 ? m_Up[0] : m_Down[0]);
    cmd.SetGlobalTexture(s_BloomTex, bloom);
    Blitter.BlitCameraTexture(cmd, cameraColor, m_Temp, material, PassComposite);
    Blitter.BlitCameraTexture(cmd, m_Temp, cameraColor);
}
Assets/Cookbook/Ch26/MyBloom.shader(节选:composite)
half4 FragComposite(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);
    half3 bloom = SAMPLE_TEXTURE2D_X(_BloomTex, sampler_LinearClamp, uv).rgb * _BloomTint.rgb * _BloomParams.w;
    if (_BloomDebugView > 0.5) return half4(bloom, 1.0);   // 调试:只看泛光
    color.rgb += bloom;                                     // 线性 HDR 空间里直接相加
    return color;
}
vec4 FragComposite()
{
    vec2 uv = texcoord;
    vec4 color = texture(_BlitTexture, uv);
    vec3 bloom = texture(_BloomTex, uv).rgb * _BloomTint.rgb * _BloomParams.w;
    if (_BloomDebugView > 0.5) return vec4(bloom, 1.0);   // 调试:只看泛光
    color.rgb += bloom;                                    // 线性 HDR 空间里直接相加
    return color;
}
Assets/Cookbook/Ch26/Editor/Ch26Shots.cs(节选:shot-bloom)
[BookShot("ch26-bloom", W = 480, H = 320, Panels = 4, Hdr = true)]   // Hdr:浮点相机目标,亮度可远大于 1
static void BloomShot(Shot s)
{
    Stage(s, glow: 10f);                             // 自发光强度 10:远超阈值
    s.Background(new Color(0.02f, 0.025f, 0.04f));   // 暗背景更能看出泛光
    s.SunDir(50, -30, 0.8f);
    var prof = s.Post();
    // 四格都加同样的 ACES 色调映射(URP 自带),只有 Bloom 不同
    prof.Add<Tonemapping>().mode.Override(TonemappingMode.ACES);
    if (s.Panel == 0) return;

    s.Feature<MyBloomFeature>();
    var v = prof.Add<MyBloomVolume>();
    v.threshold.Override(1f);
    v.softKnee.Override(0.5f);
    v.intensity.Override(1f);
    v.scatter.Override(0.7f);
    v.debugView.Override(s.Panel == 1 ? BloomDebugView.Prefilter
                       : s.Panel == 2 ? BloomDebugView.BloomOnly : BloomDebugView.None);
}
图 26-10从左到右:原图;预过滤结果(只剩亮部);上采样合并后的泛光;最终合成(threshold = 1、softKnee = 0.5、intensity = 1、scatter = 0.7),霓虹灯条和发光球周围出现柔和的彩色光晕

这组图使用浮点(HDR)相机缓冲,霓虹灯条与发光球的自发光强度为 10,远高于阈值 1;四格都加了 URP 自带的 ACES 色调映射。可以看到预过滤只留下了真正“过亮”的部分:被阳光照亮的橙色纽结、蓝色方块亮度大多在 1 以下,只在软膝区间里贡献了很弱的一点。光源越亮,散射出去的能量越多,光晕也越大——这正是在 HDR 上做 Bloom 的意义。

与 URP 内置 Bloom 的对比。为了对齐参数要注意:URP 的 threshold 是按 Gamma 空间理解的,内部做了 Mathf.GammaToLinearSpace;它的 scatter 也被重映射到 0.05~0.95(见 PostProcessPass.cs 中设置 Bloom 参数的代码)。阈值取 1 时两种理解恰好相同;URP 一侧的 scatter 取 0.72(重映射后约为 0.70),与我们的 0.7 对齐,阈值、强度都为 1:

图 26-11左:本章的 MyBloom;右:URP 内置 Bloom。两者结构相同,光晕形状接近;细微差别来自预过滤和上采样核的选择

扩展方向:URP 内置 Bloom 还有 Dirt(镜头污渍贴图,用泛光强度调制一张贴图后叠加)、高质量模式(预过滤用 13 次采样)和 RGBM 编码(不支持浮点 RT 的平台上用 8 位纹理存 HDR)。理解了金字塔结构后,这些都是在某一步上的增量修改。

案例 26.5径向模糊入门单独打开 ↗

目标:冲刺、爆炸冲击时的“速度感”:画面沿着指向某个中心的方向被拉出拖影,中心附近保持清晰。

原理:对每个像素,沿“像素 → 中心”方向在一段距离内均匀采样若干次求平均。拖影长度正比于像素到中心的距离,所以边缘模糊、中心清晰,这正是镜头快速推进时的观感。为了避免采样次数少时出现分层的“重影”,每个像素的起始位置加一个 0~1 的抖动,用的是 Interleaved Gradient Noise(Jimenez 2014),把规则的分层变成细密、均匀的噪点:

Assets/Cookbook/Ch26/RadialBlur.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float2 center = _RadialParams.xy;
    float2 dir = uv - center;                       // 指向外侧,长度 = 到中心的距离

    // 中心一圈保持清晰:按宽高比修正成正圆后再算距离
    float aspect = _ScreenParams.x / _ScreenParams.y;
    float dist = length(dir * float2(aspect, 1.0));
    float amount = _RadialParams.z * smoothstep(_ClearRadius, _ClearRadius + 0.25, dist);

    int count = (int)_RadialParams.w;
    // 每个像素的起点加一点抖动,把“分层的重影”变成细噪点
    float jitter = InterleavedGradientNoise(uv * _ScreenParams.xy);
    half3 sum = 0;
    [loop] for (int i = 0; i < count; i++)
    {
        float t = (i + jitter) / count;             // 0..1
        float2 s = uv - dir * amount * 0.3 * t;     // 向中心方向收缩
        sum += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, s).rgb;
    }
    return half4(sum / count, 1.0);
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec2 center = _RadialParams.xy;
    vec2 dir = uv - center;                         // 指向外侧,长度 = 到中心的距离

    // 中心一圈保持清晰:按宽高比修正成正圆后再算距离
    float aspect = _ScreenParams.x / _ScreenParams.y;
    float dist = length(dir * vec2(aspect, 1.0));
    float amount = _RadialParams.z * smoothstep(_ClearRadius, _ClearRadius + 0.25, dist);

    int count = int(_RadialParams.w);
    // 每个像素的起点加一点抖动,把“分层的重影”变成细噪点
    float jitter = InterleavedGradientNoise(uv * _ScreenParams.xy);
    vec3 sum = vec3(0.0);
    for (int i = 0; i < count; i++)
    {
        float t = (float(i) + jitter) / float(count);   // 0..1
        vec2 s = uv - dir * amount * 0.3 * t;           // 向中心方向收缩
        sum += texture(_BlitTexture, s).rgb;
    }
    return vec4(sum / float(count), 1.0);
}

C# 只有参数传递(使用 SimplePostPass):

Assets/Cookbook/Ch26/RadialBlur.cs(节选:pass)
class RadialBlurPass : SimplePostPass<RadialBlurVolume>
{
    static readonly int s_Params = Shader.PropertyToID("_RadialParams");   // xy 中心, z 强度, w 采样数
    static readonly int s_ClearRadius = Shader.PropertyToID("_ClearRadius");
    public RadialBlurPass(Material m) : base(m, "Ch26 Radial Blur") { }

    protected override void Apply(RadialBlurVolume v, ref RenderingData renderingData)
    {
        var c = v.center.value;
        material.SetVector(s_Params, new Vector4(c.x, c.y, v.strength.value, v.samples.value));
        material.SetFloat(s_ClearRadius, v.clearRadius.value);
    }
}
图 26-12左:原图;右:strength = 0.6、16 次采样、中心 (0.5, 0.55)、clearRadius = 0.1

运行时把 strength 交给 Volume 权重动画(参见案例 26.10 的写法),就能做出冲刺开始时猛地一拉、随后迅速恢复的效果。中心点也可以每帧设为某个世界坐标投影到屏幕的位置(Camera.WorldToViewportPoint),做成“朝着爆炸点”的冲击模糊。

案例 26.6色差与暗角:两个效果的叠加入门单独打开 ↗

目标:实现两种最常见的“镜头感”效果,并通过它们演示多个自定义后处理的组合。

色差(Chromatic Aberration)来自真实镜头对不同波长折射率不同:红光和蓝光在画面边缘的放大倍率略有差异,于是边缘出现彩色镶边。实现上,沿径向对不同颜色通道在略有不同的位置采样。只取 R、G、B 三次时,强度大了会出现三道分离的重影;这里在径向上取多个样本,每个样本按“光谱位置”给 RGB 三个权重(红→绿→蓝的三段帐篷函数),最后按通道归一化,色散就变成连续的彩虹过渡。偏移量与半径的平方成正比,中心几乎没有色差:

Assets/Cookbook/Ch26/MyChromaticAberration.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    float2 d = uv - 0.5;
    // 偏移随半径平方增长:中心几乎无色差,边缘最强(和真实镜头的横向色差一致)
    float2 offset = d * dot(d, d) * _CAParams.x * 0.15;

    int n = (int)_CAParams.y;
    half3 sum = 0, wsum = 0;
    [loop] for (int i = 0; i < n; i++)
    {
        float t = i / (float)(n - 1);               // 0 = 红(向外)… 1 = 蓝(向内)
        half3 w = SpectrumWeight(t);
        float2 suv = uv + offset * (0.5 - t) * 2.0;
        sum += SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, suv).rgb * w;
        wsum += w;
    }
    return half4(sum / max(wsum, 1e-4), 1.0);
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec2 d = uv - 0.5;
    // 偏移随半径平方增长:中心几乎无色差,边缘最强(和真实镜头的横向色差一致)
    vec2 offset = d * dot(d, d) * _CAParams.x * 0.15;

    int n = int(_CAParams.y);
    vec3 sum = vec3(0.0), wsum = vec3(0.0);
    for (int i = 0; i < n; i++)
    {
        float t = float(i) / float(n - 1);         // 0 = 红(向外)… 1 = 蓝(向内)
        vec3 w = SpectrumWeight(t);
        vec2 suv = uv + offset * (0.5 - t) * 2.0;
        sum += texture(_BlitTexture, suv).rgb * w;
        wsum += w;
    }
    return vec4(sum / max(wsum, vec3(1e-4)), 1.0);
}

暗角(Vignette):画面向边缘逐渐变暗。以 center 为圆心计算半径,smoothstep 控制从哪里开始变暗、过渡带多宽;rounded 打开时按屏幕宽高比修正,使暗角是正圆而不是随屏幕拉伸的椭圆。颜色可以不是黑色——深红的暗角常用于表现紧张或受伤:

Assets/Cookbook/Ch26/MyVignette.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);

    float2 d = (uv - _VignetteParams.xy) * 2.0;                 // 屏幕边缘中点处 |d| = 1
    d.x *= lerp(1.0, _ScreenParams.x / _ScreenParams.y, _VignetteRounded);
    float r = length(d);

    // 强度决定暗角从哪里开始,柔和度决定过渡带宽度
    float inner = 1.3 - _VignetteParams.z;
    half mask = smoothstep(inner, inner + _VignetteParams.w, r);
    color.rgb = lerp(color.rgb, _VignetteColor.rgb, mask);
    return color;
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec4 color = texture(_BlitTexture, uv);

    vec2 d = (uv - _VignetteParams.xy) * 2.0;                 // 屏幕边缘中点处 |d| = 1
    d.x *= mix(1.0, _ScreenParams.x / _ScreenParams.y, _VignetteRounded);
    float r = length(d);

    // 强度决定暗角从哪里开始,柔和度决定过渡带宽度
    float inner = 1.3 - _VignetteParams.z;
    float mask = smoothstep(inner, inner + _VignetteParams.w, r);
    color.rgb = mix(color.rgb, _VignetteColor.rgb, mask);
    return color;
}
Assets/Cookbook/Ch26/LensEffects.cs(节选:volumes)
[Serializable, VolumeComponentMenuForRenderPipeline("Cookbook/My Chromatic Aberration", typeof(UniversalRenderPipeline))]
public sealed class MyChromaticAberration : VolumeComponent, IPostProcessComponent
{
    public ClampedFloatParameter intensity = new ClampedFloatParameter(0f, 0f, 1f);
    [Tooltip("沿径向分几段采样;3 = 只取 RGB 三次,越多色散越平滑")]
    public ClampedIntParameter samples = new ClampedIntParameter(6, 3, 16);
    public bool IsActive() => intensity.value > 0f;
    public bool IsTileCompatible() => false;
}

[Serializable, VolumeComponentMenuForRenderPipeline("Cookbook/My Vignette", typeof(UniversalRenderPipeline))]
public sealed class MyVignette : VolumeComponent, IPostProcessComponent
{
    public ColorParameter color = new ColorParameter(Color.black, false, false, true);
    public Vector2Parameter center = new Vector2Parameter(new Vector2(0.5f, 0.5f));
    public ClampedFloatParameter intensity = new ClampedFloatParameter(0f, 0f, 1f);
    public ClampedFloatParameter smoothness = new ClampedFloatParameter(0.5f, 0.05f, 1f);
    [Tooltip("勾选后按屏幕宽高比修正成正圆")]
    public BoolParameter rounded = new BoolParameter(false);
    public bool IsActive() => intensity.value > 0f;
    public bool IsTileCompatible() => false;
}
Assets/Cookbook/Ch26/Editor/Ch26Shots.cs(节选:shot-lens)
[BookShot("ch26-lens", W = 640, H = 400, Panels = 3)]
static void Lens(Shot s)
{
    Stage(s);
    // 两个 Feature 都在 BeforeRenderingPostProcessing:入队顺序 = Renderer 列表顺序
    s.Feature<MyChromaticAberrationFeature>();
    s.Feature<MyVignetteFeature>();
    var prof = s.Post();
    var ca = prof.Add<MyChromaticAberration>();
    var vg = prof.Add<MyVignette>();
    if (s.Panel != 1) ca.intensity.Override(1f);
    if (s.Panel != 0) { vg.intensity.Override(0.6f); vg.smoothness.Override(0.7f); vg.rounded.Override(true); }
    if (s.Panel == 2) vg.color.Override(new Color(0.12f, 0.02f, 0.05f));
}
图 26-13左:只开色差(intensity = 1),画面四角的发光球和棋盘边缘出现彩色镶边;中:只开暗角(intensity 0.6、smoothness 0.7、rounded);右:两者同时开,暗角颜色改为深红

两个 Feature 都在 BeforeRenderingPostProcessing,执行顺序由 Renderer 上的列表顺序决定:先色差后暗角。顺序有时会影响结果:如果先暗角后色差,暗角的边界本身也会被色散成彩色。实际项目中,这两个效果更适合合并进一个 Pass(都是逐像素、都只读一次颜色),这正是 URP 把它们放进 Uber Post 的原因。

案例 26.7像素化与调色板抖动入门单独打开 ↗

目标:复古像素风:画面由大块的“像素”组成,颜色只有少数几级,并用有序抖动(Ordered Dithering)模拟中间色。

原理:把 UV 吸附到“大像素”格子的中心,用点采样取色——同一格里所有屏幕像素读到同一个颜色。色阶量化在 sRGB 空间进行,因为人眼对暗部更敏感,在线性空间均匀分级会让暗部台阶过粗。抖动用 4×4 Bayer 矩阵:给每个大像素一个 [−0.5, 0.5) 的阈值偏移再四舍五入,相邻格子在两个色阶之间交替,远看就是中间色:

Assets/Cookbook/Ch26/Pixelate.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 screen = _ScreenParams.xy;
    float size = _PixelateParams.x;

    // 1) 当前像素属于哪个“大像素”,取它中心的颜色(点采样,避免边缘被双线性混色)
    float2 block = floor(input.texcoord * screen / size);
    float2 uv = (block + 0.5) * size / screen;
    half3 c = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv).rgb;

    // 2) 色阶量化:在 sRGB 空间均匀分级(线性空间分级会让暗部台阶过粗)
    float levels = _PixelateParams.y;
    if (levels > 0.5)
    {
        half3 s = LinearToSRGB(saturate(c));
        float threshold = (Bayer4((uint2)block) - 0.5) * _PixelateParams.z;   // 以大像素为单位抖动
        s = floor(s * (levels - 1.0) + 0.5 + threshold) / (levels - 1.0);
        c = SRGBToLinear(saturate(s));
    }
    return half4(c, 1.0);
}
vec4 Frag()
{
    vec2 screen = _ScreenParams.xy;
    float size = _PixelateParams.x;

    // 1) 当前像素属于哪个“大像素”,取它中心的颜色(点采样,避免边缘被双线性混色)
    vec2 block = floor(texcoord * screen / size);
    vec2 uv = (block + 0.5) * size / screen;
    // GLSL 不能按调用指定 sampler_PointClamp:用 texelFetch 按整数纹素坐标取
    ivec2 texSize = textureSize(_BlitTexture, 0);
    vec3 c = texelFetch(_BlitTexture, clamp(ivec2(uv * vec2(texSize)), ivec2(0), texSize - 1), 0).rgb;

    // 2) 色阶量化:在 sRGB 空间均匀分级(线性空间分级会让暗部台阶过粗)
    float levels = _PixelateParams.y;
    if (levels > 0.5)
    {
        vec3 s = LinearToSRGB(saturate(c));
        float threshold = (Bayer4(uvec2(block)) - 0.5) * _PixelateParams.z;   // 以大像素为单位抖动
        s = floor(s * (levels - 1.0) + 0.5 + threshold) / (levels - 1.0);
        c = SRGBToLinear(saturate(s));
    }
    return vec4(c, 1.0);
}
图 26-14左:5 像素一格,不量化;中:每通道 6 级、无抖动,天空与地面出现明显的色带;右:每通道 6 级、Bayer 抖动,色带被打散成规则的网点图案
提示抖动的坐标用的是“大像素”坐标 block,而不是屏幕像素坐标——否则一个大像素内部会出现细碎的网点,破坏像素风。另一个常见需求是“像素不随相机平移而游动”:屏幕空间像素化在相机移动时,格子相对于场景在滑动,会显得闪烁。真正的像素风游戏通常是让相机以低分辨率渲染到 RT、并把相机位置吸附到像素网格上,再整数倍放大显示,那属于相机层面的方案。

案例 26.8CRT 显示器进阶单独打开 ↗

目标:模拟老式显像管显示器:屏幕鼓起的弯曲、扫描线、荧光粉 RGB 栅格、四角暗、电子枪轻微错位、亮度闪烁。

原理:所有效果按“先几何、再采样、后逐像素调制”的顺序放在一个 Pass 里:

  1. 弯曲:把 UV 变到 [−1, 1],按到中心距离的平方向外推:p′=p(1+k|p|2/2),等价于一个简单的桶形畸变的反向映射。推到屏幕外的部分用圆角矩形 SDF(第 4 章)画成黑色边框。
  2. 错位采样:R、B 两个通道左右错开 1 个像素采样。
  3. 扫描线:用弯曲后的 v 坐标乘以行数做余弦波,扫描线跟着屏幕一起弯。
  4. 荧光粉栅格:按屏幕像素的 x 坐标每 3 列一组,分别偏红、偏绿、偏蓝,再整体乘 1.6 补偿亮度损失。
  5. 闪烁:亮度随 _Time 小幅波动;截图时 _Time 接近 0,所以图中看不到。
Assets/Cookbook/Ch26/Crt.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv0 = input.texcoord;

    // 1) 弯曲:把 [-1,1] 坐标按到中心距离的平方向外推,模拟鼓起的玻璃屏
    float2 p = uv0 * 2.0 - 1.0;
    p *= 1.0 + _CrtParams0.y * dot(p, p) * 0.5;
    float2 uv = p * 0.5 + 0.5;

    // 屏幕外(被弯曲推出去的部分)是黑色边框;用 SDF 圆角矩形做柔和的边
    float2 q = abs(p) - (1.0 - 0.06);
    float box = length(max(q, 0.0)) - 0.06;
    half inside = 1.0 - smoothstep(-0.005, 0.005, box);

    // 2) 采样:三个通道错开约 1 像素,像电子枪没对准
    float2 px = float2(1.0 / _ScreenParams.x, 0);
    half3 c;
    c.r = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + px).r;
    c.g = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv).g;
    c.b = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - px).b;

    // 3) 扫描线:每行中间亮、行间暗;用弯曲后的 uv,扫描线也跟着弯
    float row = uv.y * _CrtParams0.z;
    half scan = 0.5 + 0.5 * cos(row * TWO_PI);
    c *= lerp(1.0, 0.35 + 0.65 * scan, _CrtParams0.w);

    // 4) 荧光粉栅格:屏幕像素 x 坐标每 3 列一组,分别偏 R、G、B
    int col = (int)fmod(input.texcoord.x * _ScreenParams.x, 3.0);
    half3 mask = col == 0 ? half3(1, 0.4, 0.4) : (col == 1 ? half3(0.4, 1, 0.4) : half3(0.4, 0.4, 1));
    c *= lerp(1.0, mask * 1.6, _CrtParams1.x);

    // 5) 边缘暗角 + 闪烁(_Time 驱动,截图时 _Time≈0)
    half vignette = saturate(1.0 - dot(p, p) * 0.25);
    half flick = 1.0 - _CrtParams1.y * (0.5 + 0.5 * sin(_Time.y * 110.0));
    c *= vignette * flick * inside;

    // 6) 总强度:和原图插值,方便做“开机/切换”过渡
    half3 original = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv0).rgb;
    return half4(lerp(original, c, _CrtParams0.x), 1.0);
}
vec4 Frag()
{
    vec2 uv0 = texcoord;

    // 1) 弯曲:把 [-1,1] 坐标按到中心距离的平方向外推,模拟鼓起的玻璃屏
    vec2 p = uv0 * 2.0 - 1.0;
    p *= 1.0 + _CrtParams0.y * dot(p, p) * 0.5;
    vec2 uv = p * 0.5 + 0.5;

    // 屏幕外(被弯曲推出去的部分)是黑色边框;用 SDF 圆角矩形做柔和的边
    vec2 q = abs(p) - (1.0 - 0.06);
    float box = length(max(q, 0.0)) - 0.06;
    float inside = 1.0 - smoothstep(-0.005, 0.005, box);

    // 2) 采样:三个通道错开约 1 像素,像电子枪没对准
    vec2 px = vec2(1.0 / _ScreenParams.x, 0.0);
    vec3 c;
    c.r = texture(_BlitTexture, uv + px).r;
    c.g = texture(_BlitTexture, uv).g;
    c.b = texture(_BlitTexture, uv - px).b;

    // 3) 扫描线:每行中间亮、行间暗;用弯曲后的 uv,扫描线也跟着弯
    float row = uv.y * _CrtParams0.z;
    float scan = 0.5 + 0.5 * cos(row * TWO_PI);
    c *= mix(1.0, 0.35 + 0.65 * scan, _CrtParams0.w);

    // 4) 荧光粉栅格:屏幕像素 x 坐标每 3 列一组,分别偏 R、G、B
    int col = int(mod(texcoord.x * _ScreenParams.x, 3.0));
    vec3 mask = col == 0 ? vec3(1, 0.4, 0.4) : (col == 1 ? vec3(0.4, 1, 0.4) : vec3(0.4, 0.4, 1));
    c *= mix(vec3(1.0), mask * 1.6, _CrtParams1.x);

    // 5) 边缘暗角 + 闪烁(_Time 驱动,截图时 _Time≈0)
    float vignette = saturate(1.0 - dot(p, p) * 0.25);
    float flick = 1.0 - _CrtParams1.y * (0.5 + 0.5 * sin(_Time.y * 110.0));
    c *= vignette * flick * inside;

    // 6) 总强度:和原图插值,方便做“开机/切换”过渡
    vec3 original = texture(_BlitTexture, uv0).rgb;
    return vec4(mix(original, c, _CrtParams0.x), 1.0);
}
图 26-15左:原图;右:curvature = 0.18、160 行扫描线、栅格强度 0.35。注意扫描线和画面内容一起被弯曲

扫描线和栅格都是“逐屏幕像素”的高频图案,分辨率变化时观感差别很大:在 4K 屏上 3 像素一组的栅格几乎看不见,在 720p 上又可能太粗。实际项目里可以把栅格周期和扫描线行数与屏幕高度挂钩(例如行数 = 屏幕高度 / 3),或者提供画质档位。这个效果适合放在 AfterRenderingPostProcessing:它模拟的是“显示设备”,应该作用在最终颜色上。

案例 26.9基于深度与法线的 Sobel 描边高阶单独打开 ↗

目标:全屏描边:所有物体的轮廓、折痕都画出线条,可以叠在原图上,也可以只显示线条做“线稿”效果。和第 14 章的法线外扩描边、第 25 章的遮罩描边相比,它不需要修改任何物体,对场景一视同仁。

原理:边就是“图像中某个量突然变化的地方”。我们用两个量:深度(物体与背景、前后物体之间的轮廓)和法线(同一物体上的折痕,比如立方体的棱)。用 Sobel 算子求它们在屏幕上的梯度:

Gx=[−101−202−101],Gy=GxT,|∇|=Gx2+Gy2

Sobel 比更简单的 Roberts 交叉算子(2×2,只取对角差)多了中间一行/列的权重 2,相当于先在垂直方向做一次平滑再求导,对噪声更不敏感、线条更连续。对法线这种三维量,分别对 xyz 三个分量求梯度再合并长度。

声明输入:效果需要 _CameraDepthTexture 和 _CameraNormalsTexture。在框架里只需重写 RequiredInputs:

Assets/Cookbook/Ch26/EdgeDetect.cs(节选:pass)
class Pass : SimplePostPass<EdgeDetectVolume>
{
    static readonly int s_Params = Shader.PropertyToID("_EdgeParams");     // x 强度, y 粗细, z 深度阈值, w 法线阈值
    static readonly int s_EdgeColor = Shader.PropertyToID("_EdgeColor");
    static readonly int s_BgColor = Shader.PropertyToID("_EdgeBgColor");   // a = edgesOnly

    public Pass(Material m) : base(m, "Ch26 Edge Detect") { }

    // 告诉 URP:本 Pass 需要 _CameraDepthTexture 和 _CameraNormalsTexture。
    // Normal 会让 URP 额外跑一遍 DepthNormals 预渲染(前向路径下),这是这个效果的主要开销。
    public override ScriptableRenderPassInput RequiredInputs =>
        ScriptableRenderPassInput.Depth | ScriptableRenderPassInput.Normal;

    protected override void Apply(EdgeDetectVolume v, ref RenderingData rd)
    {
        material.SetVector(s_Params, new Vector4(v.intensity.value, v.thickness.value, v.depthThreshold.value, v.normalThreshold.value));
        material.SetColor(s_EdgeColor, v.edgeColor.value);
        var bg = v.backgroundColor.value; bg.a = v.edgesOnly.value;
        material.SetColor(s_BgColor, bg);
    }
}

ScriptableRenderPassInput.Normal 会让 URP 在前向渲染路径下插入一次 DepthNormals 预渲染:所有不透明物体用各自 Shader 的 DepthNormals Pass 再画一遍,输出深度和世界空间法线(第 6 章讲过这个 Pass)。这是本效果最主要的开销——物体多的场景里相当于多画一遍几何体。没有 DepthNormals Pass 的自定义 Shader 不会出现在法线图里,描边也就缺了它的折痕线,这是使用这个效果前要检查的。

两个容易被忽略的归一化

Assets/Cookbook/Ch26/EdgeDetect.shader(节选:sobel)
// Sobel 核:Gx = [-1 0 1; -2 0 2; -1 0 1],Gy 为其转置
static const float2 kOffsets[8] = {
    float2(-1, -1), float2(0, -1), float2(1, -1),
    float2(-1,  0),                float2(1,  0),
    float2(-1,  1), float2(0,  1), float2(1,  1) };
static const float kGx[8] = { -1, 0, 1, -2, 2, -1, 0, 1 };
static const float kGy[8] = { -1, -2, -1, 0, 0, 1, 2, 1 };

half EdgeAt(float2 uv)
{
    float2 texel = _EdgeParams.y / _ScreenParams.xy;
    float  rawCenter = SampleSceneDepth(uv);
    float  centerDepth = LinearEyeDepth(rawCenter, _ZBufferParams);

    float2 gDepth = 0;
    float3 gNx = 0, gNy = 0;
    [unroll] for (int i = 0; i < 8; i++)
    {
        float2 suv = uv + kOffsets[i] * texel;
        float  d = LinearEyeDepth(SampleSceneDepth(suv), _ZBufferParams);
        float3 n = SampleSceneNormals(suv);
        gDepth += float2(kGx[i], kGy[i]) * d;
        gNx += kGx[i] * n;
        gNy += kGy[i] * n;
    }

    // 深度梯度除以中心深度:远处物体深度差天然更大,不归一化的话远景会全是线
    float depthEdge = length(gDepth) / max(centerDepth, 1e-3);

    // 掠射角修正:斜着看一个平面(例如远处地面)时,相邻像素深度变化很快,
    // 相对梯度约与 1/(N·V) 成正比。乘上 N·V 把这部分“假边”压下去
    float3 normalC = SampleSceneNormals(uv);
    float3 positionWS = ComputeWorldSpacePosition(uv, rawCenter, UNITY_MATRIX_I_VP);
    float3 viewDirWS = normalize(GetCameraPositionWS() - positionWS);
    float ndv = saturate(dot(normalC, viewDirWS));
    depthEdge *= ndv;

    float normalEdge = sqrt(dot(gNx, gNx) + dot(gNy, gNy));
    half e = max(step(_EdgeParams.z, depthEdge), step(_EdgeParams.w, normalEdge));
    return e;
}
// Sobel 核:Gx = [-1 0 1; -2 0 2; -1 0 1],Gy 为其转置
const vec2 kOffsets[8] = vec2[8](
    vec2(-1, -1), vec2(0, -1), vec2(1, -1),
    vec2(-1,  0),              vec2(1,  0),
    vec2(-1,  1), vec2(0,  1), vec2(1,  1));
const float kGx[8] = float[8](-1.0, 0.0, 1.0, -2.0, 2.0, -1.0, 0.0, 1.0);
const float kGy[8] = float[8](-1.0, -2.0, -1.0, 0.0, 0.0, 1.0, 2.0, 1.0);

float EdgeAt(vec2 uv)
{
    vec2 texel = _EdgeParams.y / _ScreenParams.xy;
    float rawCenter = SampleSceneDepth(uv);
    float centerDepth = LinearEyeDepth(rawCenter, _ZBufferParams);

    vec2 gDepth = vec2(0.0);
    vec3 gNx = vec3(0.0), gNy = vec3(0.0);
    for (int i = 0; i < 8; i++)
    {
        vec2 suv = uv + kOffsets[i] * texel;
        float d = LinearEyeDepth(SampleSceneDepth(suv), _ZBufferParams);
        vec3 n = SampleSceneNormalsRaw(suv);
        gDepth += vec2(kGx[i], kGy[i]) * d;
        gNx += kGx[i] * n;
        gNy += kGy[i] * n;
    }

    // 深度梯度除以中心深度:远处物体深度差天然更大,不归一化的话远景会全是线
    float depthEdge = length(gDepth) / max(centerDepth, 1e-3);

    // 掠射角修正:斜着看一个平面(例如远处地面)时,相邻像素深度变化很快,
    // 相对梯度约与 1/(N·V) 成正比。乘上 N·V 把这部分“假边”压下去
    vec3 normalC = SampleSceneNormalsRaw(uv);
    vec3 positionWS = ComputeWorldSpacePosition(uv, rawCenter, UNITY_MATRIX_I_VP);
    vec3 viewDirWS = normalize(GetCameraPositionWS() - positionWS);
    float ndv = saturate(dot(normalC, viewDirWS));
    depthEdge *= ndv;

    float normalEdge = sqrt(dot(gNx, gNx) + dot(gNy, gNy));
    float e = max(step(_EdgeParams.z, depthEdge), step(_EdgeParams.w, normalEdge));
    return e;
}
  • 除以中心深度:两个前后相距 1 米的物体,在 5 米处和 50 米处产生的深度差相同,但我们希望近处描、远处也描——真正有意义的是“相对差”。除以中心深度让阈值与距离无关。
  • 乘以 N·V:斜着看一个平面(远处的地面)时,相邻像素的深度变化很快,相对梯度约与 1/(N·V) 成正比,不处理的话地平线附近会出现一大片假的“边”。乘上 N·V 正好抵消这一项,而真正的轮廓(深度突变)即使乘了 N·V 仍远超阈值。早期版本没有这一步时,配图的地平线处有一条粗黑带。

深度/法线纹理在延迟渲染、开启“Accurate G-buffer Normals”时会采用八面体编码,SampleSceneNormals 靠 _GBUFFER_NORMALS_OCT 关键字决定是否解码,所以 Shader 里加了 #pragma multi_compile_fragment _ _GBUFFER_NORMALS_OCT。

Assets/Cookbook/Ch26/Editor/Ch26Shots.cs(节选:shot-edge)
[BookShot("ch26-edge", W = 640, H = 400, Panels = 3)]
static void Edge(Shot s)
{
    Stage(s);
    s.Feature<EdgeDetectFeature>();
    var v = s.Post().Add<EdgeDetectVolume>();
    v.intensity.Override(1f);
    v.thickness.Override(1f);
    switch (s.Panel)
    {
        case 0: v.edgesOnly.Override(1f); v.normalThreshold.Override(4f); break;   // 只用深度
        case 1: v.edgesOnly.Override(1f); break;                                     // 深度 + 法线
        default: v.edgesOnly.Override(0f); break;                                    // 叠在原图上
    }
}
图 26-16左:只用深度(normalThreshold 设到最大)、只显示线条,得到外轮廓;中:深度 + 法线,立方体的棱、纽结的自遮挡处都出现了线;右:线条叠在原图上

调参:depthThreshold 过小会把曲面上的深度变化也当成边,过大会漏掉贴得近的物体之间的轮廓;normalThreshold 过小时,高曲率的小物体(远处的小球)会被整个涂黑——因为每个像素之间法线变化都很大。线宽用 thickness 调,它放大的是采样间距,超过 2 像素时线条会出现断裂,需要更宽的线时应该先求边再做一次膨胀(案例 25.6 的方法)。

案例 26.10受击红屏进阶单独打开 ↗

目标:角色受到伤害时,屏幕边缘泛起带有不规则“血丝”的红色,中间区域轻微压暗去色,随后在半秒左右淡出;强度可以随伤害大小变化。

原理:效果本身是一个边缘遮罩:椭圆形的距离场,用 fBm 噪声扰动边界产生不规则的“血丝”,强度越大红色往中心侵入越深。混合时不用简单的 lerp 到红色——那会把画面边缘变成一片纯色——而是“乘 + 加”:原图乘以红色保留明暗细节,再加上一点红色保证暗部也被染红:

Assets/Cookbook/Ch26/HitFlash.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);
    float k = _HitParams.x;

    // 1) 椭圆形的边缘遮罩:强度越大,红色往中心侵入越深
    float2 d = (uv - 0.5) * 2.0;
    float r = length(d * float2(1.0, 0.85));

    // 2) 用 fBm 噪声扰动遮罩边界,形成不规则的“血丝”;相位让它在运行时蠕动
    float t = _HitParams.z + _Time.y * 0.6;
    float n = Fbm(uv * _HitParams.y + float2(t, -t * 0.7));
    float inner = lerp(1.25, 0.45, k);
    half edge = smoothstep(inner, inner + 0.55, r + (n - 0.5) * 0.45);

    // 3) 中心区域轻微去色、压暗,模拟“眼前一黑”
    half luma = dot(color.rgb, half3(0.2126, 0.7152, 0.0722));
    half3 dimmed = lerp(color.rgb, luma.xxx * half3(1.0, 0.85, 0.85), 0.35 * k) * (1.0 - 0.2 * k);

    // 4) 边缘:用乘 + 加的组合,暗部也能被染红,亮部不会变成纯色块
    half3 blood = dimmed * _HitColor.rgb * 0.6 + _HitColor.rgb * 0.35;
    color.rgb = lerp(dimmed, blood, edge * saturate(k * 1.5));
    return color;
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec4 color = texture(_BlitTexture, uv);
    float k = _HitParams.x;

    // 1) 椭圆形的边缘遮罩:强度越大,红色往中心侵入越深
    vec2 d = (uv - 0.5) * 2.0;
    float r = length(d * vec2(1.0, 0.85));

    // 2) 用 fBm 噪声扰动遮罩边界,形成不规则的“血丝”;相位让它在运行时蠕动
    float t = _HitParams.z + _Time.y * 0.6;
    float n = Fbm(uv * _HitParams.y + vec2(t, -t * 0.7));
    float inner = mix(1.25, 0.45, k);
    float edge = smoothstep(inner, inner + 0.55, r + (n - 0.5) * 0.45);

    // 3) 中心区域轻微去色、压暗,模拟“眼前一黑”
    float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
    vec3 dimmed = mix(color.rgb, vec3(luma) * vec3(1.0, 0.85, 0.85), 0.35 * k) * (1.0 - 0.2 * k);

    // 4) 边缘:用乘 + 加的组合,暗部也能被染红,亮部不会变成纯色块
    vec3 blood = dimmed * _HitColor.rgb * 0.6 + _HitColor.rgb * 0.35;
    color.rgb = mix(dimmed, blood, edge * saturate(k * 1.5));
    return color;
}
图 26-17intensity 分别为 0.3、0.65、1:红色逐渐从角落向中心侵入,中央画面压暗、去色的程度也随之增加

怎么驱动它:这个效果天然是“瞬时”的,最干净的做法是让 Volume 权重来做动画,而不是每帧去改 Profile 里的参数:

  1. 场景里放一个全局 Volume,Profile 中只添加 Hit Flash 覆盖项,intensity = 1。
  2. 给它挂上下面的 HitFlashTrigger,受击时调用 Hit(伤害比例)。
Assets/Cookbook/Ch26/HitFlashTrigger.cs(节选:trigger)
/// 挂在一个全局 Volume(Is Global 勾选,Profile 里只放 HitFlashVolume 且 intensity=1)上。
/// 受击时调用 Hit():Volume 权重瞬间拉到 1 再按曲线衰减到 0。权重为 0 时栈里的 intensity 回到默认值 0,
/// Feature 的 IsActive() 为假,整个 Pass 不入队,平时没有任何开销。
[RequireComponent(typeof(Volume))]
public class HitFlashTrigger : MonoBehaviour
{
    public float duration = 0.6f;
    public AnimationCurve falloff = AnimationCurve.EaseInOut(0, 1, 1, 0);

    Volume m_Volume;
    float m_Elapsed = float.MaxValue;

    void Awake()
    {
        m_Volume = GetComponent<Volume>();
        m_Volume.weight = 0f;
    }

    public void Hit(float strength = 1f)
    {
        m_Elapsed = 0f;
        m_Volume.weight = strength;
    }

    void Update()
    {
        if (m_Elapsed > duration) { m_Volume.weight = 0f; return; }
        m_Elapsed += Time.deltaTime;
        m_Volume.weight = falloff.Evaluate(Mathf.Clamp01(m_Elapsed / duration));
    }
}

这样做的好处是:Profile 资产不会在运行时被改脏(直接改 sharedProfile 里的参数,退出 Play 模式后改动会留在资产里);多个来源的受击可以用多个 Volume 叠加;权重回到 0 后,栈里的 intensity 回到默认值 0,IsActive() 为假,Pass 不入队,平时没有任何开销。这个“用 Volume 权重做后处理动画”的模式同样适用于径向模糊、低血量时的持续暗角、进入水下时的色调等。

注意:记得 26.1 节的提醒:如果相机的 Volume Update Mode 是 Via Scripting,权重变化不会自动反映到栈里。另外,Volume.weight 是对整个 Profile 生效的,所以给这类“动画用”的 Volume 单独一个 Profile,不要和常驻的调色、Bloom 放在一起。

26.5 效果不出来时的排查顺序

自定义后处理“加了但看不到”是最常见的问题,原因分布在 Feature、Volume、相机三个地方。按下面的顺序检查,基本都能在几分钟内定位:

检查点说明
Feature 加在了实际使用的 Renderer 上吗项目的每个画质等级(Quality Level)可以指定不同的 URP Asset,每个 Asset 又有自己的 Renderer 列表,相机还能选列表中的第几个。编辑器里切换画质等级后效果消失,多半是这个原因
相机勾选了 Post Processing 吗框架在 postProcessEnabled 为假时直接不入队;Scene 视图则看工具栏 Effects 菜单里的 Post Processing 开关
Volume 的参数勾选了覆盖吗参数左侧的复选框(overrideState)没勾时,值不会进入栈,栈里仍是默认值
Volume 能影响这个相机吗相机的 Volume Mask 是否包含 Volume 所在的层;局部 Volume 是否有 Collider、相机是否在范围内;权重是否大于 0
IsActive() 判断对吗在 AddRenderPasses 里临时打印 stack.GetComponent<T>() 的参数值,确认混合结果符合预期
Frame Debugger 里有没有这个 Pass有 Pass 但画面不变:检查 Shader 是否编译出错(粉色或黑屏)、目标是否写对、是否在 Blit 后被下一个 Pass 覆盖;没有 Pass:回到上面几条
打包后才消失Shader 没有被引用而被剔除;或者自定义 VolumeComponent 所在的程序集被代码裁剪(Managed Stripping Level 较高时)剥离——给 Feature 直接引用 Shader,必要时用 link.xml 保留程序集

26.6 性能与移动端

自定义后处理的成本几乎都可以用“全屏读写了多少次、每次多少字节”来估算。下面是本章效果的结构性开销汇总(全分辨率读写次数指相机分辨率的全屏操作,不含降采样后的小图):

效果全分辨率全屏读写每像素采样额外输入
饱和度 / 暗角 / 像素化 / 受击红屏2(处理 + 拷回)1—
色差23~16—
径向模糊24~32—
CRT24—
高斯模糊(1/N 降采样)2(缩小、放大)小图上 10 × 迭代—
Dual Kawase2(第一级下采样、最后一级上采样)5 / 8,逐级递减—
Bloom3(预过滤、合成、拷回)金字塔上 13 / 9—
Sobel 描边28 次深度 + 8 次法线 + 1DepthNormals 预渲染

据此,移动端的优化思路很明确:

  • 合并:逐像素、只读一次颜色的效果(饱和度、暗角、色差、CRT、受击红屏)合并成一个 Uber Pass,N 个效果从 2N 次全屏读写降到 2 次。
  • 降采样:模糊、Bloom 尽早降到 1/2 或 1/4,主要的迭代都在小图上完成。
  • 精度:Shader 中颜色计算用 half;中间 RT 在不需要 HDR 的场合用 8 位格式,Bloom 金字塔用 B10G11R11_UFloatPack32 这类紧凑的 HDR 格式(URP 相机 HDR 缓冲的常用格式)。
  • 减少依赖:能只用深度就不要用法线——ScriptableRenderPassInput.Normal 带来的 DepthNormals 预渲染在复杂场景里可能比描边本身贵得多。
  • 关闭即零成本:坚持 IsActive() + 不入队的结构;不要在 Shader 里用 if (intensity == 0) return color; 来“关闭”效果——那仍然付出了全部的带宽。

26.7 与 URP 17 的关系

Volume 框架在 URP 17 中没有本质变化,本章的 VolumeComponent、参数类型、IsActive 判断、用权重做动画的写法都可以原样保留;Shader 部分(Blit.hlsl 的约定、各个效果的算法)也完全通用。需要改写的只是第 25 章 25.8 节提到的 Pass 的 C# 部分:在 Render Graph 下,Bloom 这样的多级金字塔不再由我们持有 RTHandle 数组,而是在图中按帧创建临时纹理,由图负责分配、复用和释放;逐次参数也改由每个 Pass 的数据对象传入渲染函数。具体 API 请以对应版本的文档为准。

本章小结

  • VolumeStack 的计算:从默认值出发,按 priority 从低到高、用每个 Volume 的有效权重对 overrideState 为真的参数依次插值;浮点线性插值,整数截断,布尔与枚举跳变。
  • 自定义后处理 = VolumeComponent(参数,默认值即“无效果”)+ IPostProcessComponent(IsActive)+ Feature(在 AddRenderPasses 里读 VolumeManager.instance.stack,无效果时不入队)。
  • 把重复代码抽成 Feature 基类与 Pass 基类后,单 Pass 效果的 C# 只剩参数传递。
  • 多 Pass 效果使用乒乓 RT 或降采样金字塔;迭代中变化的参数必须用 cmd.SetGlobalXxx 记录进命令缓冲。
  • 可分离高斯把 N² 次采样降到 2N,双线性技巧再减半;Dual Kawase 用金字塔获得指数增长的半径;Bloom = 软阈值预过滤 + 13 次采样下采样 + 帐篷上采样按 scatter 混合 + HDR 相加。
  • Sobel 描边需要声明 Depth 与 Normal 输入;深度梯度要除以深度、乘以 N·V 才不会在远处和斜面上产生假边。
  • 瞬时效果用 Volume 权重做动画,既不改脏资产,又能在关闭时零开销。

练习

  1. 给 SaturationVolume 增加 ColorParameter tint,并思考:它的默认值应该是什么才能保证“没有 Volume 时无效果”?IsActive 要怎么改?
  2. 把高斯模糊改成只模糊画面上下两条带(电影感的“移轴”效果):在放大回相机颜色时,按屏幕 v 坐标在原图与模糊图之间插值。
  3. 为 MyBloom 实现 Dirt:增加一个 TextureParameter,合成时 bloom * (1 + dirt * dirtIntensity)。
  4. 把色差、暗角、受击红屏合并成一个 Uber Feature:一个 Shader 用 multi_compile_local 关键字开关各部分,C# 里按各自的 IsActive 设置关键字。用 Frame Debugger 对比合并前后的全屏 Pass 数量。
  5. Sobel 描边目前对透明物体无效(它们不写深度、不进法线图)。查阅第 16 章,思考有哪些办法让某些透明物体也被描边。