本篇主要写一下关于屏幕后处理的一些实现方式和原理。本篇中的实现原理是基于 OnRenderImage 函数的实现,这个函数只有在 Built-in 渲染管线下才能生效,在 URP 中需要做其它的操作以适配,但原理都是相同的。所以我们先通过 Built-in 引入,在最后的部分我们再考虑升级管线的问题。
全屏 Shader
在 Unity Built-In 渲染管线下,创建一个 Image Effect Shader。这个 Shader 通常用来做全屏特效,并且会处于 Hidden 的分支下,所以在材质球上并不能直接选择。
对于后期处理而言,很多时候我们并不需要特别地处理顶点。因此,对于顶点着色器,我们可以直接使用内置的 vert_img。在片元着色器中,我们也对应地使用 v2f_img 这个内置结构,而不是传统的 v2f。
Plain TextShader "Hidden/PPSomeEffect" {
Properties { } // Some Properties omitted
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert_img
#pragma fragment frag
#include "UnityCG.cginc"
// Properties
fixed4 frag (v2f_img i) : SV_Target { return col; }
}
}
在片元着色器中,因为后期处理无论如何都是针对屏幕中已经输出的结果进行后期修改,所以第一步总是针对 _MainTex 的采样。
Plain Textfixed4 frag (v2f_img i) : SV_Target {
fixed4 col = tex2D(_MainTex, i.uv);
// Some manipulation on col
return col;
}
用 C# 驱动它
Shader 自己什么都不会发生。挂在相机上的组件通过 OnRenderImage 拿到已渲染的这一帧(src),并负责把结果写进 dest:
C#[ExecuteInEditMode, RequireComponent(typeof(Camera))]
public class BrightnessEffect : MonoBehaviour
{
public Shader shader;
[Range(0f, 3f)] public float brightness = 1f;
private Material material;
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if (shader == null || !shader.isSupported) { Graphics.Blit(src, dest); return; }
if (material == null) material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave };
material.SetFloat("_Brightness", brightness);
Graphics.Blit(src, dest, material);
}
}
Graphics.Blit 会往 dest 里画一个全屏四边形,把 src 绑定成 _MainTex,然后对每个像素跑一遍我们的 Shader。注意那个 fallback:Shader 不被支持时仍然要把 src blit 到 dest,否则屏幕会直接黑掉。
亮度 / 饱和度 / 对比度
首先是最简单的三个特效。
亮度
亮度(Brightness)。根据亮度的定义,我们简单地将最终输出的颜色的 RGB 值去乘上一个亮度系数即可。
Plain Textfixed3 finalColor = col.rgb * _Brightness;
饱和度
饱和度(Saturation)。饱和度在最低的时候,颜色要接近直接的灰度颜色。在高的时候,则要更鲜艳。
首先,如果要将一个带有颜色的图去色(Desaturate)或者说变成灰度图,我们可以用下面的公式获得这个灰色。
Plain Textfixed lum = 0.2125 * col.r + 0.7154 * col.g + 0.0721 * col.b;
fixed lumColor = fixed3 (lum, lum, lum);
其中的 0.2125R, 0.7154G, 0.0721B 是一个经验公式,研究表明当RGB以这样的比例被结算成灰色的时候人眼觉得这个灰色最合理。
然后,我们再根据这个 lumColor 进行线性插值即可。
Plain Textfixed3 finalColor = lerp(lumColor, col, _Saturation);
注:
回忆一下线性插值的计算方式。
Plain Text//require 0 <= delta <= 1
lerp(A,B, delta) = (1-delta) * A + delta * B
所以,当 _Saturation 在 0 与 1 之间时,越接近 0,颜色就越接近 lumColor,也就是我们的灰度图;越接近 1 ,就越接近 col,也就是我们原本采样出来的颜色,不过,在 Unity 中,delta 大于 1的时候 lerp 也会有对应的数值,具体的计算这边不展示,在 Saturation 的情况中,我们可以大概理解为 _Saturation 越高,颜色就越鲜艳。越接近0,颜色就越灰。
对比度
对比度(Contrast)。当对比度低的时候,所有颜色都无法区分,变成统一的灰色(0.5, 0.5, 0.5)。因此,对比度也是很简单的线性插值就可以解决的问题。
Plain Textfixed avgColor = fixed3 (0.5, 0.5, 0.5);
fixed3 finalColor = lerp (avgColor, col, _Contrast);
边缘检测
对于边缘检测,我们需要先了解卷积和卷积核的概念。
所谓的卷积(Convolution)本质上就是一种加权求和。以一个采样点为中心,覆盖到周围的一定区域内,根据卷积核(Convolution Kernal)定义的加权方式求得该点的数值。对图像进行一次卷积求值的过程也叫做滤波(Filter)。
例如在下图中,一个 3×3 的方阵中,对于中心点 P5,我们使用卷积核 Gy,对该点求值。
Plain TextP5 = -1 * P1 + (-2) * P2 + (-1) * P3 + 0 * P4 + 0 * P5 + 0 * P6 + 1 * P7 + 1 * P8 + 1 * P9
= (P7 - P1) + 2 * (P8 - P2) + (P9 - P3)
我们使用 Sobel 定义的 Sobel 卷积核对图像进行卷积处理,就会发现在边缘附近得到的值会偏高。边缘附近的颜色会剧烈地变化,通过这种方式也就可以找到边缘的位置。
Sobel 卷积核分为 Gx 和 Gy 两个部分,对 x 方向和 y 方向分别滤波一次,就能求得两个方向上的数值了。
那么,对于每一个像素,影响这个像素最终颜色的都会由其周围(包括自己)的九个像素影响。但是,在这里我们稍微严谨一点。由于我们是在做全屏 Shader,我们获得的 _MainTex 实际上是在做后处理之前的一张纹理贴图,因此,当我们在计算一个像素的最终颜色的时候,我们要在 _MainTex 的纹理的一个单元,也就是纹素(Texel)上去进行采样。因此,获得周围纹素的过程依然发生在顶点着色器中。
为了方便我们把这 9 个纹素的纹理坐标全部记录下来,我们在 v2f 的数据结构中,不再仅仅记录一个 uv,而是记录 9 个 uv 值,分别是这个 9 个纹素的所有纹理坐标。
Plain Textstruct v2f {
half2 uv[9] : TEXCOORD0;
float4 pos : SV_POSITION;
}
然后,我们用内置的 _MainTex_TexelSize 来提取纹素的实际位置。对于这个内置变量,_MainTex_TexelSize.x 是每个纹素的水平尺寸,而 _MainTex_TexelSize.y 是每个纹素的垂直尺寸。那么这样我们就能很轻松地获取到周围的所有纹素的 uv 值了。
Plain Textv2f vert (appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
// Obtain the uv of current vertex
half2 uv = v.texcoord;
// Fetch the uv of the surrounding 9 Texels
o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1);
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(0, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(1, -1);
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 0);
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(0, 0);
o.uv[5] = uv + _MainTex_TexelSize.xy * half2(1, 0);
o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1, 1);
o.uv[7] = uv + _MainTex_TexelSize.xy * half2(0, 1);
o.uv[8] = uv + _MainTex_TexelSize.xy * half2(1, 1);
return o;
}
这种技巧可以很有效地帮助我们在 v2f 中记录更多的信息。在这里,只有 o.uv[4] 代表了这个顶点在纹理上对应的本来的位置。然后,我们对这个纹素周围的 9 个点进行采样并记录。
边缘检测实际上就是针对灰度图再进行一次 Sobel 卷积。灰度图在上面的 Saturation 部分已经提过了。
Plain Textfixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
然后我们来进行 Sobel 卷积。
Plain Texthalf Sobel (v2f i) {
// Define Sobel Kernal
const half Gx[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
const half Gy[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
half texColor;
half edgeX = 0;
half edgeY = 0;
// Convolution sum
for (int j = 0; j < 9; j++) {
texColor = luminance(tex2D(_MainTex, i.uv[j]));
edgeX += texColor * Gx[j];
edgeY += texColor * Gy[j];
}
half edge = 1 - abs(edgeX) - abs(edgeY);
return edge;
}
此时 edge 在边缘处接近 0,其它地方接近 1,可以直接当成遮罩用——在描边色和原图之间 lerp,或者在描边色和纯色背景之间 lerp 得到纯线稿。
有两个坑值得提前知道。第一,基于亮度的 Sobel 看不见两种亮度相同、色相不同的颜色之间的边界;如果这件事重要,就改成对深度和法线做卷积,而不是颜色。第二,结果是分辨率相关的,因为卷积核是按纹素定义的——同一个场景在低分辨率下检测出的线会更粗。
高斯模糊
模糊是后处理里的主力:泛光、景深、软阴影都建立在它之上。
半径为 r 的高斯核,直接做是 (2r+1)² 次采样。但二维高斯是可分离的——它可以写成两个一维高斯的乘积——所以先横向一遍再纵向一遍,只要 2(2r+1) 次采样就能得到同样的结果。半径 3 的时候,是 49 次对 14 次。
于是 Shader 里是两个 Pass 共用一个片元函数,只有偏移方向不同:
HLSLhalf4 frag (v2f i) : SV_Target {
float weight[3] = {0.4026, 0.2442, 0.0545}; // 归一化后的 5 tap 高斯
fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];
for (int it = 1; it < 3; it++) {
sum += tex2D(_MainTex, i.uv[it * 2 - 1]).rgb * weight[it];
sum += tex2D(_MainTex, i.uv[it * 2 ]).rgb * weight[it];
}
return fixed4(sum, 1.0);
}
C# 这边在两张临时 RenderTexture 之间来回 ping-pong:
C#void OnRenderImage(RenderTexture src, RenderTexture dest)
{
int rtW = src.width / downSample;
int rtH = src.height / downSample;
RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
buffer0.filterMode = FilterMode.Bilinear;
Graphics.Blit(src, buffer0);
for (int i = 0; i < iterations; i++)
{
material.SetFloat("_BlurSize", 1.0f + i * blurSpread);
RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(buffer0, buffer1, material, 0); // 横向
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(buffer1, buffer0, material, 1); // 纵向
RenderTexture.ReleaseTemporary(buffer1);
}
Graphics.Blit(buffer0, dest);
RenderTexture.ReleaseTemporary(buffer0);
}
这里真正在省时间的是两个很便宜的技巧。downSample 让模糊在屏幕分辨率的几分之一上进行——模糊本来就是低频的,画质损失看不出来,开销却按平方下降。而双线性过滤意味着每次采样其实已经免费平均了四个纹素,这也是为什么一个 5 tap 的核看起来会比 5 个像素宽得多。
GetTemporary 一定要配对 ReleaseTemporary。忘记释放是编辑器里泄漏显存的经典方式之一。
泛光 / Bloom
Bloom 分三步:把亮的部分提取出来、模糊它、再加回去。
HLSL// pass 0: 提取亮部
fixed4 fragExtractBright(v2f_img i) : SV_Target {
fixed4 c = tex2D(_MainTex, i.uv);
fixed val = clamp(luminance(c) - _LuminanceThreshold, 0.0, 1.0);
return c * val;
}
// pass 3: 合成,_Bloom 里是模糊过的亮部
fixed4 fragBloom(v2fBloom i) : SV_Target {
return tex2D(_MainTex, i.uv.xy) + tex2D(_Bloom, i.uv.zw);
}
这里用减法阈值而不是硬 step 是有讲究的:它让像素随着变亮渐渐进入 bloom,而不是突然跳出来——后者在运动中会闪得很厉害。
关于色彩空间还有一点。在 Gamma 空间里,没法用大于 1 的阈值,因为所有值都被 clamp 了,所以 bloom 只能是一个由小于 1 的阈值驱动的风格化效果。在配合 HDR 相机目标的 Linear 空间里,数值真的会超过 1,可以用 1.0 作为阈值,物理上更有依据。同一个 Shader 在两种设置下的结果差别很明显——如果 bloom 看起来不对,先检查色彩空间,再去动参数。
迁移到 URP
URP 里没有 OnRenderImage,而且在 SRP 中直接用 Graphics.Blit 会出问题(它会忽略当前的相机目标,并且破坏 XR)。对应的做法是 Renderer Feature 加 Render Pass:
C#public class PostProcessFeature : ScriptableRendererFeature
{
class Pass : ScriptableRenderPass
{
private Material material;
private RTHandle temp;
public override void Execute(ScriptableRenderContext context, ref RenderingData data)
{
CommandBuffer cmd = CommandBufferPool.Get("Custom Post");
RTHandle source = data.cameraData.renderer.cameraColorTargetHandle;
Blitter.BlitCameraTexture(cmd, source, temp, material, 0);
Blitter.BlitCameraTexture(cmd, temp, source);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data)
=> renderer.EnqueuePass(pass);
}
实际会变的有四件事:
Graphics.Blit换成Blitter.BlitCameraTexture,RenderTexture换成RTHandle。- 同一个 Pass 里不能同时读写同一张目标,所以经临时纹理的 ping-pong 是必须的,不是优化。
renderPassEvent决定效果在一帧中的什么位置执行——透明物体之前还是之后、内置后处理栈之前还是之后。- Shader 本身要改成 HLSL 和 URP 的 include,做法和《Unity Shader #3 升级到 URP》里说的完全一样。
vert_img/v2f_img换成Blit.hlsl里的FullscreenVert。
上面所有的数学部分一点都不用改,变的只是管线接口——这基本上就是把任何东西从 Built-in 搬到 URP 时的固定主题。
