URP 内置后处理全解析
第 26 章我们自己写了后处理。但项目里绝大多数画面效果其实来自 URP 自带的那十几个 Volume Override。它们的每个滑条到底改了什么?顺序如何?哪些在移动端很贵?本章逐个打开 URP 14 的 Runtime/Overrides/*.cs 和对应的 Shader,用统一的样板场景给每个效果配上开/关对比图,把参数含义落实到源码里的那一行公式上。
27.1 Volume 框架
URP 的后处理参数不写在相机上,而写在场景里的 Volume 上。一个 Volume 组件引用一个 Volume Profile 资源,Profile 里是若干个 Volume Component(也叫 Override),比如 Bloom、Tonemapping。每个 Component 里有一组 VolumeParameter,每个参数前面有一个复选框——这就是 overrideState,只有勾上的参数才会参与混合。没勾的参数保持“默认值”,而默认值就是源码里构造参数时写的那个值,例如 Bloom 的 intensity 默认 0,所以只把 Bloom 加进 Profile 却不勾 Intensity,是不会有 Bloom 的。
相机端的开关
- 相机 Inspector 的 必须勾选(
UniversalAdditionalCameraData.renderPostProcessing)。 - 决定这台相机受哪些 Layer 上的 Volume 影响; 决定用哪个 Transform 的位置去判断局部 Volume(默认是相机自己)。
- Renderer(Universal Renderer Data)上的 要启用并指定 Post Process Data(里面是后处理用到的 Shader 和贴图)。
- URP Asset 的 栏有 Grading Mode(Low/High Dynamic Range)、LUT size(默认 32,范围 16~65)、Fast sRGB/Linear conversions、Volume Update Mode 等全局设置。
混合规则
每帧渲染相机前,VolumeManager.Update 先把一个“栈”(VolumeStack,每种 Component 一份)重置为默认值,然后按优先级从低到高遍历所有生效的 Volume,逐个把它们的参数“叠”上去。对每个 Volume,混合系数是:
然后对每个勾选了 override 的参数执行 stateParam.Interp(stateParam, toParam, t)。浮点、颜色、向量参数的 Interp 是线性插值;布尔、枚举、纹理这类参数的默认 Interp 是“t > 0 就直接替换”(VolumeParameter<T>.Interp:m_Value = t > 0f ? to : from)。这解释了两个常见现象:局部 Volume 的 Tonemapping 模式在进入混合区的一瞬间就切换了,不会渐变;多个 Volume 都覆盖同一个参数时,优先级高的最后叠上去,以它的权重为准插值“盖”住前面的结果。注意 blendDistance 的衰减按距离平方计算,不是线性的;碰撞体只用来求“相机到 Volume 的最近距离”(Collider.ClosestPoint,相机在内部时距离为 0),所以局部 Volume 需要一个 Collider(可以设为 Trigger),类型限于 Box、Sphere、Capsule 和凸的 Mesh Collider。

sharedProfile,改它会直接改磁盘上的资源;运行时脚本访问 volume.profile 会先克隆一份实例(与 Renderer.material 的行为类似),改实例不影响资源。27.21 节的脚本示范了这种用法。27.2 后处理在一帧里的位置与顺序
URP 14 的后处理由 PostProcessPass 执行,事件是 BeforeRenderingPostProcessing 之后。在它之前还有一个 ColorGradingLutPass,把所有调色效果(白平衡、对比度、曲线……加上色调映射)预先“烘”进一张 LUT。真正处理屏幕像素时的顺序如下:
PostProcessPass.Render 的执行顺序。Bloom 的金字塔在进入 Uber 之前单独计算,结果作为纹理被 Uber 读取这个顺序有几个实际含义:Bloom 作用在色调映射之前的 HDR 颜色上,所以它的阈值是 HDR 亮度;Post Exposure 在 Bloom 之后乘上去,调曝光不会改变 Bloom 的阈值判断;Vignette 在色调映射之前,所以 ACES 也会作用在暗角颜色上;颗粒在色调映射之后的显示空间加。另外,几乎所有“屏幕空间”的效果都被合并进 Uber 一个 Pass,只有景深、运动模糊、Panini 和 Bloom 金字塔需要额外的全屏 Pass——这也是 URP 在移动端后处理开销可控的原因。
targetTexture 时,URP 14 的 CreateRenderTextureDescriptor 直接用这张 RT 的格式作为中间颜色缓冲的格式。如果你给相机挂的是一张 ARGB32 的 RT(截图、小地图、UI 里的 3D 预览常这样做),整帧会在 8 位里渲染,HDR 亮度在后处理之前就被截断,Bloom 阈值、色调映射全部失真。本章的配图都让相机渲染到 ARGBHalf(16 位浮点)的 RT,再转换成 8 位图片输出,保证后处理拿到的是完整的 HDR 数据。项目里遇到“RT 里的画面后处理不对、Bloom 很弱”,先检查 RT 格式。27.3 本章的样板场景
为了让每个效果都“有东西可调”,所有对比图使用同一个场景:左边是一块 24 色标准色卡(用 Unlit 显示,颜色值取公开的常见近似值),可以精确看出调色对各种色相、灰阶的影响;中间是红黄绿蓝四个彩色球和一个高光很强的白球;后方和地面上有几个亮度远大于 1 的自发光物体(橙色圆环、青色和品红方块、绿色灯管),用来测试 Bloom 和色调映射。除特别说明,图中都开启了 ACES 色调映射作为基础,这样对比的只是被讨论的那个效果。图中的“关”指的是该效果关闭但其他设置不变。
27.4 Bloom
Bloom 模拟镜头和人眼中强光向周围的散射。URP 的实现是经典的“亮度提取 + 高斯金字塔 + 逐级上采样合成”,代码在 PostProcessPass.SetupBloom 和 Shaders/PostProcessing/Bloom.shader。
| 参数 | 默认 | 含义(以源码为准) |
|---|---|---|
| Threshold | 0.9 | Gamma 空间的亮度阈值,C# 端先 GammaToLinearSpace 转成线性值再传给 Shader;软拐点宽度固定为阈值的一半 |
| Intensity | 0 | 合成时 Bloom 纹理的乘数,0 时整个效果关闭 |
| Scatter | 0.7 | 上采样时低一级 mip 的混合比例,映射为 lerp(0.05, 0.95, scatter) |
| Tint | 白 | 颜色在使用前被除以自身亮度,只改色相不改亮度 |
| Clamp | 65472 | 参与计算的最大亮度,防止个别极亮像素(数值错误的高光)炸出巨大光晕 |
| High Quality Filtering | 关 | 预过滤用 13 次采样、上采样用双三次(Bicubic) |
| Downscale(高级) | Half | 金字塔起始分辨率:Half 或 Quarter |
| Max Iterations(高级) | 6 | 金字塔层数上限(2~8),实际层数还受分辨率限制 |
| Dirt Texture / Dirt Intensity | 无 / 0 | 镜头脏污贴图,按 Bloom 亮度调制后叠加 |
预过滤:先按 min(Clamp, color) 截断,然后以 RGB 三通道的最大值作为亮度做软阈值。设亮度为 b、阈值为 T、拐点 K = T/2:
亮度在 T − K 以下完全不贡献,T + K 以上按 b − T 线性贡献,中间用二次曲线平滑过渡,避免阈值处出现硬边。也就是说,Bloom 只取“超出阈值的那部分能量”。
金字塔:预过滤结果写入半分辨率的 mip0,然后每一级先 2 倍降采样并做 9 次采样的高斯模糊,再做一次可分离的第二遍模糊。上采样从最小一级往回走,每一级 up[i] = lerp(down[i], up[i+1], scatter)。把它展开,最终结果是各级的加权和,权重为 (1−s)、s(1−s)、s²(1−s)……:Scatter 越大,越多能量来自分辨率低、模糊半径大的层级,光晕就越大越柔。



Dirt:Uber 里的合成是 color += bloom × intensity × tint,开了 Dirt 再加 dirt × dirtIntensity × bloom。脏污贴图按“覆盖”方式缩放到屏幕,保持自身长宽比(多出的部分裁掉)。由于它乘的是 Bloom 的亮度,只有画面上有强光的区域附近才显出脏污,这正是真实镜头的表现。

使用建议:Bloom 的物理前提是 HDR——只有亮度超过 1 的像素才“应该”泛光,所以通常把 Threshold 保持在 1 左右,让美术用自发光强度控制谁发光,而不是降低阈值让整个画面都泛光。移动端可以把 Downscale 设为 Quarter、关闭 High Quality Filtering、Max Iterations 降到 4~5;Bloom 金字塔的每一级都是一次全屏(缩小后的)读写,带宽是主要开销。
27.5 Tonemapping
场景渲染在线性 HDR 空间进行,亮度没有上限;显示器只能显示 0~1(SDR)。最简单的处理是截断,结果是高光处的颜色先在某个通道饱和、再整体变白,丢失层次。色调映射(Tonemapping)用一条 S 形曲线把 [0, ∞) 压到 [0, 1),同时保留暗部对比、柔和地滚降高光。
| 参数 | 说明 |
|---|---|
| Mode | None / Neutral / ACES。IsActive 只在不为 None 时返回 true |
| Neutral HDR Range Reduction Mode、ACES Preset、Hue Shift Amount、Detect Paper White、Paper White、Detect Brightness Limits、Min/Max Nits | 只在开启 HDR 显示输出(HDR Output)时使用,控制如何把场景亮度映射到 HDR 显示器的 nit 范围;SDR 输出时忽略 |
Neutral 是 John Hable 风格的“艺术友好”曲线(Color.hlsl 的注释写的是 Hable/Hejl/Frostbite),对色相影响很小,参数固定:白点 5.3,也就是线性亮度 5.3 会被映射到 1。它适合需要自己做完整调色、不希望色调映射带来额外“风格”的项目。
// 摘自 core 14 ShaderLibrary/Color.hlsl float3 NeutralCurve(float3 x, real a, real b, real c, real d, real e, real f) { return ((x * (a * x + c * b) + d * e) / (x * (a * x + b) + d * f)) - e / f; } // NeutralTonemap: a=0.2 b=0.29 c=0.24 d=0.272 e=0.02 f=0.3, whiteLevel=5.3 // whiteScale = 1 / NeutralCurve(whiteLevel); x = NeutralCurve(x * whiteScale) * whiteScale
ACES 采用电影工业 ACES 标准中的参考渲染变换(RRT)和输出变换(ODT)的近似实现:先把颜色从 sRGB 原色转换到 ACES 的 AP0/AP1 色域,在那里做色调映射,再转回来。它的曲线更陡、对比更强,暗部压得更深,高光的饱和颜色会逐渐偏向白色(亮橙色变黄、亮蓝色变青白),给画面一种“电影感”。代价是它会改变色相与饱和度,美术在 ACES 下调好的颜色切到 Neutral 会显得不一样,所以项目早期就应该定下用哪一个。

Grading Mode 与色调映射在哪做。URP Asset 的 Grading Mode 为 High Dynamic Range 时,调色和色调映射一起烘进一张以 LogC 编码的 3D LUT(LutBuilderHdr.shader),Uber 里只查一次表;为 Low Dynamic Range 时,Uber 先做色调映射(ApplyTonemap),再在 0~1 的范围里查 LDR LUT。HDR 模式下调色作用在色调映射之前的线性 HDR 数据上,结果更自然(比如对比度不会把高光直接推爆),也是 HDR 渲染时的推荐设置;LDR 模式更便宜一点,适合不开 HDR 的低端配置。本章所有图都使用 HDR Grading。
27.6 调色效果的总顺序
接下来的九个效果(Color Adjustments、White Balance、Split Toning、Channel Mixer、Shadows Midtones Highlights、Lift Gamma Gain、Color Curves、Tonemapping,以及 Color Lookup)都不在屏幕上逐像素做,而是在 ColorGradingLutPass 里写进同一张 LUT。读 LutBuilderHdr.shader 的 ColorGrade 函数可以得到精确的处理顺序:
- LogC 解码回线性;
- White Balance:转到 LMS 空间乘系数,再转回;
- Contrast:在对数空间(ACES 模式下是 ACEScc,否则是 LogC)围绕中灰点缩放;
- Color Filter:直接乘颜色;然后把负值钳到 0;
- Split Toning:在近似 Gamma 2.2 空间做两次柔光混合;
- Channel Mixer:3×3 矩阵;
- Shadows Midtones Highlights:按亮度分三段加权乘色;
- Lift Gamma Gain:
color × gain + lift,再做幂; - HSV 运算:Hue vs Sat、Sat vs Sat、Lum vs Sat 三条曲线得到饱和度倍数,Hue Shift 与 Hue vs Hue 改色相;
- Saturation:围绕亮度缩放;
- YRGB 曲线:先用
FastTonemap把值压进 0~1,依次过 Master、R、G、B 曲线,再逆变换回去; - 最后 Tonemapping(在 LUT 的后续步骤中)。
Post Exposure 不在这里,它在 Uber 里查表之前直接乘到颜色上;Color Lookup(用户 LUT)也不在这里,它在 Uber 里于内部 LUT 之后、在 sRGB 空间中查第二张表。理解这个顺序能解释很多“调了没效果”的问题:比如先用 Channel Mixer 把画面变黑白,后面 Shadows Midtones Highlights 上的颜色仍然会生效;而 Saturation 设为 −100 在 Split Toning 之后执行,会把 Split Toning 的颜色一起去掉。
因为所有调色最终都烘进 LUT,它们在屏幕上的开销与开启几个无关——只要开了后处理,Uber 里永远有一次 LUT 查询。LUT 本身每帧重新生成(32³ = 32768 个像素的小图),开销很小。LUT 尺寸 32 对大多数项目足够;画面中有大面积平滑渐变(天空)且调色很激进时,才需要考虑 64 来减少色带。
27.7 Color Adjustments
| 参数 | 范围/默认 | 源码中的计算 |
|---|---|---|
| Post Exposure | EV,默认 0 | Uber 中 color *= 2^EV,在 Bloom 之后、查 LUT 之前;不影响 Bloom、景深的输入 |
| Contrast | −100~100 | (log − midGray) × (contrast/100 + 1) + midGray,在对数空间围绕中灰缩放 |
| Color Filter | HDR 颜色 | 线性空间直接相乘,可以大于 1 |
| Hue Shift | −180~180 | 换算为 hueShift/360 加到 HSV 的 H 上 |
| Saturation | −100~100 | luma + (saturation/100 + 1) × (color − luma) |


对比度在对数空间调整是一个重要细节:线性空间的对比度会把高光推到无穷、把暗部压成负数;对数空间里围绕中灰缩放,相当于对“曝光档位”做缩放,暗部和亮部对称变化,这与摄影中的对比度直觉一致。另外注意 Saturation 用的“亮度”是 GetLuminance(Rec.709 系数),去色后的灰度与人眼亮度感知一致,黄色比蓝色亮得多,这正是图中黑白版本里黄球最亮、蓝球最暗的原因。
27.8 White Balance
白平衡用两个参数模拟相机的色温和色调校正:Temperature(−100~100,负值偏蓝、正值偏黄)和 Tint(−100~100,负值偏绿、正值偏品红)。C# 端 ColorUtils.ColorBalanceToLMSCoeffs 把它们换算成 LMS(人眼三种视锥细胞的响应空间)中的三个缩放系数;Shader 里把颜色转到 LMS、逐通道相乘、再转回线性 RGB。这比直接在 RGB 上乘色更接近真实的白平衡:它模拟的是“换了一种光源颜色、眼睛重新适应”,对中性灰的影响最明显,对饱和色的影响相对温和。

白平衡最常见的用途是统一不同场景的“冷暖基调”:夜景偏冷、黄昏偏暖。数值一般在 ±10~±30 就足够,图中的 ±60 只是为了看清效果。
27.9 Lift Gamma Gain
这是调色软件里经典的三色轮:Lift 控制暗部、Gamma 控制中间调、Gain 控制亮部。每个参数是一个 Vector4:xyz 是色轮上选的颜色(默认 1,1,1),w 是色轮下方的亮度滑条(默认 0)。它们在 ColorUtils.PrepareLiftGammaGain 中被换算:
// 摘自 core 14 Runtime/Utilities/ColorUtils.cs(节选) lift.xyz = GammaToLinear(lift.xyz) * 0.15f; lift.xyz = lift.xyz - Luminance(lift) + lift.w; gamma.xyz = GammaToLinear(gamma.xyz) * 0.8f; gamma.xyz = 1 / max(gamma.xyz - Luminance(gamma) + gamma.w + 1, 1e-3); gain.xyz = GammaToLinear(gain.xyz) * 0.8f; gain.xyz = gain.xyz - Luminance(gain) + gain.w + 1;
注意每个颜色都减去了自身的亮度:色轮上选的颜色只决定“往哪个方向偏色”,亮度由 w 单独控制。Shader 中的应用是:
Lift 是加法偏移,对暗部影响最大(0 附近的值加上偏移变化最明显);Gain 是乘法,对亮部影响最大;Gamma 是幂,0 和 1 两端不动、中间弯曲。

27.10 Shadows Midtones Highlights
与 Lift Gamma Gain 目的相近,但用的是“按亮度分段加权乘色”,并且分段位置可调。shadows、midtones、highlights 三个 Vector4 经 PrepareShadowsMidtonesHighlights 处理:xyz 转线性,w 为亮度(正值乘 4、负值乘 1 后加到 xyz 上)。Shader 中:
// 摘自 URP 14 Shaders/PostProcessing/LutBuilderHdr.shader luma = GetLuminance(colorLinear); float shadowsFactor = 1.0 - smoothstep(_ShaHiLimits.x, _ShaHiLimits.y, luma); float highlightsFactor = smoothstep(_ShaHiLimits.z, _ShaHiLimits.w, luma); float midtonesFactor = 1.0 - shadowsFactor - highlightsFactor; colorLinear = colorLinear * _Shadows.xyz * shadowsFactor + colorLinear * _Midtones.xyz * midtonesFactor + colorLinear * _Highlights.xyz * highlightsFactor;
_ShaHiLimits 就是 Inspector 下方的 Shadows Start/End、Highlights Start/End,默认 0、0.3、0.55、1。下图画出了默认参数下三个权重随亮度的变化,三者之和恒为 1:

与 Lift Gamma Gain 相比,Shadows Midtones Highlights 的分段是“硬性”的权重分配,调整阴影色时中间调完全不受影响,更容易做出“暗部冷、亮部暖”的分离效果;Lift Gamma Gain 的作用范围则是数学曲线自然决定的,过渡更柔和。两者可以同时使用,执行顺序是先 SMH 后 LGG。
27.11 Channel Mixer
通道混合器就是一个 3×3 矩阵:输出的每个通道是输入三个通道的加权和,权重来自九个滑条(−200~200,除以 100 后使用)。默认是单位矩阵(Red Out 的 Red In 为 100,其余为 0)。

黑白摄影里“加红色滤镜让天空变暗、皮肤变亮”的技巧,就是图中间这种做法:三个输出用同一组系数,系数决定哪种颜色在灰度里更亮。行内三个系数之和大于 1 会整体提亮,小于 1 会变暗,做黑白时通常让它们的和等于 1(即 100)。
27.12 Color Curves
Color Curves 提供八条曲线,分两组。YRGB 组:Master、Red、Green、Blue,横轴是输入值、纵轴是输出值,作用在 FastTonemap 压缩到 0~1 的数据上(x / (1 + max(r,g,b))),处理完再逆变换回 HDR,因此在 HDR 下也能安全使用。色相/饱和度组:Hue vs Hue、Hue vs Sat、Sat vs Sat、Lum vs Sat,纵轴 0.5 表示不变;Hue vs Hue 把纵轴偏离 0.5 的量加到色相上,三条“vs Sat”曲线的值乘以 2 后连乘得到饱和度倍数(所以曲线拉到 0 是完全去色,拉到 1 是饱和度翻倍)。Hue 类曲线是循环的(0 和 1 都是红色),对应 TextureCurve 构造函数的 loop = true。
ColorCurves 的 IsActive() 永远返回 true——所有曲线在默认状态下都是恒等映射,所以开不开都在 LUT 里,没有额外开销。

用代码设置曲线时要注意 Keyframe 的切线:只给时间和值的构造函数切线为 0,曲线在每个关键点都是平的,S 曲线会变成一级级台阶,造成奇怪的偏色(本书的配图就因为这个出过一次“天空变紫”的问题)。需要平滑曲线时给出合适的入/出切线,或者在 Inspector 里用曲线编辑器调。
27.13 Split Toning
分离色调给暗部和亮部分别染上一种颜色,是摄影后期中最常见的风格化手段。参数有 Shadows 颜色、Highlights 颜色(默认都是中灰 0.5,即不染色)和 Balance(−100~100)。实现上,URP 刻意在近似 Gamma 2.2 空间中计算以匹配 Adobe 软件的效果(源码注释原话:“counter-intuitive”),并用柔光(Soft Light)混合:
// 摘自 URP 14 LutBuilderHdr.shader float balance = _SplitShadows.w; // = Balance / 100 float3 colorGamma = PositivePow(colorLinear, 1.0 / 2.2); float luma = saturate(GetLuminance(saturate(colorGamma)) + balance); float3 splitShadows = lerp((0.5).xxx, _SplitShadows.xyz, 1.0 - luma); float3 splitHighlights = lerp((0.5).xxx, _SplitHighlights.xyz, luma); colorGamma = SoftLight(colorGamma, splitShadows); colorGamma = SoftLight(colorGamma, splitHighlights);
柔光混合以 0.5 为中性:混合色大于 0.5 提亮并染色,小于 0.5 压暗并染色。Balance 直接加到亮度上:正值让更多像素被当作“亮部”,Highlights 的颜色占主导;负值则 Shadows 颜色占主导。

27.14 Color Lookup
Color Lookup 让你用一张外部 LUT 贴图做调色:在 Photoshop、DaVinci Resolve 等软件里对一张“中性 LUT 图”调色,导出后交给 URP,就能在游戏里复现同样的调色。参数只有 Texture 和 Contribution(0~1 的混合比例)。
贴图格式由 ColorLookup.ValidateLUT 规定:高度必须等于 URP Asset 的 LUT size(默认 32),宽度等于高度的平方,且不能是 sRGB 格式(导入设置里要取消 sRGB,压缩选 None)。不满足时 IsActive() 返回 false,效果静默失效,Inspector 上会有提示。
LUT 中存的是 sRGB 编码的颜色:Uber 里会先把颜色钳到 0~1、转到 sRGB,再查用户 LUT,最后转回线性(ApplyColorGrading 中的 GetLinearToSRGB/GetSRGBToLinear)。HDR Grading 模式下,用户 LUT 作用在内部 LUT(含色调映射)之后,所以它看到的已经是色调映射后的 0~1 数据,这与在调色软件里对一张 SDR 截图调色的情形一致。下面的代码程序化生成一张“暖调褪色胶片”LUT:
static Texture2D MakeLut(int size) { var t = new Texture2D(size * size, size, TextureFormat.RGBA32, false, true) { name = "ch27_lut", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear, hideFlags = HideFlags.DontSave }; var px = new Color[size * size * size]; for (int b = 0; b < size; b++) for (int g = 0; g < size; g++) for (int r = 0; r < size; r++) { var c = new Vector3(r, g, b) / (size - 1); // 输入:sRGB 0..1 float l = Vector3.Dot(c, new Vector3(0.299f, 0.587f, 0.114f)); c = Vector3.Lerp(new Vector3(l, l, l), c, 0.7f); // 去掉 30% 饱和度 c = new Vector3(c.x * 1.06f + 0.03f, c.y * 0.98f + 0.025f, c.z * 0.85f + 0.06f); // 暖色 + 抬黑(褪色感) for (int k = 0; k < 3; k++) // 轻微 S 曲线 { float v = Mathf.Clamp01(c[k]); c[k] = Mathf.Lerp(v, v * v * (3 - 2 * v), 0.35f); } // 横条布局:第 b 个 size×size 小方块对应蓝色 = b,方块内 x = 红、y = 绿 px[g * size * size + b * size + r] = new Color(c.x, c.y, c.z, 1); } t.SetPixels(px); t.Apply(); return t; }

实际工作流:用 URP 包里的中性 LUT 或者用代码生成一张中性 LUT(把上面代码里的调色步骤去掉即可),在游戏里截一张没有用户 LUT 的画面,把截图和中性 LUT 一起放进调色软件,对截图调色的同时把同样的调整应用到 LUT 图上,导出 LUT 图并按上面的格式导入。只要截图时的 Volume 设置与最终一致,游戏里的结果就和调色软件里看到的一样。
27.15 Vignette
暗角模拟镜头边缘的光衰减,把视线引向画面中心。参数:Color(默认黑)、Center(默认 0.5,0.5)、Intensity(0~1)、Smoothness(0.01~1)、Rounded(是否为正圆)。C# 端把 Intensity 乘 3、Smoothness 乘 5,Rounded 时把屏幕宽高比作为 x 方向的缩放。Shader 中:
// 摘自 URP 14 Shaders/PostProcessing/Common.hlsl float2 dist = abs(uv - center) * intensity; dist.x *= roundness; // Rounded 时为宽高比,否则为 1 float vfactor = pow(saturate(1.0 - dot(dist, dist)), smoothness); return input * lerp(color, (1.0).xxx, vfactor);
不勾 Rounded 时,距离在 UV 空间计算,宽屏下暗角是一个横向拉长的椭圆,正好贴合屏幕;勾上后在像素空间是正圆,左右两侧会压得更多。注意暗角是“乘以颜色”,颜色不是黑色时就成了带色的边框,可以做受伤、中毒、潜行等状态提示(27.21 节的例子)。

27.16 Chromatic Aberration
色差(色散)模拟镜头对不同波长折射率不同造成的彩边,越靠近画面边缘越明显。URP 14 用的是 HDRP 色差的“快速版”:只采样三次,分别取 R、G、B。
// 摘自 URP 14 Shaders/PostProcessing/UberPost.shader float2 coords = 2.0 * uv - 1.0; float2 end = uv - coords * dot(coords, coords) * ChromaAmount; // ChromaAmount = intensity × 0.05 float2 delta = (end - uv) / 3.0; half r = SAMPLE(... uvDistorted).x; half g = SAMPLE(... DistortUV(delta + uv)).y; half b = SAMPLE(... DistortUV(delta * 2.0 + uv)).z;
偏移量与到中心距离的平方成正比、方向指向中心,所以中心完全没有色差,四角最强:Intensity = 1 时屏幕角落的蓝通道向内偏移约 6.7% 的屏幕宽度。它在 Uber 里只是多了两次采样,非常便宜;但它会让画面边缘的细节变糊,UI 和文字不应受它影响(URP 的后处理默认不作用于 Screen Space - Overlay 的 UI)。

27.17 Film Grain
胶片颗粒在画面上叠加噪声,既是风格化手段,也能掩盖色带。参数:Type(Thin1、Thin2、Medium1~6、Large01、Large02 十种内置颗粒贴图,或 Custom 自定义贴图)、Intensity、Response。内置贴图在 Post Process Data 里,每帧随机偏移平铺位置,所以颗粒是动态的。Shader 中:
// 摘自 URP 14 Shaders/PostProcessing/Common.hlsl(ApplyGrain) half grain = SAMPLE_TEXTURE2D(GrainTexture, GrainSampler, uv * scale + offset).w; grain = (grain - 0.5) * 2.0; // 0.5 为中性,映射到 [-1, 1] float lum = 1.0 - sqrt(Luminance(input)); lum = lerp(1.0, lum, response); // Response 越大,亮部的颗粒越少 return input + input * grain * intensity * lum;
颗粒是“乘以输入颜色”后再加的,所以纯黑处没有颗粒;Response 让颗粒集中在暗部和中间调,这符合真实胶片的特性。颗粒在色调映射之后施加;如果开启了 FXAA 或渲染缩放,URP 会把颗粒挪到 Final Post 里在最后做,避免被抗锯齿抹掉。

27.18 Lens Distortion
镜头畸变改变 Uber 采样时的 UV,模拟桶形(Barrel)和枕形(Pincushion)畸变。参数:Intensity(−1~1)、X/Y Multiplier(各方向的强度系数,设为 0 可关闭某个方向)、Center、Scale(整体缩放,用来把被推出屏幕的边缘拉回来)。C# 端把 Intensity 换算成一个角度 θ = min(160°, 1.6 × |intensity × 100|) 和 σ = 2tan(θ/2),Shader 中按到中心的距离 r 做非线性映射:正值用 tan(r·θ)/(r·σ) 把采样点往外推,图像向中心收缩、呈桶形(鱼眼);负值用 atan 反向映射,呈枕形。

畸变会影响 Bloom、色差、暗角使用的 UV(它们都用 uvDistorted),但不会改变景深、运动模糊这些在 Uber 之前完成的效果。另外,Scene 视图相机会跳过 Lens Distortion(SetupLensDistortion 的 !isSceneView 条件),所以只能在 Game 视图看到。
27.19 Panini Projection
超广角(视场角 90° 以上)的透视投影会让画面边缘的物体被严重拉伸——球变成椭圆、人像变宽。Panini 投影是一种“圆柱形”的替代投影:水平方向上把视线投到圆柱面上,保持竖直线是直的,同时大幅减少边缘拉伸。参数:Distance(0~1,0 为普通透视,1 为完全的圆柱投影)、Crop To Fit(0~1,裁掉投影后画面四角出现的空白区域)。

Panini 在 Uber 之前单独占一个全屏 Pass(PaniniProjection.shader),IsTileCompatible 返回 false,在 TBDR 移动 GPU 上会打断 tile 内的连续处理。它主要用于竞速、第一人称射击这类需要大视野的游戏,视场角不大时没有必要开。
27.20 Depth of Field
景深模拟镜头只能对一个距离精确对焦。URP 14 有两种模式,都在半分辨率下工作,都需要深度纹理(URP 会自动请求)。
Gaussian
“只有远景模糊”的快速近似(DepthOfFieldMode.Gaussian 的注释:Non physical, fast, small radius, far blur only)。参数 Start、End、Max Radius(0.5~1.5)、High Quality Sampling。弥散圆(CoC)按深度线性计算:
最大模糊半径在 C# 中按 maxRadius × (半分辨率宽度 / 1080) 换算,并限制在 2 以内,所以低分辨率下模糊半径也相应变小。适合移动端和“远景虚化”这种简单需求。

Bokeh
基于物理相机模型的景深,前景和背景都会模糊,亮点会变成光圈形状的光斑。参数:Focus Distance、Aperture(光圈值 f/1~f/32)、Focal Length(1~300 mm)、Blade Count(光圈叶片数 3~9)、Blade Curvature(叶片曲率,1 为正圆)、Blade Rotation。CoC 计算(DoBokehDepthOfField 与 BokehDepthOfField.shader):
其中 P 是对焦距离、z 是线性深度。前景 CoC 为负、背景为正,各自钳在 [−1, 1],再乘以一个随屏幕高度换算的最大半径(源码中经验值:14 像素对应的 UV 比例,上限 0.05)。可以看出:光圈值越小(光圈越大)、焦距越长、对焦距离越近,景深越浅。实际的模糊是用 42 个采样点组成的同心圆盘核(SAMPLE_COUNT 42)完成的,叶片数和曲率改变的是这些采样点的分布形状。

性能:Bokeh 在半分辨率下每像素 42 次采样,再加上 CoC 计算、预过滤、后过滤和合成共五个 Pass,在移动端很贵;Gaussian 便宜得多。另外两种模式在 SystemInfo.graphicsShaderLevel < 35 的设备上不可用(IsActive 中的判断)。过场动画用 Bokeh、游戏过程中用 Gaussian 或关闭,是常见的折中。
27.21 Motion Blur
参数:Mode(Camera Only / Camera And Objects)、Quality(Low / Medium / High)、Intensity(0~1)、Clamp(0~0.2,相机旋转造成的速度相对屏幕尺寸的上限)。URP 14 的实现只考虑相机运动:CameraMotionBlur.shader 用深度重建每个像素的世界坐标,分别乘以本帧和上一帧的 VP 矩阵,两者在屏幕上的差就是这个像素的速度,再沿速度方向前后采样(Low/Medium/High 分别为 2×2、3×2、4×2 次)求平均。
// 摘自 URP 14 Shaders/PostProcessing/CameraMotionBlur.shader(GetCameraVelocity 节选) float4 worldPos = float4(ComputeWorldSpacePosition(uv.xy, depth, UNITY_MATRIX_I_VP), 1.0); float4 prevClipPos = mul(_PrevViewProjM, worldPos); float4 curClipPos = mul(_ViewProjM, worldPos); half2 velocity = (prevClipPos.xy / prevClipPos.w - curClipPos.xy / curClipPos.w); return ClampVelocity(velocity, _Clamp);
源码里有三个容易踩的坑:一,MotionBlur.IsActive() 是 intensity > 0 && mode == CameraOnly,也就是说在 URP 14 里把 Mode 切到 Camera And Objects,运动模糊反而完全关闭;二,PostProcessPass 里有一行 useMotionBlur = useMotionBlur && Application.isPlaying,编辑模式下(包括不进入 Play 的 Game 视图)永远看不到运动模糊;三,上一帧矩阵只在 Time.frameCount 变化时更新,同一帧里多次渲染同一相机(例如手动调用 Camera.Render)时速度为 0。由于第二点,本节的配图无法直接用 Volume 渲染,改用一个 Renderer Feature 调用 URP 自带的同一个 CameraMotionBlur.shader,传入模拟的上一帧矩阵,参数与 Volume 一致:
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { ref var cameraData = ref renderingData.cameraData; var vp = GL.GetGPUProjectionMatrix(cameraData.GetProjectionMatrix(), true) * cameraData.GetViewMatrix(); m_Material.SetMatrix(s_ViewProjM, vp); m_Material.SetMatrix(s_PrevViewProjM, m_Owner.previousViewProj); m_Material.SetFloat(s_Intensity, m_Owner.intensity); m_Material.SetFloat(s_Clamp, m_Owner.clamp); var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, m_Sampler)) { var d = cameraData.cameraTargetDescriptor; cmd.SetGlobalVector(s_SourceSize, new Vector4(d.width, d.height, 1f / d.width, 1f / d.height)); Blitter.BlitCameraTexture(cmd, source, m_Temp, m_Material, m_Owner.quality); Blitter.BlitCameraTexture(cmd, m_Temp, source); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }
// 27.17 节配图用的 MotionBlurPreviewFeature(对应 Ch27/MotionBlurPreviewFeature.cs)。 // 直接调用 URP 自带的 Hidden/Universal Render Pipeline/CameraMotionBlur(网页版见 shaders/URP/CameraMotionBlur.glsl), // “上一帧的 VP 矩阵”由外部指定(导出数据里的 previousViewProj)。 import { RenderPassEvent } from '../engine/features.js'; import { cameraMotionBlur } from '../engine/post/extra.js'; export default class MotionBlurPreviewFeature { constructor(engine, data) { this.engine = engine; this.data = { intensity: 0.5, clamp: 0.05, quality: 2, ...(data || {}) }; // C# 里是 GL.GetGPUProjectionMatrix(P, true) * V:渲染到贴图时 y 被翻转(D3D/Metal 约定)。 // WebGL 不翻转,所以把第 2 行取反还原;z 行不影响屏幕速度,可以不管。 const e = this.data.previousViewProj; const m = new Float32Array(16); if (e && e.e00 !== undefined) { for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) m[c * 4 + r] = e[`e${r}${c}`]; for (let c = 0; c < 4; c++) m[c * 4 + 1] = -m[c * 4 + 1]; } else m.set([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); this.prevViewProj = m; } addPasses(frame, enqueue) { const s = this.data; if (!(s.intensity > 0)) return; // 与 URP 一样:在后处理(色调映射)之前,作用于 HDR 颜色 enqueue(RenderPassEvent.BeforeRenderingPostProcessing, (fr) => { const temp = cameraMotionBlur(this.engine, fr, fr.cameraColorTexture(), { viewProj: fr.viewProj, prevViewProj: this.prevViewProj, intensity: s.intensity, clamp: s.clamp, quality: s.quality, depth: fr.cameraDepthTexture(), }); fr.blit(temp.texture, 'camera'); fr.bindCamera(); }, 'MotionBlurPreview'); } }

由于只有相机速度,运动模糊对“相机快速转动”的游戏(第一人称、竞速)最有价值;角色自身的高速动作不会模糊。移动端一般不开。如果项目确实需要物体运动模糊,URP 14 需要自己基于运动矢量(TAA 会生成 _MotionVectorTexture)实现,或者升级到更高版本的 URP。
27.22 运行时用脚本控制后处理
案例 27.1受击反馈与低血量去色入门单独打开 ↗
目标:游戏逻辑调用 OnHit() 时,屏幕边缘闪一下暗红色暗角并带一点色差,随后逐渐恢复;血量低于 30% 时画面逐渐去色。所有效果都放在一个专用的高优先级全局 Volume 上,和场景的美术 Volume 互不干扰。
void OnEnable() { m_Volume = GetComponent<Volume>(); // profile(而不是 sharedProfile)会在第一次访问时克隆一份运行时实例,改它不会污染磁盘上的资源 var profile = m_Volume.profile; m_Vignette = GetOrAdd<Vignette>(profile); m_CA = GetOrAdd<ChromaticAberration>(profile); m_Color = GetOrAdd<ColorAdjustments>(profile); } static T GetOrAdd<T>(VolumeProfile profile) where T : VolumeComponent { if (!profile.TryGet(out T comp)) comp = profile.Add<T>(); comp.active = true; return comp; }
void Update() { m_Hit = Mathf.Max(0f, m_Hit - hitDecay * Time.deltaTime); // Override(x) 同时做两件事:overrideState = true,value = x。 // 只改 .value 而 overrideState 为 false 时,这个参数不参与混合,改了等于没改。 m_Vignette.color.Override(hitColor); m_Vignette.intensity.Override(Mathf.Lerp(0.2f, 0.55f, m_Hit)); m_Vignette.smoothness.Override(0.6f); m_CA.intensity.Override(m_Hit * 0.8f); // 低血量:血量低于 30% 后逐渐去色 float lowHp = Mathf.InverseLerp(0.3f, 0f, health); m_Color.saturation.Override(Mathf.Lerp(0f, -80f, lowHp)); }
几个关键点:volume.profile 会克隆一份运行时 Profile,修改它不会弄脏资源文件,但要记得在销毁时释放;修改参数用 Override(value),它同时把 overrideState 设为 true——只赋 .value 而没勾 override 的参数不参与混合,这是“脚本改了没效果”的头号原因;TryGet 找不到组件时用 Add 添加,并确认 active 为 true。
如果想知道所有 Volume 混合之后的最终值(例如让 UI 随 Bloom 强度变化),读 VolumeManager.instance.stack:
默认栈由正在渲染的相机每帧更新,多相机时要注意读取时机;也可以用 VolumeManager.instance.CreateStack() 建立自己的栈,再 VolumeManager.instance.Update(stack, trigger, layerMask) 手动更新。URP Asset 的 Volume Update Mode 设为 Via Scripting 时,相机不会每帧自动更新 Volume,需要调用 UniversalAdditionalCameraData.UpdateVolumeStack(),在 Volume 很多、参数很少变化的项目里可以省下 CPU。
27.23 组合、相机栈与移动端建议

相机栈:使用 Base + Overlay 相机栈时,后处理只在栈中最后一个开启了 Post Processing 的相机上执行一次,作用于整个栈的合成结果(Overlay 相机上的设置决定是否参与);Overlay 相机通常用来画 UI 或第一人称武器,如果不想让武器被景深模糊,需要把景深放在只影响 Base 的流程里或调整 Volume Mask。URP 14 中 Base 相机和 Overlay 相机各自的 Post Processing 勾选与 Volume Mask 会影响最终使用的 Volume 设置,以实际版本为准做验证。
抗锯齿:相机上的 Anti-aliasing(FXAA、SMAA、TAA)属于后处理流程的一部分。SMAA 在景深之前,FXAA 在 Final Post 里;开了 FXAA 后颗粒和抖动会挪到 Final Post 里执行。MSAA 则在 URP Asset 上设置,不属于后处理。
移动端:
| 效果 | 开销 | 建议 |
|---|---|---|
| 调色全家(LUT)、Tonemapping、Vignette、色差、颗粒、畸变 | 合并在 Uber 一个 Pass | 放心使用;LUT size 32 即可 |
| Bloom | 多级半/四分之一分辨率读写 | Downscale Quarter、关 HQ、迭代 4~5 级 |
| Depth of Field | Gaussian 中、Bokeh 高 | 只用 Gaussian 或仅过场使用 Bokeh |
| Motion Blur、Panini | 各一个全屏 Pass,不兼容 tile | 一般不开 |
| HDR | 颜色缓冲变成 R11G11B10 或 RGBA16F,带宽增加 | 低端机可关闭 HDR 并使用 LDR Grading |
只要开启了后处理,就至少有一次全屏的 Uber Pass,以及“渲染到中间纹理再拷贝到屏幕”的额外带宽。在极低端设备上,最省的方案是完全关闭后处理,把调色烘进材质或用一张 LUT 在最终的 UI 合成阶段处理。另一方面,Uber 的设计让“多开一个调色效果”几乎免费,所以在中高端设备上不必吝惜调色,真正需要权衡的是 Bloom、景深这些多 Pass 的效果。
本章小结
- Volume 参数要勾选 override 才参与混合;混合系数 = 权重 ×(局部 Volume 的 1 − d²/blendDistance²),数值参数线性插值,枚举/布尔/纹理在 t > 0 时直接替换,优先级高的后叠加。
- 后处理顺序:景深 → (TAA) → 运动模糊 → Panini → Uber(畸变、色差、Bloom、暗角、调色查表、颗粒、抖动)→ Final Post。
- Bloom 用软阈值提取超出阈值的能量,Scatter 是上采样时的 lerp 比例;它工作在色调映射之前的 HDR 数据上,渲染到 RT 时要保证 RT 是 HDR 格式。
- 九个调色效果都烘进一张 LUT,顺序为白平衡 → 对比度 → 颜色滤镜 → 分离色调 → 通道混合 → SMH → LGG → HSV 曲线与饱和度 → YRGB 曲线 → 色调映射;Post Exposure 和用户 LUT 在 Uber 里。
- Color Lookup 贴图必须是 (N²×N)、非 sRGB,N 等于 URP Asset 的 LUT size。
- URP 14 的 Motion Blur 只有相机运动,Camera And Objects 模式下反而关闭,且编辑模式不渲染。
- 运行时修改 Volume 用
volume.profile+Override();读最终值用VolumeManager.instance.stack。
练习
- 写一个编辑器工具,生成中性 LUT(1024×32,非 sRGB)并保存为 PNG,按 27.14 节的流程在调色软件里调一个“夜景”风格,再导回 Unity 验证。
- 在场景里放一个带 Box Collider(Is Trigger)的局部 Volume,Blend Distance 设为 5,里面只开去饱和;让相机从外面走进去,记录 Saturation 的最终值随距离的变化,验证 d² 衰减。
- 把 Lens Distortion 的 X Multiplier 设为 0、只保留 Y 方向畸变,配合轻微的色差和暗角,做一个“透过玻璃瓶看”的效果。
- 阅读
LutBuilderLdr.shader,对比它与 HDR 版本在处理顺序上的不同,并在 LDR Grading 模式下重拍图 27-10,观察 Contrast 的结果有何差异。 - 修改
PostFxController,让受击时的暗角颜色随伤害类型变化(火焰橙、毒素绿),并用VolumeManager.instance.stack读回最终的 Vignette 强度显示在 UI 上。