屏幕后处理

本文最后更新于:2025年4月17日 下午

建立一个基本的屏幕后处理脚本系统

屏幕后处理,通常指的是在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效。使用这种技术,可以为游戏画面添加更多的艺术效果,例如景深(Depth of Field)、运动模糊(Motion Blur)等。

因此,想要实现屏幕后处理的基础在于得到渲染后的屏幕图像,即抓取屏幕,而 Unity 为我们提供了这样一个方便的接口——OnRenderImage 函数。它的函数声明如下:

1
MonoBehaviour.OnRenderImage(RenderTexture src, RenderTexture dest)

当我们在脚本中声明此函数后,Unity 会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上。在 OnRenderImage 函数中,我们通常是利用 Graphics.Blit 函数来完成对渲染纹理的处理。它有 3 种函数声明:

1
2
3
public static void Blit(Texture src, RenderTexture dest);
public static void Blit(Texture src, RenderTexture dest, Material mat, int pass = -1);
public static void Blit(Texture src, Material mat, int pass = -1);

其中,参数 src 对应了源纹理,在屏幕后处理技术中,这个参数通常就是当前屏幕的渲染纹理或是上一步处理后得到的渲染纹理。参数 dest 是目标渲染纹理,如果它的值为 null 就会直接将结果显示在屏幕上。参数 mat 是我们使用的材质,这个材质使用的 Unity Shader 将会进行各种屏幕后处理操作,而 src 纹理将会被传递给 Shader 中名为 _MainTex 的纹理属性。参数 pass 的默认值为 -1,表示将会依次调用 Shader 内的所有 Pass。否则,只会调用给定索引的 Pass。

在默认情况下,OnRenderImage 函数会在所有的不透明和透明的 Pass 执行完毕后被调用,以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的 Pass(即渲染队列小于等于 2500 的 Pass,内置的 Background、Geometry 和 AlphaTest 渲染队列均在此范围内)执行完毕后立即调动 OnRenderImage 函数,从而不对透明物体产生任何影响。此时,我们可以在 OnRenderImage 函数前添加 ImageEffectOpaque 属性来实现这样的目的。

要在 Unity 中实现屏幕后处理效果,过程通常如下:我们首先需要在摄像机中添加一个用于屏幕后处理的脚本。在这个脚本中,我们会实现 OnRenderImage 函数来获取当前屏幕的渲染纹理。然后,再调用 Graphics.Blit 函数使用特定的 Unity Shader 来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上。对于一些复杂的屏幕特效,我们可能需要多次调用 Graphics.Blit 函数来对上一步的输出结果进行下一步处理。

但是,在进行屏幕后处理之前,我们需要检查一系列条件是否满足,例如当前平台是否支持渲染纹理和屏幕特效,是否支持当前使用的 Unity Shader 等。为此,我们创建了一个用于屏幕后处理效果的基类,在实现各种屏幕特效时,我们只需要继承自该基类,再实现派生类中不同的操作即可。PostEffectsBase.cs 的主要代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using UnityEngine;
using System.Collections;

[ExecuteInEditMode]
[RequireComponent (typeof(Camera))]
public class PostEffectsBase : MonoBehaviour {

// Called when start
protected void CheckResources() {
bool isSupported = CheckSupport();

if (isSupported == false) {
NotSupported();
}
}

// Called in CheckResources to check support on this platform
protected bool CheckSupport() {
if (SystemInfo.supportsImageEffects == false || SystemInfo.supportsRenderTextures == false) {
Debug.LogWarning("This platform does not support image effects or render textures.");
return false;
}

return true;
}

// Called when the platform doesn't support this effect
protected void NotSupported() {
enabled = false;
}

protected void Start() {
CheckResources();
}

// Called when need to create the material used by this effect
protected Material CheckShaderAndCreateMaterial(Shader shader, Material material) {
if (shader == null) {
return null;
}

if (shader.isSupported && material && material.shader == shader)
return material;

if (!shader.isSupported) {
return null;
}
else {
material = new Material(shader);
material.hideFlags = HideFlags.DontSave;
if (material)
return material;
else
return null;
}
}
}

CheckShaderAndCreateMaterial 函数接受两个参数,第一个参数指定了该特效需要使用的 Shader,第二个参数则是用于后期处理的材质。该函数首先检查 Shader 的可用性,检查通过后就返回一个使用了该 Shader 的材质,否则返回 null。

调整屏幕的亮度、饱和度和对比度

首先编写 BrightnessSaturationAndContrast.cs 脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using UnityEngine;
using System.Collections;

