自定义后处理
URP 14 没有提供“自定义后处理”的专用接口,但第 25 章的 Renderer Feature 加上 Volume 框架,就能做出和内置 Bloom、Vignette 用起来一模一样的效果:在 Volume 上添加覆盖项、按区域混合、按权重淡入淡出。本章先把 Volume 的工作机制讲透,再搭一个小框架,然后用它实现十种常见的全屏效果。
26.1 Volume 框架是怎么工作的
先在场景里观察一下 URP 自带后处理的用法:给物体加 Volume 组件,指定一个 VolumeProfile 资产,在 Profile 里 ,勾选要覆盖的参数并填值。这一套由 core 包的四个类型组成:
| 类型 | 是什么 | 源码位置(core 14) |
|---|---|---|
Volume | 场景里的 MonoBehaviour:全局或局部(靠 Collider 定义范围)、优先级 priority、权重 weight、混合距离 blendDistance | Runtime/Volume/Volume.cs |
VolumeProfile | ScriptableObject 资产,装着若干个 VolumeComponent | VolumeProfile.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²),不是线性的:刚进入混合范围时变化慢,靠近边界时变化快。
volumeStack),需要你在参数变化后手动调用 camera.UpdateVolumeStack()。对于静态后处理设置,这能省下每帧的混合开销;但如果你做了受击红屏这类动态效果,记得别用这个模式,或者在触发时手动更新。编辑器非运行状态和 Scene 视图相机总是每帧更新。26.2 自定义后处理的整体结构
URP 的内置后处理全部在一个大 Pass(PostProcessPass)里执行,它不认识我们写的 VolumeComponent。所以自定义后处理的结构是:Volume 组件只负责“存参数和被混合”,一个 Renderer Feature 负责“每帧从栈里读出混合结果,决定要不要画、怎么画”。
这里的 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 里画成滑条:
// 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 为支点缩放,等价于对亮度做幂次变换 ,暗部和亮部都按“档位”(stop)对称地拉开或压缩,URP 自己的 Color Adjustments 也采用以中灰为支点的思路:
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:
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 完全同构(拷出处理、拷回、恢复目标),这里不再重复讲解:
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'); } }
[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); } }

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

在编辑器里使用:
- Renderer Data 上 ,拖入 Shader。
- 场景里选中(或新建)一个 Volume,,勾选 saturation 前的复选框并调整。
- 确认相机勾选了 。
参数类型怎么选
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。把它们抽成两个基类,后面每个效果就只剩“参数”和“怎么画”两件事。
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 里重写一个属性即可。
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、无深度、可按倍数缩小”的描述符,模糊类效果都要用。
/// 最常见的“单 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),非常干净。
逐次变化的参数:第 25 章提醒过,材质属性在命令执行时才被读取。如果在一个 for 循环里对同一个材质 SetFloat("_Offset", i) 然后 Blit,所有 Blit 最终都读到最后一次的值。正确做法是把参数记录进命令缓冲:cmd.SetGlobalFloat、cmd.SetGlobalVector、cmd.SetGlobalTexture。它们按录制顺序生效,每次 Blit 看到的是它之前最近一次设置的值。要让全局值生效,Shader 里对应的变量不能出现在 Properties 块里,也不能在材质上 Set 过(材质上的同名属性优先级高于全局值)。本章约定:一帧内不变的参数写材质,迭代中变化的参数用 cmd.SetGlobalXxx。
案例 26.2可分离高斯模糊与降采样进阶单独打开 ↗
目标:实现可调强度的全屏高斯模糊,用于暂停菜单背景、UI 毛玻璃等。
原理:二维高斯函数可以拆成两个一维高斯的乘积:
所以一个 N×N 的二维卷积,等价于先做一遍水平 N 抽头卷积、再做一遍竖直 N 抽头卷积:每像素的采样数从 N² 降到 2N。9×9 的核从 81 次降到 18 次。
还能再省一半:利用 GPU 的双线性过滤,在两个相邻纹素之间的某个位置采样一次,硬件会自动按距离给两个纹素加权。只要采样位置选得恰当,一次采样就等于两个权重的加权和。设相邻两个抽头偏移为 、权重为 ,合并后:
用 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 相同:
// 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 放大采样间距,进一步扩大有效半径:
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); } }

