0%

【Unity Shader】(七)基础屏幕特效

屏幕后处理效果(screen post-processing effects)是游戏中实现屏幕特效的常见方法。在本章中,我们将学习如何在 Unity 中利用渲染纹理来实现各种常见的屏幕后处理效果。

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

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

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

MonoBehaviour.OnRenderimage (RenderTexture src, RenderTexture dest)

当我们在脚本中声明此函数后,Unity 会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上。在 OnReoderlmage 函数中,通常是利用 Grapbics.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 。

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

因此,要在 Unity 中实现屏幕后处理效果,过程通常如下:我们首先需要在摄像中添加一个用于屏幕后处理的脚本。在这个脚本中,我们会实现 OnRenderlmage 函数来获取当前屏幕的渲染纹理。然后,再调用 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
58
59
60
61
62
using UnityEngine;
using System.Collections;

// 所有屏幕后处理效果都需要绑定在某个摄像机上
// 并且我们希望在编辑器状态下也可以执行该脚本来查看效果
[ExecuteInEditMode]
[RequireComponent (typeof(Camera))]
public class PostEffectsBase : MonoBehaviour {

// 检查资源和条件是否满足,在start函数中调用
protected void CheckResources() {
bool isSupported = CheckSupport();

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

// 检查平台是否支持渲染纹理和屏幕后处理
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;
}

// 不支持时调用
protected void NotSupported() {
enabled = false;
}

protected void Start() {
CheckResources();
}

// 由于每个屏幕后处理效果通常都需要指定一个 Shader 来创建一个用于处理渲染纹理的材质
// 因此基类中也需要提供这样的方法
// 第一个参数指定了该特效需要使用的Shader,第二个参数则是用于后期处理的材质
// 该函数检查shader可用性,shader可用则返回一个使用了该shader的材质
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;
}
}
}

后面我们就可以通过继承这个基类来实现一些屏幕后处理效果。

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

首先来编写 C# 脚本,继承上面的基类:

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
using UnityEngine;
using System.Collections;

// 继承基类
public class BrightnessSaturationAndContrast : PostEffectsBase {

// 声明该效果需要使用的shader,并创建相应的材质
public Shader briSatConShader;
private Material briSatConMaterial;
public Material material {
get {
briSatConMaterial = CheckShaderAndCreateMaterial(briSatConShader, briSatConMaterial);
return briSatConMaterial;
}
}

// 调整亮度、饱和度、对比度的参数,Range可以指定参数的变化区间
[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;

// 定义OnRenderImage来实现屏幕特效
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);
}
}
}

然后来编写 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
Shader "Unity Shaders Book/Chapter 12/Brightness Saturation And Contrast" {
Properties {
// 这里的纹理就是脚本中Blit函数的第一个参数传入的纹理
_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;
};

// 使用了Unity内置的appdata_img 结构体作为顶点着色器的输入
// 它只包含了图像处理时必需的顶点坐标和纹理坐标等变量
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);

// 调整亮度
fixed3 finalColor = renderTex.rgb * _Brightness;

// 调整饱和度
// 先得到该像素的亮度值,用每个颜色分量乘以一个特定的系数得到
fixed luminance = 0.2125 * renderTex.r + 0.7154 * renderTex.g + 0.0721 * renderTex.b;
// 使用该亮度值创建一个饱和度为 0 的颜色值
fixed3 luminanceColor = fixed3(luminance, luminance, luminance);
// 使用_Saturation属性在其和上一步得到的颜色颜色之间进行插值从而得到希望的饱和度颜色
finalColor = lerp(luminanceColor, finalColor, _Saturation);

// 先创建一个对比度为0的颜色
fixed3 avgColor = fixed3(0.5, 0.5, 0.5);
// 用_Contrast属性在其和上一步得到的颜色颜色之间进行插值从而得到希望的对比度颜色
finalColor = lerp(avgColor, finalColor, _Contrast);

return fixed4(finalColor, renderTex.a);
}

ENDCG
}
}

Fallback Off
}

现在返回 Unity 中,将 cs 脚本赋给摄像机,然后在摄像机属性面板中的脚本组件中将上面的 Shader 赋给 Bri Sat Con Shader 属性:

image-20220407170634205

然后调整各个参数就可以调整屏幕效果,原图如下:

image-20220407170806951

调整部分参数后:

image-20220407170829444

3 边缘检测

边缘检测是一个常见的屏幕后处理效果,用于实现描边效果。常用的边缘检测算子有:

image-20220407171016814