public class BrightnessSaturationAndContrast : PostEffectsBase {

public Shader briSatConShader;
private Material briSatConMaterial;
public Material material {
get {
briSatConMaterial = CheckShaderAndCreateMaterial(briSatConShader, briSatConMaterial);
return briSatConMaterial;
}
}

[Range(0.0f, 3.0f)]
public float brightness = 1.0f;

[Range(0.0f, 3.0f)]
public float saturation = 1.0f;

[Range(0.0f, 3.0f)]
public float contrast = 1.0f;

void OnRenderImage(RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_Brightness", brightness);
material.SetFloat("_Saturation", saturation);
material.SetFloat("_Contrast", contrast);

Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}

每当 OnRenderImage 函数被调用时,它会检查材质是否可用。如果可用,就把参数传递给材质,再调用 Graphics.Blit 进行处理;否则,直接把原图像显示到屏幕上,不做任何处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
Shader "Custom/Chapter12/Chapter12-BrightnessSaturationAndContrast"
{

Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
// _Brightness ("Brightness", Float) = 1
// _Saturation("Saturation", Float) = 1
// _Contrast("Contrast", Float) = 1
}
SubShader {
Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

sampler2D _MainTex;
half _Brightness;
half _Saturation;
half _Contrast;

struct v2f {
float4 pos : SV_POSITION;
half2 uv: TEXCOORD0;
};

v2f vert(appdata_img v) {
v2f o;

o.pos = UnityObjectToClipPos(v.vertex);

o.uv = v.texcoord;

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed4 renderTex = tex2D(_MainTex, i.uv);

// Apply brightness
fixed3 finalColor = renderTex.rgb * _Brightness;

// Apply saturation
fixed luminance = 0.2125 * renderTex.r + 0.7154 * renderTex.g + 0.0721 * renderTex.b;
fixed3 luminanceColor = fixed3(luminance, luminance, luminance);
finalColor = lerp(luminanceColor, finalColor, _Saturation);

// Apply contrast
fixed3 avgColor = fixed3(0.5, 0.5, 0.5);
finalColor = lerp(avgColor, finalColor, _Contrast);

return fixed4(finalColor, renderTex.a);
}

ENDCG
}
}

Fallback Off
}

我们提到 Graphics.Blit(src, dest, material) 将把第一个参数传递给 Shader 中名为 _MainTex 的属性,因此我们必须声明一个名为 _MainTex 的纹理属性。除此之外,我们还声明了用于调整亮度、饱和度和对比度的属性。这些值将会由脚本传递而得。事实上,我们可以省略 Properties 中的属性声明,Properties 中声明的属性仅仅是为了显示在材质面板中,但对于屏幕特效来说,它们使用的材质都是临时创建的,我们也不需要在材质面板上调整参数,而是直接从脚本传递给 Unity Shader。

1
ZTest Always Cull Off ZWrite Off

屏幕后处理实际上是在场景中绘制了一个与屏幕同宽同高的四边形面片,为了防止它对其他物体产生影响,我们需要设置相关的渲染状态,在这里我们关闭了深度写入,是为了防止它“挡住”在其后面被渲染的物体。例如,如果当前的 OnRenderImage 函数在所有不透明的 Pass 执行完毕后立即被调用,不关闭深度写入就会影响后面透明的 Pass 的渲染。这些状态设置可以认为是用于屏幕后处理的 Shader 的“标配”。

首先,我们得到对原屏幕图像(存储在 _MainTex 中)的采样结果 renderTex。然后利用 _Brightness 属性来调整亮度。亮度的调整非常简单,我们只需要把原颜色乘以亮度系数 _Brightness 即可。然后,我们计算该像素对应的亮度值(luminance),这是通过对每个颜色乘以一个特定的系数再相加得到的。我们使用该亮度值创建了一个饱和度为 0 的颜色值,并使用 _Saturation 属性在其和上一步得到的颜色之间进行插值,从而得到希望的饱和度颜色。对比度的处理类似,我们首先创建一个对比度为 0 的颜色值(各分量均为 0.5),再使用 _Contrast 属性在其和上一步得到的颜色之间进行插值,从而得到最终的处理结果。

调整屏幕的亮度、饱和度和对比度

边缘检测

边缘检测的原理是利用一些边缘检测算子对图像进行**卷积(convolution)**操作,我们首先来了解什么是卷积。

什么是卷积

在图像处理中,卷积操作指的就是使用一个**卷积核(kernel)**对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构(例如 2 $\times$ 2、3 $\times$ 3 的方形区域),该区域内每个方格都有一个权重值。当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,如下图所示,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。

卷积核与卷积

这样的计算过程虽然简单,但可以实现很多常见的图像处理效果,例如图像模糊、边缘检测等。例如,如果我们想要对图像进行均值模糊,可以使用一个 3 $\times$ 3 的卷积核,核内每个元素的值均为 1/9。