调参与开销:模糊半径大致正比于 downsample × spread × √iterations(多次高斯卷积的方差相加,标准差按平方根增长)。在开销上,降采样最划算:1/4 分辨率时像素数只有 1/16,迭代几次都很便宜。spread 不宜超过 2 左右,否则抽头之间的间隙会产生可见的重影条纹——想要更大的半径,加降采样或加迭代。
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 递减,总开销几乎与级数无关,而模糊半径随级数指数增长。
// 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); }
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 次的偏移,效果完全不对,而且不会报任何错。
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); }

| 方法(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 模拟的是镜头和人眼里的光散射——极亮的光源周围出现一圈逐渐衰减的光晕。实现分四步:
- 预过滤(Prefilter):从相机颜色中提取亮部,同时降到 1/2 分辨率。
- 下采样金字塔:反复减半,得到 1/4、1/8 …… 的一串越来越模糊的图。
- 上采样合并:从最小的一级开始往回放大,每级把“放大后的低一级”与“本级”按 scatter 混合。不同尺度的模糊叠在一起,形成中心亮、拖尾长的光晕。
- 合成:在线性 HDR 空间里把泛光乘以强度和颜色,加回原图。
软阈值(Soft Knee)
最简单的阈值是 (b 为像素亮度,取 RGB 最大值),但它在 b = T 处有一个折角:亮度在阈值附近轻微变化(比如物体移动、高光闪烁)时,泛光会突然出现或消失。软阈值在 [T − K, T + K] 区间内用一段二次曲线把 0 平滑地接到直线上:
在 b = T + K 处,二次曲线 s 的值与斜率都恰好等于直线 b − T 的值与斜率(都等于 K 与 1),所以拼接处是光滑的。URP 内置 Bloom 用的是同一个公式(参数里的 Threshold Knee)。我们的 Volume 用“相对阈值的比例”来表示膝宽:K = T × softKnee。
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 的源纹素,明显减轻了这种闪烁:
// 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 越大,低频(大尺度)成分占比越高,光晕越大越散;越小,光晕越集中在光源附近:
// 上采样:低一层用 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# 部分负责分配金字塔与组织调用顺序:
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"); }
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); }
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; }
[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); }

这组图使用浮点(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:

扩展方向:URP 内置 Bloom 还有 Dirt(镜头污渍贴图,用泛光强度调制一张贴图后叠加)、高质量模式(预过滤用 13 次采样)和 RGBM 编码(不支持浮点 RT 的平台上用 8 位纹理存 HDR)。理解了金字塔结构后,这些都是在某一步上的增量修改。
案例 26.5径向模糊入门单独打开 ↗
目标:冲刺、爆炸冲击时的“速度感”:画面沿着指向某个中心的方向被拉出拖影,中心附近保持清晰。
原理:对每个像素,沿“像素 → 中心”方向在一段距离内均匀采样若干次求平均。拖影长度正比于像素到中心的距离,所以边缘模糊、中心清晰,这正是镜头快速推进时的观感。为了避免采样次数少时出现分层的“重影”,每个像素的起始位置加一个 0~1 的抖动,用的是 Interleaved Gradient Noise(Jimenez 2014),把规则的分层变成细密、均匀的噪点:
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):
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); } }