在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值 $G_x$ 和 $G_y$,而整体的梯度可按下面的公式计算而得:
$$
G = \sqrt{G_x^2 + G_y^2}
$$
由于上述计算包含了开根号操作,出于性能的考虑,我们有时会使用绝对值操作来代替开根操作:
$$
G = |G_x| + |G_y|
$$
当得到梯度 G 后,我们就可以据此来判断哪些像素对应了边缘。

下面我们使用 Sobel 算子进行边缘检测,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
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;
}
}

// 调整边缘线强度的参数,当值为0时,边缘会叠加到原图像上,值为1时只显示边缘
[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);
// 将src作为纹理用material对应的shader处理,结果保存到dest纹理中
Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, 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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
Shader "Unity Shaders Book/Chapter 12/Edge Detection" {
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

#include "UnityCG.cginc"

#pragma vertex vert
#pragma fragment fragSobel

sampler2D _MainTex;
// xxx_TexelSize是 Unity 内置的访问纹理纹素大小的变量
// 例如512 * 512 的纹理的纹素大小就是 1/512
// 因为卷积要对相邻纹素操作,所以要用纹素大小计算得到当前纹素相邻的纹素的位置
uniform half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;

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

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

// 计算每个像素的亮度值
fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}

// 计算当前像素的梯度值
half Sobel(v2f i) {
// Sobel算子
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;
// 滤波
for (int it = 0; it < 9; it++) {
texColor = luminance(tex2D(_MainTex, i.uv[it]));
edgeX += texColor * Gx[it];
edgeY += texColor * Gy[it];
}
// 得到最终滤波结果,这里的edge显然值越小越可能是边缘
half edge = 1 - abs(edgeX) - abs(edgeY);

return edge;
}

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);
// 利用_EdgeOnly在两者之间插值得到最终的像素值
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}

ENDCG
}
}
FallBack Off
}

_EdgeOnly 为 0 时的效果如下:

image-20220407173137539

_EdgeOnly 为 1 时的效果如下:

image-20220407173211757

4 高斯模糊

高斯模糊我们非常熟悉,但还是有一个小技巧需要说明,那就是存储高斯模糊核时不需要全部存储,只需要存储极少的权重即可,因为:

image-20220407173403469

所以一个 5 * 5 的高斯模糊核我们只需要存储 3 个权重即可。

并且为了提高性能,我们使用两个 Pass,第一个 Pass 将会使用竖直方向的一维高斯核对图像进行滤波,第二个 Pass 再使用水平方向的一维高斯核对图像进行滤波,得到最终的目标图像。同时还将利用图像缩放来进一步提高性能,并通过调整高斯滤波的应用次数来控制模糊程度。

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

// 滤波次数
[Range(0, 4)]
public int iterations = 3;

//下面两个参数都是出于性能考虑
// 模糊范围,在高斯核维数不变的情况下,模糊范围越大模糊程度越高,但过高会造成虚影
[Range(0.2f, 3.0f)]
public float blurSpread = 0.6f;
// 下采样倍数越高,需要处理的像素数越少,同时也能进一步提高模糊程度,但过大的downSample可能会使图像像素化
[Range(1, 8)]
public int downSample = 2;

/// 第一个版本,最简单的实现
// void OnRenderImage(RenderTexture src, RenderTexture dest) {
// if (material != null) {
// int rtW = src.width;
// int rtH = src.height;
// // 分配了一块与屏幕图像大小相同的缓冲区,因为有两个Pass,因此需要一个缓冲存储中间结果
// RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);
//
// // 使用Shader中的第1个Pass进行竖直方向一维高斯滤波
// Graphics.Blit(src, buffer, material, 0);
// // 使用Shader中的第2个Pass进行水平方向一维高斯滤波
// Graphics.Blit(buffer, dest, material, 1);
// // 释放缓冲区
// RenderTexture.ReleaseTemporary(buffer);
// } else {
// Graphics.Blit(src, dest);
// }
// }

/// 第二个版本,利用缩放对图像下采样,减少需要处理的像素个数
// void OnRenderImage (RenderTexture src, RenderTexture dest) {
// if (material != null) {
// // 声明缓冲区大小是进行缩放,并设置该临时纹理的滤波模式为双线性
// // 这样在调用第一个 Pass 时,我们需要处理的像素个数就是原来的几分之一
// int rtW = src.width/downSample;
// int rtH = src.height/downSample;
// RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);
// buffer.filterMode = FilterMode.Bilinear;
//
// Graphics.Blit(src, buffer, material, 0);
// Graphics.Blit(buffer, dest, material, 1);
// RenderTexture.ReleaseTemporary(buffer);
// } else {
// Graphics.Blit(src, dest);
// }
// }