常见的边缘检测算子

卷积操作的神奇之处在于选择的卷积核。那么,用于边缘检测的卷积核(也被称为边缘检测算子)应该是什么样子的?在回答这个问题之前,我们可以首先回想一下边到底是如何形成的。如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,我们就会认为它们之间应该有一条边界。这种相邻像素之间的差值可以用**梯度(gradient)**来表示,可以想像得到,边缘处的梯度绝对值会比较大。基于这样的理解,有几种不同的边缘检测算子被先后提出来。

3 种常见的边缘检测算子

3 种常见的边缘检测算子如上图所示,它们都包含了两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息。在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值 $G_x$ 和 $G_y$,而整体的梯度可按下面的公式计算而得:

$$
G = \sqrt{G_x^2 + G_y^2}
$$

由于上述计算包含了开根号操作,出于性能的考虑,我们有时会使用绝对值操作来代替开根号操作:

$$
G = |G_x| + |G_y|
$$

当得到梯度 $G$ 后,我们就可以据此来判断哪些像素对应了边缘(梯度值越大,越有可能是边缘点)。

实现

我们首先来编写 EdgeDetection 脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using UnityEngine;
using System.Collections;

public class EdgeDetection : PostEffectsBase {

public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material {
get {
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}

[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;

public Color edgeColor = Color.black;

public Color backgroundColor = Color.white;

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);

Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}

当 edgesOnly 值为 0 时,边缘将会叠加在原渲染图像上;当 edgesOnly 值为 1 时,则会只显示边缘,不显示原渲染图像。其中,背景颜色由 backgroundColor 指定,边缘颜色由 edgeColor 指定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
Shader "Custom/Chapter12/Chapter12-EdgeDetection"
{
Properties
{
_MainTex ("Base (RGB)", 2D) = "white" {}
_EdgeOnly ("Edge Only", Float) = 1.0
_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment fragSobel
#include "UnityCG.cginc"

sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;

struct v2f {
float4 pos : SV_POSITION;
half2 uv[9] : TEXCOORD0;
};

fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}

half Sobel(v2f i) {
const half Gx[9] = {-1, -2, -1 ,0, 0, 0, 1, 2, 1};
const half Gy[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
half texColor;
half edgeX = 0;
half edgeY = 0;
for(int it = 0; it < 9; it++){
texColor = luminance(tex2D(_MainTex, i.uv[it]));
edgeX += texColor * Gx[it];
edgeY += texColor * Gy[it];
}
half edge = 1 - abs(edgeX) - abs(edgeY);
return edge;
}

v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;

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;
}

fixed4 fragSobel(v2f i) : SV_TARGET {
half edge = Sobel(i);
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}

ENDCG
}
}
FallBack Off
}

我们声明了一个变量 _MainTex_TexelSize,xxx_TexelSize 是 Unity 为我们提供的访问 xxx 纹理对应的每个纹素的大小。由于卷积需要对相邻区域内的纹理进行采样,因此我们需要利用 _MainTex_TexelSize 来计算各个相邻区域的纹理坐标。

我们在 v2f 结构体中定义了一个维数为 9 的纹理数组,对应了使用 Sobel 算子采样时需要的 9 个邻域纹理坐标。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器到插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。

我们调用 Sobel 函数计算当前像素的梯度值 edge,并利用该值分别计算了背景为原图和纯色下的颜色值,然后利用 _EdgeOnly 在两者之间插值得到最终的像素值。Sobel 函数利用 Sobel 算子对原图进行边缘检测。在 Sobel 函数中,我们定义了水平方向和竖直方向使用的卷积核 $G_x$ 和 $G_y$。接着,我们依次对 9 个像素进行采样,计算它们的亮度值,再与卷积核 $G_x$ 和 $G_y$ 中对应的权重相乘后,叠加到各自的梯度值上。最后,我们从 1 中减去水平方向和竖直方向的梯度值的绝对值,得到 edge。edge 值越小,表明该位置越可能是一个边缘点。至此,边缘检测过程结束。

需要注意的是,本次实现的边缘检测仅仅利用了屏幕的颜色信息,而在实际应用中,物体的纹理、阴影等信息均会影响边缘检测的结果,使得结果包含许多非预期的描边。为了得到更加准确的边缘信息,我们往往会在屏幕的深度纹理和法线纹理上进行边缘检测。

边缘检测结果

只显示边缘的效果

高斯模糊

模糊的实现有很多方法,例如均值模糊和中值模糊。均值模糊同样使用了卷积操作,它使用的卷积核中的各个元素值都相等,且相加等于 1,也就是说,卷积后得到的像素值是其邻域内各个像素值的平均值。而中值模糊则是选择邻域内对所有像素排序后的中值替换掉原颜色。一个更高级的模糊方法是高斯模糊。