运行时把 strength 交给 Volume 权重动画(参见案例 26.10 的写法),就能做出冲刺开始时猛地一拉、随后迅速恢复的效果。中心点也可以每帧设为某个世界坐标投影到屏幕的位置(Camera.WorldToViewportPoint),做成“朝着爆炸点”的冲击模糊。
案例 26.6色差与暗角:两个效果的叠加入门单独打开 ↗
目标:实现两种最常见的“镜头感”效果,并通过它们演示多个自定义后处理的组合。
色差(Chromatic Aberration)来自真实镜头对不同波长折射率不同:红光和蓝光在画面边缘的放大倍率略有差异,于是边缘出现彩色镶边。实现上,沿径向对不同颜色通道在略有不同的位置采样。只取 R、G、B 三次时,强度大了会出现三道分离的重影;这里在径向上取多个样本,每个样本按“光谱位置”给 RGB 三个权重(红→绿→蓝的三段帐篷函数),最后按通道归一化,色散就变成连续的彩虹过渡。偏移量与半径的平方成正比,中心几乎没有色差:
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 打开时按屏幕宽高比修正,使暗角是正圆而不是随屏幕拉伸的椭圆。颜色可以不是黑色——深红的暗角常用于表现紧张或受伤:
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; }
[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; }
[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)); }

两个 Feature 都在 BeforeRenderingPostProcessing,执行顺序由 Renderer 上的列表顺序决定:先色差后暗角。顺序有时会影响结果:如果先暗角后色差,暗角的边界本身也会被色散成彩色。实际项目中,这两个效果更适合合并进一个 Pass(都是逐像素、都只读一次颜色),这正是 URP 把它们放进 Uber Post 的原因。
案例 26.7像素化与调色板抖动入门单独打开 ↗
目标:复古像素风:画面由大块的“像素”组成,颜色只有少数几级,并用有序抖动(Ordered Dithering)模拟中间色。
原理:把 UV 吸附到“大像素”格子的中心,用点采样取色——同一格里所有屏幕像素读到同一个颜色。色阶量化在 sRGB 空间进行,因为人眼对暗部更敏感,在线性空间均匀分级会让暗部台阶过粗。抖动用 4×4 Bayer 矩阵:给每个大像素一个 [−0.5, 0.5) 的阈值偏移再四舍五入,相邻格子在两个色阶之间交替,远看就是中间色:
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); }

block,而不是屏幕像素坐标——否则一个大像素内部会出现细碎的网点,破坏像素风。另一个常见需求是“像素不随相机平移而游动”:屏幕空间像素化在相机移动时,格子相对于场景在滑动,会显得闪烁。真正的像素风游戏通常是让相机以低分辨率渲染到 RT、并把相机位置吸附到像素网格上,再整数倍放大显示,那属于相机层面的方案。案例 26.8CRT 显示器进阶单独打开 ↗
目标:模拟老式显像管显示器:屏幕鼓起的弯曲、扫描线、荧光粉 RGB 栅格、四角暗、电子枪轻微错位、亮度闪烁。
原理:所有效果按“先几何、再采样、后逐像素调制”的顺序放在一个 Pass 里:
- 弯曲:把 UV 变到 [−1, 1],按到中心距离的平方向外推:,等价于一个简单的桶形畸变的反向映射。推到屏幕外的部分用圆角矩形 SDF(第 4 章)画成黑色边框。
- 错位采样:R、B 两个通道左右错开 1 个像素采样。
- 扫描线:用弯曲后的 v 坐标乘以行数做余弦波,扫描线跟着屏幕一起弯。
- 荧光粉栅格:按屏幕像素的 x 坐标每 3 列一组,分别偏红、偏绿、偏蓝,再整体乘 1.6 补偿亮度损失。
- 闪烁:亮度随
_Time小幅波动;截图时_Time接近 0,所以图中看不到。
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); }