/// 最终版本,还考虑了滤波次数
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);

// 垂直方向滤波
Graphics.Blit(buffer0, buffer1, material, 0);

// 缓存交替
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

// 垂直滤波
Graphics.Blit(buffer0, buffer1, material, 1);

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

Graphics.Blit(buffer0, dest);
RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src, 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
90
91
92
93
94
95
96
97
98
99
100
101
Shader "Unity Shaders Book/Chapter 12/Gaussian Blur" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader {
// CGINCLUDE包含的代码相当于头文件的功能,包含在CGINCLUDE内的代码块不需要写在任何Pass中
// 在后面的Pass中可以直接用函数名调用这当中的函数
// 由于我们要用到两个Pass ,并且它们的片元着色器代码是完全相同的,因此这样可以避免代码重复
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;

//垂直方向的五个纹素坐标,用_BlurSize控制采样距离,采样距离越远模糊越严重
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;

// 水平方向的五个纹素坐标,用_BlurSize控制采样距离,采样距离越远模糊越严重
o.uv[0] = uv;
o.uv[1] = uv + float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
o.uv[2] = uv - float2(_MainTex_TexelSize.x * 1.0, 0.0) * _BlurSize;
o.uv[3] = uv + float2(_MainTex_TexelSize.x * 2.0, 0.0) * _BlurSize;
o.uv[4] = uv - float2(_MainTex_TexelSize.x * 2.0, 0.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*2-1]).rgb * weight[it];
sum += tex2D(_MainTex, i.uv[it*2]).rgb * weight[it];
}

return fixed4(sum, 1.0);
}

ENDCG

ZTest Always Cull Off ZWrite Off

// 下面的 Pass 中直接调用上面的函数名即可
// 为Pass定义了一个名字,这使得其他Shder可以通过改名字来直接使用该Pass
// 因为高斯模糊很常用,定义名字为了方便其他Shader调用
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 "Diffuse"
}

渲染效果如下:

image-20220407205453483

5 Bloom 效果

Bloom 特效是游戏中常见的一种屏幕效果。这种特效可以模拟真实摄像机的一种图像效果,它让画面中较亮的区域“扩散”到周围的区域中,造成一种朦胧的效果。如下图:

image-20220407205959390

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
70
71
72
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;
}
}

// 和高斯模糊的参数一样
[Range(0, 4)]
public int iterations = 3;

[Range(0.2f, 3.0f)]
public float blurSpread = 0.6f;

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

// 提取较亮区域时使用的阈值大小
// 尽管在绝大多数情况下,图像的亮度值不会超过1
// 但如果我们开启了HDR,硬件会允许我们把颜色值存储在一个更高精度范围的缓冲中,此时像素的亮度值可能会超过1
// 因此,在这里我们把 luminanceThreshold 的值规定在[O, 4]范围内
[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;

// 使用Shader中第一个Pass来提取图像中的较亮区域
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);
// 使用第二个Pass进行垂直滤波
Graphics.Blit(buffer0, buffer1, material, 1);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
//使用第三个Pass进行水平滤波
Graphics.Blit(buffer0, buffer1, material, 2);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}
// 将模糊后的较亮区域设为纹理
material.SetTexture ("_Bloom", buffer0);
// 使用第四个Pass将处理后的亮部叠加到原图上
Graphics.Blit (src, dest, material, 3);

RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src, 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
90
91
92
93
94
95
96
97
98
99
100
Shader "Unity Shaders Book/Chapter 12/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);
// 用采样得到的亮度值减去_LuminanceThreshold阈值并截取到[0,1]范围内
// 这样比阈值小的部分就置为0,只剩下了较亮区域
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);
// xy存储主纹理坐标
o.uv.xy = v.texcoord;
// zw存储模糊后的亮部图像纹理坐标
o.uv.zw = v.texcoord;

// 对zw进行平台差异化处理
#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
}

// 这里可以直接使用之前的高斯模糊中的Pass
UsePass "Unity Shaders Book/Chapter 12/Gaussian Blur/GAUSSIAN_BLUR_VERTICAL"

UsePass "Unity Shaders Book/Chapter 12/Gaussian Blur/GAUSSIAN_BLUR_HORIZONTAL"

Pass {
CGPROGRAM
#pragma vertex vertBloom
#pragma fragment fragBloom

ENDCG
}
}
FallBack Off
}

原图如下:

image-20220407211732586

Bloom 效果如下:

image-20220407211752023

6 运动模糊