高斯滤波

高斯模糊同样利用了卷积计算,它使用的卷积核名为高斯核。高斯核是一个正方形大小的滤波核,其中每个元素的计算都是基于下面的高斯方程:
$$
G(x, y) = \frac{1}{2\pi\sigma^2}e^{-\frac{x^2 + y^2}{2\sigma^2}}
$$

其中,$\sigma$ 是标准方差(一般取值为 1),$x$ 和 $y$ 分别对应了当前位置到卷积核中心的整数距离。要构建一个高斯核,我们只需要计算高斯核中各个位置对应的高斯值。为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为 1。因此,高斯函数中 $e$ 前面的系数实际不会对结果有任何影响。下图显示了一个标准方差为 1 的 5 $\times$ 5 大小的高斯核。

标准方差为 1 的高斯核

高斯方程很好地模拟了邻域每个像素对当前处理像素的影响程度——距离越近,影响越大。高斯核的维数越高,模糊程度越大。使用一个 N $\times$ N 的高斯核对图像进行卷积滤波,就需要 N $\times$ N $\times$ W $\times$ H(W 和 H 分别是图像的宽和高)次纹理采样。当 N 的大小不断增加时,采样次数会变得非常巨大。幸运的是,我们可以把这个二维高斯函数拆分为两个一维函数。也就是说,我们可以使用两个一维的高斯核先后对图像进行滤波,它们得到的结果和直接使用二维高斯核是一样的,但采样次数只需要 2 $\times$ N $\times$ W $\times$ H。我们可以进一步观察到,两个一维高斯核中包含了很多重复的权重。对于一个大小为 5 的一维高斯核,我们实际只需要记录 3 个权重值即可。

我们将使用上述 5 $\times$ 5 的高斯核对原图像进行高斯模糊。我们将先后调用两个 Pass,第一个 Pass 将会使用竖直方向的一维高斯核对图像进行滤波,第二个 Pass 再使用水平方向的一维高斯核对图像进行滤波,得到最终的目标图像。在实现中,我们还将利用图像缩放来进一步提高性能,并通过调整高斯滤波的应用次数来控制模糊程度(次数越多,图像越模糊)。

实现

我们首先来编写 GaussianBlur.cs 脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
using UnityEngine;
using System.Collections;

public class GaussianBlur : PostEffectsBase {

public Shader gaussianBlurShader;
private Material gaussianBlurMaterial = null;

public Material material {
get {
gaussianBlurMaterial = CheckShaderAndCreateMaterial(gaussianBlurShader, gaussianBlurMaterial);
return gaussianBlurMaterial;
}
}

// Blur iterations - larger number means more blur.
[Range(0, 4)]
public int iterations = 3;

// Blur spread for each iteration - larger value means more blur
[Range(0.2f, 3.0f)]
public float blurSpread = 0.6f;

[Range(1, 8)]
public int downSample = 2;

/// 1st edition: just apply blur
// void OnRenderImage(RenderTexture src, RenderTexture dest) {
// if (material != null) {
// int rtW = src.width;
// int rtH = src.height;
// RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);
//
// // Render the vertical pass
// Graphics.Blit(src, buffer, material, 0);
// // Render the horizontal pass
// Graphics.Blit(buffer, dest, material, 1);
//
// RenderTexture.ReleaseTemporary(buffer);
// } else {
// Graphics.Blit(src, dest);
// }
// }

/// 2nd edition: scale the render texture
// void OnRenderImage (RenderTexture src, RenderTexture dest) {
// if (material != null) {
// int rtW = src.width/downSample;
// int rtH = src.height/downSample;
// RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);
// buffer.filterMode = FilterMode.Bilinear;
//
// // Render the vertical pass
// Graphics.Blit(src, buffer, material, 0);
// // Render the horizontal pass
// Graphics.Blit(buffer, dest, material, 1);
//
// RenderTexture.ReleaseTemporary(buffer);
// } else {
// Graphics.Blit(src, dest);
// }
// }

/// 3rd edition: use iterations for larger blur
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
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);

// Render the vertical pass
Graphics.Blit(buffer0, buffer1, material, 0);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

// Render the horizontal pass
Graphics.Blit(buffer0, buffer1, material, 1);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}

Graphics.Blit(buffer0, dest);
RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src, dest);
}
}
}

blurSpread 和 downSample 都是出于性能的考虑。在高斯核维数不变的情况下,_BlurSize 越大,模糊程度越高,但采样数却不会收到影响,但过大的 _BlurSize 值会造成虚影。而 downSample 越大,需要处理的像素数越少,同时也能进一步提高模糊程度,但过大的 downSample 可能会使图像像素化。