扫描线和栅格都是“逐屏幕像素”的高频图案,分辨率变化时观感差别很大:在 4K 屏上 3 像素一组的栅格几乎看不见,在 720p 上又可能太粗。实际项目里可以把栅格周期和扫描线行数与屏幕高度挂钩(例如行数 = 屏幕高度 / 3),或者提供画质档位。这个效果适合放在 AfterRenderingPostProcessing:它模拟的是“显示设备”,应该作用在最终颜色上。
案例 26.9基于深度与法线的 Sobel 描边高阶单独打开 ↗
目标:全屏描边:所有物体的轮廓、折痕都画出线条,可以叠在原图上,也可以只显示线条做“线稿”效果。和第 14 章的法线外扩描边、第 25 章的遮罩描边相比,它不需要修改任何物体,对场景一视同仁。
原理:边就是“图像中某个量突然变化的地方”。我们用两个量:深度(物体与背景、前后物体之间的轮廓)和法线(同一物体上的折痕,比如立方体的棱)。用 Sobel 算子求它们在屏幕上的梯度:
Sobel 比更简单的 Roberts 交叉算子(2×2,只取对角差)多了中间一行/列的权重 2,相当于先在垂直方向做一次平滑再求导,对噪声更不敏感、线条更连续。对法线这种三维量,分别对 xyz 三个分量求梯度再合并长度。
声明输入:效果需要 _CameraDepthTexture 和 _CameraNormalsTexture。在框架里只需重写 RequiredInputs:
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 不会出现在法线图里,描边也就缺了它的折痕线,这是使用这个效果前要检查的。
两个容易被忽略的归一化
// 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。
[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; // 叠在原图上 } }

调参:depthThreshold 过小会把曲面上的深度变化也当成边,过大会漏掉贴得近的物体之间的轮廓;normalThreshold 过小时,高曲率的小物体(远处的小球)会被整个涂黑——因为每个像素之间法线变化都很大。线宽用 thickness 调,它放大的是采样间距,超过 2 像素时线条会出现断裂,需要更宽的线时应该先求边再做一次膨胀(案例 25.6 的方法)。
案例 26.10受击红屏进阶单独打开 ↗
目标:角色受到伤害时,屏幕边缘泛起带有不规则“血丝”的红色,中间区域轻微压暗去色,随后在半秒左右淡出;强度可以随伤害大小变化。
原理:效果本身是一个边缘遮罩:椭圆形的距离场,用 fBm 噪声扰动边界产生不规则的“血丝”,强度越大红色往中心侵入越深。混合时不用简单的 lerp 到红色——那会把画面边缘变成一片纯色——而是“乘 + 加”:原图乘以红色保留明暗细节,再加上一点红色保证暗部也被染红:
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; }

怎么驱动它:这个效果天然是“瞬时”的,最干净的做法是让 Volume 权重来做动画,而不是每帧去改 Profile 里的参数:
- 场景里放一个全局 Volume,Profile 中只添加 Hit Flash 覆盖项,intensity = 1。
- 给它挂上下面的
HitFlashTrigger,受击时调用Hit(伤害比例)。
/// 挂在一个全局 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 | — |
| 色差 | 2 | 3~16 | — |
| 径向模糊 | 2 | 4~32 | — |
| CRT | 2 | 4 | — |
| 高斯模糊(1/N 降采样) | 2(缩小、放大) | 小图上 10 × 迭代 | — |
| Dual Kawase | 2(第一级下采样、最后一级上采样) | 5 / 8,逐级递减 | — |
| Bloom | 3(预过滤、合成、拷回) | 金字塔上 13 / 9 | — |
| Sobel 描边 | 2 | 8 次深度 + 8 次法线 + 1 | DepthNormals 预渲染 |
据此,移动端的优化思路很明确:
- 合并:逐像素、只读一次颜色的效果(饱和度、暗角、色差、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 权重做动画,既不改脏资产,又能在关闭时零开销。
练习
- 给 SaturationVolume 增加
ColorParameter tint,并思考:它的默认值应该是什么才能保证“没有 Volume 时无效果”?IsActive 要怎么改? - 把高斯模糊改成只模糊画面上下两条带(电影感的“移轴”效果):在放大回相机颜色时,按屏幕 v 坐标在原图与模糊图之间插值。
- 为 MyBloom 实现 Dirt:增加一个
TextureParameter,合成时bloom * (1 + dirt * dirtIntensity)。 - 把色差、暗角、受击红屏合并成一个 Uber Feature:一个 Shader 用
multi_compile_local关键字开关各部分,C# 里按各自的 IsActive 设置关键字。用 Frame Debugger 对比合并前后的全屏 Pass 数量。 - Sobel 描边目前对透明物体无效(它们不写深度、不进法线图)。查阅第 16 章,思考有哪些办法让某些透明物体也被描边。