运动模糊是真实世界中的摄像机的一种效果。如果在摄像机曝光时,拍摄场景发生了变化,就会产生模糊的画面。运动模糊在我们的日常生活中是非常常见的,运动模糊效果可以让物体运动看起来更加真实平滑,但在计算机产生的图像中,由于不存在曝光这一物理现象,渲染出来的图像往往都棱角分明,缺少运动模糊。而在现在的许多第一人称或者赛车游戏中,运动模糊是一种必不可少的效果。

运动模糊实现的一种方法是使用累积缓存记录多张连续图像,然后取平均作为运动模糊图像,但是这种方法对于性能的消耗很大,因为想要获取多帧连续图像意味着要渲染一个场景多次。另一种广泛应用的方法是创建和使用速度缓存,这个缓存中存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。

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

为此,我们先编写一个脚本让摄像机运动:

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
using UnityEngine;
using System.Collections;

public class Translating : MonoBehaviour {

public float speed = 10.0f;
public Vector3 startPoint = Vector3.zero;
public Vector3 endPoint = Vector3.zero;
public Vector3 lookAt = Vector3.zero;
public bool pingpong = true;

private Vector3 curEndPoint = Vector3.zero;

// Use this for initialization
void Start () {
transform.position = startPoint;
curEndPoint = endPoint;
}

// Update is called once per frame
void Update () {
transform.position = Vector3.Slerp(transform.position, curEndPoint, Time.deltaTime * speed);
transform.LookAt(lookAt);
if (pingpong) {
if (Vector3.Distance(transform.position, curEndPoint) < 0.001f) {
curEndPoint = Vector3.Distance(curEndPoint, endPoint) < Vector3.Distance(curEndPoint, startPoint) ? startPoint : endPoint;
}
}
}
}

然后编写运动模糊的脚本:

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

// 定义运动模糊在混合图像时使用的混合参数
// blurAmount 的值越大,运动拖尾的效果就越明显
// 为了防止拖尾效果完全替代当前帧的渲染结果,我们把它的值截取在0.0到0.9范围内
[Range(0.0f, 0.9f)]
public float blurAmount = 0.5f;

// 定义保存之前图像叠加的结果的纹理
private RenderTexture accumulationTexture;
// 我们在该脚本不运行时,即调用 OnDisable 函数时立即销毁之前叠加的结果
// 这是因为我们希望在下一次开始应用运动模糊时重新叠加图像
void OnDisable() {
DestroyImmediate(accumulationTexture);
}

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
// 判断当前accumulationTexture是否满足条件按,不满足则创建满足条件的纹理并初始化为当前帧图像
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);
}

// 这个函数表明我们需要进行一个渲染纹理的恢复操作
// 恢复操作发生在渲染到纹理而该纹理又没有被提前清空或销毁的情况下
// 我们要用accumulationTexture混合当前图像,因此不能提前清空
accumulationTexture.MarkRestoreExpected();

// 参数传递给材质
material.SetFloat("_BlurAmount", 1.0f - blurAmount);
// 把当前屏幕图像叠加到accumulationTexture
Graphics.Blit (src, accumulationTexture, material);
// 把结果显示到屏幕上
Graphics.Blit (accumulationTexture, dest);
} else {
Graphics.Blit(src, 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
Shader "Unity Shaders Book/Chapter 12/Motion Blur" {
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;
half2 uv : TEXCOORD0;
};

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

// 采样RGB,并将其A通道设为_BlurAmount
// 以便在后面混合时可以使用它的透明通道进行混合
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

// 处理RGB通道
// 更新RGB时我们需要设置它的A通道来混合图像,但又不希望A通道的值写入渲染纹理中
Pass {
Blend SrcAlpha OneMinusSrcAlpha
ColorMask RGB

CGPROGRAM

#pragma vertex vert
#pragma fragment fragRGB

ENDCG
}

// 处理A通道,保证A通道还是原来的值
Pass {
Blend One Zero
ColorMask A

CGPROGRAM

#pragma vertex vert
#pragma fragment fragA

ENDCG
}
}
FallBack Off
}

Blur Amount 设置为 0 时的效果:

motion1

Blur Amount 设置为 1 时的效果:

motion2

明显看到了运动模糊。当然这只是一种简单的实现,当物体运动速度过快时,这种方法可能会造成单独的帧图像变得可见。之后我们会学习如何利用深度纹理重建速度来模拟运动模糊效果。

---- 本文结束 知识又增加了亿点点!----

文章版权声明 1、博客名称:LycTechStack
2、博客网址:https://lz328.github.io/LycTechStack.github.io/
3、本博客的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系博主进行删除处理。
4、本博客所有文章版权归博主所有,如需转载请标明出处。