第一版中,我们利用 RenderTexture.GetTemporary 函数分配了一块与屏幕图像大小相同的缓冲区。这是因为,高斯模糊需要调用两个 Pass,我们需要使用一块中间缓存来存储第一个 Pass 执行完毕后得到的模糊结果。如代码所示,我们首先调用 Graphics.Blit(src, buffer, material, 0),使用 Shader 中的第一个 Pass(即使用竖直方向的一维高斯核进行滤波)对 src 进行处理,并将结果存储在了 buffer 中。然后,再调用 Graphics.Blit(buffer, dest, material, 1),使用 Shader 中的第二个 Pass(即使用水平方向的一维高斯核进行滤波)对 buffer 进行处理,返回最终的屏幕图像。最后,我们还需要调用 RenderTexture.ReleaseTemporary 来释放之前分配的缓存。

第二版中,我们在声明缓冲区的大小时,使用了小于原屏幕分辨率的尺寸,并将该临时渲染纹理的滤波模式设置为双线性。这样,在调用第一个 Pass 时,我们需要处理的像素个数就是原来的几分之一。对图像进行降采样不仅可以减少需要处理的像素的个数,提高性能,而且适当的降采样往往还可以得到更好的模糊效果。尽管 downSample 值越大,性能越好,但过大的 downSample 可能会造成图像像素化。

最后一个版本中,我们考虑了高斯模糊的迭代次数。上面的代码显示了如何利用两个临时缓存在迭代之间进行交替的过程。在迭代开始前,我们首先定义了第一个缓存 buffer0,并把 src 中的图像缩放后存储到 buffer0 中。在迭代过程中,我们又定义了第二个缓存 buffer1。在执行第一个 Pass 时,输入是 buffer0,输出是 buffer1,完毕后首先把 buffer0 释放,再把结果值 buffer1 存储到 buffer0 中,重新分配 buffer1,然后再调用第二个 Pass,重复上述过程。迭代完成后,buffer0 将存储最终的图像,我们再利用 Graphics.Blit(buffer0, dest) 把上述结果显示到屏幕上,并释放缓存。

下面,我们来实现 Shader 部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
Shader "Custom/Chapter12/Chapter12-GaussianBlur" {

Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}

SubShader {
CGINCLUDE
#include "UnityCG.cginc"

sampler2D _MainTex;
half4 _MainTex_TexelSize;
float _BlurSize;

struct v2f {
float4 pos : SV_POSITION;
half2 uv[5] : TEXCOORD0;
};

v2f vertBlurVertical(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;

o.uv[0] = uv;
o.uv[1] = uv + float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
o.uv[2] = uv - float2(0.0, _MainTex_TexelSize.y * 1.0) * _BlurSize;
o.uv[3] = uv + float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;
o.uv[4] = uv - float2(0.0, _MainTex_TexelSize.y * 2.0) * _BlurSize;
return o;
}

v2f vertBlurHorizontal(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;

o.uv[0] = uv;
o.uv[1] = uv + float2(0.0, _MainTex_TexelSize.x * 1.0) * _BlurSize;
o.uv[2] = uv - float2(0.0, _MainTex_TexelSize.x * 1.0) * _BlurSize;
o.uv[3] = uv + float2(0.0, _MainTex_TexelSize.x * 2.0) * _BlurSize;
o.uv[4] = uv - float2(0.0, _MainTex_TexelSize.x * 2.0) * _BlurSize;
return o;
}

fixed4 fragBlur(v2f i) : SV_Target {
float weight[3] = {0.4026, 0.2442, 0.0545};
fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];

for(int it = 1; it < 3; it++) {
sum += tex2D(_MainTex, i.uv[it]).rgb * weight[it];
sum += tex2D(_MainTex, i.uv[2 * it]).rgb * weight[it];
}
return fixed4(sum, 1.0);
}


ENDCG

ZTest Always Cull Off ZWrite Off

Pass {
Name "GAUSSIAN_BLUR_VERTICAL"

CGPROGRAM

#pragma vertex vertBlurVertical
#pragma fragment fragBlur

ENDCG
}

Pass {
Name "GAUSSIAN_BLUR_HORIZONTAL"

CGPROGRAM

#pragma vertex vertBlurHorizontal
#pragma fragment fragBlur

ENDCG
}
}

FallBack Off
}

我们在 SubShader 块中用 CGINCLUDE 和 ENDCG 语义来定义一系列代码,这些代码不需要包含在任何 Pass 语义块中,在使用时,我们只需要在 Pass 中直接指定需要使用的顶点着色器和片元着色器函数名即可。CGINCLUDE 类似于 C++ 中头文件的功能。由于高斯模糊需要定义两个 Pass,但它们使用的片元着色器代码是完全相同的,使用 CGINCLUDE 可以避免我们编写两个完全一样的 frag 函数。

我们利用了 5 $\times$ 5 大小的高斯核对原图像进行高斯模糊,而一个 5 $\times$ 5 的二维高斯核可以拆分成两个大小为 5 的一维高斯核,因此我们只需要计算 5 个纹理坐标即可。为此,我们在 v2f 结构体中定义了一个 5 维的纹理坐标数组。数组的第一个坐标存储了当前的采样纹理,而剩余的四个坐标则是高斯模糊中对邻域采样时使用的纹理坐标。我们还和属性 _BlurSize 相乘来控制采样距离。在高斯核维数不变的情况下,_BlurSize 越大,模糊程度越高,但采样数却不会受到影响。但过大的 _BlurSize 值会造成虚影,这可能并不是我们希望的。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。

一个 5 $\times$ 5 的二维高斯核可以拆分成两个大小为 5 的一维高斯核,并且由于它的对称性,我们只需要记录 3 个高斯权重,也就是代码中的 weight 变量。我们首先声明了各个邻域像素对应的权重 weight,然后将结果值 sum 初始化为当前的像素值乘以它的权重值。根据对称性,我们进行了两次迭代,每次迭代包含了两次纹理采样,并把像素值和权重相乘后的结果叠加到 sum 中。最后,函数返回滤波结果 sum。

我们为两个 Pass 使用了 NAME 语义定义了它们的名字。这是因为,高斯模糊是非常常见的图像处理操作,很多屏幕特效都是建立在它的基础上的,例如 Bloom 效果。为 Pass 定义名字,可以在其他 Shader 中直接通过它们的名字来使用该 Pass,而不需要再重复编写代码。

高斯模糊后的效果

Bloom 效果

Bloom 特效是游戏中常见的一种屏幕效果。这种特效可以模拟真实摄像机的一种图像效果,它让画面中较亮的区域“扩散”到周围的区域中,造成一种朦胧的效果。Bloom 的实现原理非常简单:我们首先根据与一个阈值提取出图像中的较亮区域,把它存储在一张渲染纹理中,再利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果,最后再将其和原图像进行混合,得到最终的效果。

我们首先来编写 Bloom.cs 脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
using UnityEngine;
using System.Collections;

public class Bloom : PostEffectsBase {

public Shader bloomShader;
private Material bloomMaterial = null;
public Material material {
get {
bloomMaterial = CheckShaderAndCreateMaterial(bloomShader, bloomMaterial);
return bloomMaterial;
}
}

// Blur iterations - larger number means more blur.
[Range(0, 4)]
public int iterations = 3;

// Blur spread for each iteration - larger value means more blur
[Range(0.2f, 3.0f)]
public float blurSpread = 0.6f;

[Range(1, 8)]
public int downSample = 2;

[Range(0.0f, 4.0f)]
public float luminanceThreshold = 0.6f;

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_LuminanceThreshold", luminanceThreshold);

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, material, 0);

for (int i = 0; i < iterations; i++) {
material.SetFloat("_BlurSize", 1.0f + i * blurSpread);

RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

// Render the vertical pass
Graphics.Blit(buffer0, buffer1, material, 1);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

// Render the horizontal pass
Graphics.Blit(buffer0, buffer1, material, 2);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}

material.SetTexture ("_Bloom", buffer0);
Graphics.Blit (src, dest, material, 3);

RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src, dest);
}
}
}

由于 Bloom 效果是建立在高斯模糊的基础上的,因此脚本中提供的参数和高斯模糊中的几乎完全一样,我们只增加了一个新的参数 luminanceThreshold 来控制提取较亮区域时使用的阈值大小;尽管在绝大多数情况下,图像的亮度不会超过 1,但如果我们开启了 HDR,硬件会允许我们把颜色值存储在一个更高精度范围的缓冲中,此时像素的亮度值可能会超过 1。因此,在这里我们把 luminanceThreshold 的值规定在 [0, 4] 范围内。

Bloom 效果需要 3 个步骤:首先,提取图像中较亮的区域,因此,我们没有直接对 src 进行降采样,而是通过调用 Graphics.Blit(src, buffer0, material, 0) 来使用 Shader 中的第一个 Pass 提取图像中较亮区域,提取到的较亮区域将存储在 buffer0 中。然后,我们进行高斯模糊迭代处理,这些 Pass 对应了 Shader 的第二个和第三个 Pass。模糊后的较亮区域将会存储在 buffer0 中,此时,我们再把 buffer0 传递给材质中的 _Bloom 纹理属性,并调用 Graphics.Blit(src, dest, material, 3) 使用 Shader 中的第四个 Pass 来进行最后的混合,将结果存储在目标渲染纹理 dest 中。最后,释放临时缓存。

下面我们来实现 Shader 的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
Shader "Custom/Chapter12/Chapter12-Bloom" {

Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Bloom ("Bloom (RGB)", 2D) = "black" {}
_LuminanceThreshold ("Luminance Threshold", Float) = 0.5
_BlurSize ("Blur Size", Float) = 1.0
}

SubShader {
CGINCLUDE
#include "UnityCG.cginc"

sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _Bloom;
float _LuminanceThreshold;
float _BlurSize;

struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
};

v2f vertExtractBright (appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}

fixed luminance (fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}

fixed4 fragExtractBright (v2f i) : SV_Target {
fixed4 c = tex2D(_MainTex, i.uv);
fixed val = clamp(luminance(c) - _LuminanceThreshold, 0.0, 1.0);
return c * val;
}

struct v2fBloom {
float4 pos : SV_POSITION;
half4 uv : TEXCOORD0;
};

v2fBloom vertBloom (appdata_img v) {
v2fBloom o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord;
o.uv.zw = v.texcoord;

#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y < 0.0)
o.uv.w = 1.0 - o.uv.w;
#endif

return o;
}

fixed4 fragBloom (v2fBloom i) : SV_TARGET {
return tex2D(_MainTex, i.uv.xy) + tex2D(_Bloom, i.uv.zw);
}

ENDCG

ZTest Always Cull Off ZWrite Off

Pass {

CGPROGRAM
#pragma vertex vertExtractBright
#pragma fragment fragExtractBright
ENDCG
}

UsePass "Custom/Chapter12/Chapter12-GaussianBlur/GAUSSIAN_BLUR_VERTICAL"
UsePass "Custom/Chapter12/Chapter12-GaussianBlur/GAUSSIAN_BLUR_HORIZONTAL"

Pass {
CGPROGRAM
#pragma vertex vertBloom
#pragma fragment fragBloom
ENDCG
}
}

FallBack Off
}

这里使用的顶点着色器与之前的有所不同,我们定义了两个纹理坐标,并存储在同一个类型为 half4 的变量 uv 中。它的 xy 分量对应了 _MainTex,即原图像的纹理坐标。而它的 zw 分量是 _Bloom,即模糊后的较亮区域的纹理坐标。我们需要对这个纹理坐标进行平台差异化处理。第二个和第三个 Pass 我们直接使用了上面高斯模糊中定义的两个 Pass,这是通过 UsePass 语义指明它们的 Pass 名来实现的。需要注意的是,由于 Unity 内部会把所有 Pass 的 Name 转换成大写字母表示,因此在使用 UsePass 命令时我们必须使用大写形式的名字。

Bloom 效果

运动模糊

运动模糊是真实世界中的摄像机的一种效果。如果在摄像机曝光时,拍摄场景发生了变化,就会产生模糊的画面。运动模糊在我们的日常生活中是非常常见的,只要留心观察,就可以发现无论是体育报道还是各个电影里,都有运动模糊的身影。运动模糊效果可以让物体运动看起来更加真实平滑,但在计算机产生的图像中,由于不存在曝光这一物理现象,渲染出来的图像往往都是棱角分明,缺少运动模糊。在一些诸如赛车类型的游戏中,为画面添加运动模糊是一种常见的处理方法。

运动模糊的实现有多种方法。一种实现方法是利用一块积累缓存(accumulation buffer)来混合多张连续的图像。当物体快速移动产生多张图像后,我们取它们之间的平均值作为最后的运动模糊图像。然而,这种暴力的方法对性能的消耗很大,因为想要获取多张帧图像往往意味着我们需要在同一帧里渲染多次场景。另一种应用广泛的方法是创建和使用速度缓存(velocity buffer),这个缓存中存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。

在本次实现中,我们将使用类似上述第一种方法来模拟运动模糊的效果。我们不需要在一帧中把场景渲染多次,但需要保存之前的渲染结果,不断把当前的渲染图像叠加到之前的渲染图像中,从而产生一种运动轨迹的视觉效果。这种方法与原始的利用累计缓存的方法相比性能更好,但模糊效果可能会略有影响。

我们首先来编写 MotionBlur.cs 脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using UnityEngine;
using System.Collections;

public class MotionBlur : PostEffectsBase {

public Shader motionBlurShader;
private Material motionBlurMaterial = null;

public Material material {
get {
motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
return motionBlurMaterial;
}
}

[Range(0.0f, 0.9f)]
public float blurAmount = 0.5f;

private RenderTexture accumulationTexture;

void OnDisable() {
DestroyImmediate(accumulationTexture);
}

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
// Create the accumulation texture
if (accumulationTexture == null || accumulationTexture.width != src.width || accumulationTexture.height != src.height) {
DestroyImmediate(accumulationTexture);
accumulationTexture = new RenderTexture(src.width, src.height, 0);
accumulationTexture.hideFlags = HideFlags.HideAndDontSave;
Graphics.Blit(src, accumulationTexture);
}

// We are accumulating motion over frames without clear/discard
// by design, so silence any performance warnings from Unity
accumulationTexture.MarkRestoreExpected();

material.SetFloat("_BlurAmount", 1.0f - blurAmount);

Graphics.Blit (src, accumulationTexture, material);
Graphics.Blit (accumulationTexture, dest);
} else {
Graphics.Blit(src, dest);
}
}
}

blurAmount 的值越大,运动拖尾的效果就越明显,为了防止拖尾效果完全替代当前帧的渲染结果,我们把它的值截取在 0.0~0.9 范围内。我们在脚本不运行时,即调用 OnDisable 函数时,立即销毁 accumulationTexture。这是因为,我们希望在下一次开始应用运动模糊时重新叠加图像。在确认材质可用后,我们首先判断用于混合图像的 accumulationTexture 是否满足条件。我们不仅判断它是否为空,还判断它是否与当前的屏幕分辨率相等,如果不满足,就说明我们需要重新创建一个适合于当前分辨率的 accumulationTexture 变量。创建完毕后,由于我们会自己控制该变量的销毁,因此可以把它的 hideFlags 设置为 HideFlags.HideAndDontSave,这意味着这个变量不会显示在 Hierarchy 中,也不会保存到场景中。然后,我们使用当前的帧图像初始化 accumulationTexture(使用 Graphics.Blit(src, accumulationTexture) 代码)。

当得到了有效的 accumulationTexture 变量后,我们调用了 accumulationTexture.MarkRestoreExpected 函数来表明我们需要进行一个渲染纹理的恢复操作。**恢复操作(restore operation)**发生在渲染到纹理而该纹理又没有被提前清空或销毁的情况下。在本例中,我们每次调用 OnRenderImage 时都需要把当前的帧图像和 accumulationTexture 中的图像混合,accumulationTexture 纹理不需要提前清空,因为它保存了我们之前的混合结果。然后,我们将参数传递给材质,并调用 Graphics.Blit(src, accumulationTexture, material) 把当前的屏幕图像 src 叠加到 accumulationTexture 中。最后使用 Graphics.Blit(accumulationTexture, dest) 把结果显示到屏幕上。

下面,我们来实现 Shader 部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
Shader "Custom/Chapter12/Chapter12-MotionBlur" {

Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_BlurAmount ("Blur Amount", Float) = 1.0
}

SubShader {
CGINCLUDE
#include "UnityCG.cginc"

sampler2D _MainTex;
fixed _BlurAmount;

struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};

v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}

fixed4 fragRGB (v2f i) : SV_Target {
return fixed4(tex2D(_MainTex, i.uv).rgb, _BlurAmount);
}

half4 fragA (v2f i) : SV_Target {
return tex2D(_MainTex, i.uv);
}

ENDCG

ZTest Always Cull Off ZWrite Off

Pass {
Blend SrcAlpha OneMinusSrcAlpha

ColorMask RGB

CGPROGRAM
#pragma vertex vert
#pragma fragment fragRGB
ENDCG
}

Pass {
Blend One Zero
ColorMask A
CGPROGRAM
#pragma vertex vert
#pragma fragment fragA
ENDCG
}
}

FallBack Off
}

RGB 通道版本的片元着色器对当前图像进行采样,并将其 A 通道的值设为 _BlurAmount,以便在后面混合时可以使用它的透明通道进行混合。A 通道版本的代码就更简单了,直接返回采样结果。实际上,这个版本只是为了维护渲染纹理的透明通道值,不让其受到混合时使用的透明度值的影响。然后,我们定义了运动模糊所需的 Pass。在本例中我们需要两个 Pass,一个用于更新渲染纹理的 RGB 通道,第一个用于更新 A 通道。之所以要把 A 通道和 RGB 通道分开,是因为在更新 RGB 时我们需要设置它的 A 通道来混合图像,但又不希望 A 通道的值写入渲染纹理中。

本次是对运动模糊的一种简单实现。我们混合了连续帧之间的图像,这样得到一张具有模糊拖尾的图像。然而,当物体运动速度过快时,这种方法可能会造成单独的帧图像变得可见。以后我们会学习如何利用深度纹理重建速度来模拟运动的模糊效果。

运动模糊效果


屏幕后处理
http://example.com/posts/屏幕后处理/
作者
祭零小白
发布于
2022年3月4日
许可协议