第12章 屏幕后处理效果

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/e295166319/article/details/79021317

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

在12.1 节中,我们首先会解释在Unity 中实现屏幕后处理效果的原理,并建立一个基本的屏幕后处理脚本系统。

随后在12.2 节中,我们会使用这个系统实现一个简单的调整画面亮度、饱和度和对比度的屏幕特效。

在12.3 节中,我们会接触到图像滤波的概念,并利用Sobel 算子在屏幕空间中对图像进行边缘检测, 实现描边效果。

在此基础上,12.4 节将会介绍如何实现一个高斯模糊的屏幕特效。

在12.5和12.6 节中,我们会分别介绍如何实现Bloom 和运动模糊效果。

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

屏幕后处理,顾名思义,通常指的是在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效。使用这种技术,可以为游戏画面添加更多的艺术效果,例如景深( Depth of Field )、运动模糊( Motion Blur )等。
因此,想要实现屏幕后处理的基础在于得到渲染后的屏幕图像,即抓取屏幕,而Unity 为我们提供了这样一个方便的接口一一OnRenderlmage 函数。它的函数声明如下:
	MonoBehaviour.OnRenderImage(RenderTexture src, RenderTexture dest)

当我们在脚本中声明此函数后, Unity 会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上。在OnRenderlmage 函数中,我们通常是利用Graphics.Blit函数来完成对渲染纹理的处理。它有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 函数,从而不对透明物体产生任何影响。此时,我们可以OnReoderlmage

函数前添加ImageEffectOpaque 属性来实现这样的目的。13.4 节展示了这样一个例子,在13.4 节中,我们会利用深度和法线纹理进行边缘检测从而实现描边的效果,但我们不希望透明物体也被描边。

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

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

PostEffectsBase.cs 的主要代码如下。

(1)首先,所有屏幕后处理效果都需要绑定在某个摄像机上,并且我们希望在编辑器状态下也可以执行该脚本来查看效果:

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

(2)为了提前检查各种资源和条件是否满足,我们在Start 函数中调用CheckResources 函数:

	// 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();
	}

一些屏幕特效可能需要更多的设置,例如设置一些默认值等,可以重载Start、CheckResources或CheckSupport 函数。

(3)由于每个屏幕后处理效果通常都需要指定一个Shader 来创建一个用于处理渲染纹理的材质,因此基类中也提供了这样的方法:

	// 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 。

在12.2 节中,我们就会看到如何继承PostEffectsBase.cs 来创建一个简单的用于调整屏幕的亮度、饱和度和对比度的特效脚本。

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

在12.1 节中,我们了解了实现屏幕后处理特效的技术原理。在本节中,我们就小试牛刀来实现一个非常简单的屏幕特效一一调整屏幕的亮度、饱和度和对比度。在本节结束后, 我们将得到类似图12.1 中的效果。
为此, 我们需要进行如下准备工作。
( 1 )新建一个场景。在本书资源中,该场景名为Scene_12_2 。在Unity 5.2 中, 默认情况下场景将包含一个摄像机和一个平行光, 并且使用了内置的天空盒子。在Window → Lighting → Skybox 中去掉场景中的天空盒子。
( 2) 把本书资源中的Assets/Textures/Chapter12/Sakura0.jpg 拖曳到场景中, 并调整其的位置使它可以填充整个场景。注意, Sakura0.jpg 的纹理类型己被设置为Sprite,因此可以直接拖曳到场景中。
(3)新建一个脚本。在本书资源中,该脚本名为BrightnessSaturationAndContrast.cs . 把该脚本拖曳到摄像机上。
(4)新建一个Unity Shader。在本书资源中, 该Shader 名为Chapter12-BrightnessSaturationAndContrast。
我们首先来编写BrightnessSaturationAndContrast.cs 脚本。打开该脚本, 并进行如下修改。
( 1)首先, 继承12.1 节中创建的基类:
	public class BrightnessSaturationAndContrast : PostEffectsBase {

( 2 )声明该效果需要的Shader, 并据此创建相应的材质:

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

在上述代码中, briSatConShader 是我们指定的Shader,对应了后面将会实现的Chapter12-BrightnessSaturationAndContrast。 briSatConMaterial 是创建的材质,我们提供了名为material 的材质来访问它, material 的get 函数调用了基类的CheckShaderAndCreateMaterial 函数来得到对应的材质。

(3)我们还在脚本中提供了调整亮度、饱和度和对比度的参数:

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

我们利用Unity 提供的Range 属性为每个参数提供了合适的变化区间。

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

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

下面,我们来实现Shader 的部分。打开Chapter12-BrightnessSaturationAndContrast,进行如下修改。

( 1 )我们首先需要声明本例使用的各个属性:

	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_Brightness ("Brightness", Float) = 1
		_Saturation("Saturation", Float) = 1
		_Contrast("Contrast", Float) = 1
	}

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

(2)定义用于屏幕后处理的Pass :

SubShader {
		Pass {  
			ZTest Always Cull Off ZWrite Off

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

(3 )为了在代码中访问各个属性, 我们需要在CG 代码块中声明对应的变量:

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

( 4 )定义顶点着色器。屏幕特效使用的顶点着色器代码通常都比较简单, 我们只需要进行必需的顶点变换, 更重要的是,我们需要把正确的纹理坐标传递给片元着色器, 以便对屏幕图像进行正确的来样:

	struct v2f {
		float4 pos : SV_POSITION;
		half2 uv: TEXCOORD0;
	};
			  
	v2f vert(appdata_img v) {
		v2f o;
				
		o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
		o.uv = v.texcoord;
						 
		return o;
	}

在上面的顶点着色器中,我们使用了Unity 内置的appdata_img 结构体作为顶点着色器的输入,读者可以在UnityCG.cginc 中找到该结构体的声明, 它只包含了图像处理时必需的顶点坐标和纹理坐标等变量。

( 5 )接着,我们实现了用于调整亮度、饱和度和对比度的片元着色器:

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

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

(6)最后,我们关闭该Unity Shader 的Fallback:

	Fallback Off

完成后返回编辑器,并把Chapter12-BrightnessSaturationAndContrast 拖曳到摄像机的BrightnessSaturationAndContrast.cs 脚本中的briSatConShader 参数中。调整各个参数后,我们就可以得到类似图12.1 中的效果。

在上面的实现中,我们需要手动把Shader 拖曳到脚本的参数上。为了在以后的使用中,当把脚本拖曳到摄像机上时直接使用对应的Shader ,我们可以在脚本的面板中设置Shader 参数的默认值,如图12.2 所示。

12.3 边缘检测

在12.2 节中,我们己经学习了如何实现一个简单的屏幕后处理效果。在本节中,我们会学习一个常见的屏幕后处理效果一一边缘检测。边缘检测是描边效果的一种实现方法,在本节结束后,我们可以得到类似图12.3 中的效果。
边缘检测的原理是利用一些边缘检测算子对图像进行 卷积( convolution )操作,我们首先来了解什么是卷积。

12.3.1 什么是卷积

在图像处理中,卷积操作指的就是使用一个卷积核(kernel) 对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构(例如2×2、3 ×3 的方形区域),该区域内每个方格都有一个权重值。当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,如图12.4 所示,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。
这样的计算过程虽然简单,但可以实现很多常见的图像处理效果,例如图像模糊、边缘检测等。例如,如果我们想要对图像进行均值模糊,可以使用一个3x3的卷积核,核内每个元素的值均为1/9。

12.3.2 常见的边缘检测算子

卷积操作的神奇之处在于选择的卷积核。那么,用于边缘检测的卷积核(也被称为边缘检测算子〉应该长什么样呢?在回答这个问题前,我们可以首先回想一下边到底是如何形成的。如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,我们就会认为它们之间应该有一条边界。这种相邻像素之间的差值可以用梯度( gradient )来表示,可以想象得到,边缘处的梯度绝对值会比较大。基于这样的理解,有几种不同的边缘检测算子被先后提出来。
3 种常见的边缘检测算子如图12.5 所示,它们都包含了两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息。在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值Gx 和Gy,而整体的梯度可按下面的公式计算而得:

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

12.3.3 实现

本节将会使用Sobel 算子进行边缘检测,实现描边效果。为此,我们需要进行如下准备工作。
(1)新建一个场景。在本书资源中,该场景名为Scene_12_3 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window -> Lighting -> Skybox 中去掉场景中的天空盒子。
(2)把本书资源中的Assets/Textures/Chapter12/Sakura0.jpg 拖曳到场景中,并调整它的位置使其可以填充整个场景。注意, Sakura0.jpg 的纹理类型已被设置为Sprite,因此可以直接拖曳到场景中。
(3)新建一个脚本。在本书资源中,该脚本名为EdgeDetection.cs。把该脚本拖曳到摄像机上。
(4)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter12-EdgeDetection。
我们首先来编写EdgeDetection.cs 脚本。打开该脚本,并进行如下修改。
(1) 首先,继承12.1 节中创建的基类:
public class EdgeDetection : PostEffectsBase {

(2)声明该效果需要的Shader,并据此创建相应的材质:

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

在上述代码中, edgeDetectShader 是我们指定的Shader ,对应了后面将会实现的Chapter12-EdgeDetection 。

(3)在脚本中提供用于调整边缘线强度、描边颜色以及背景颜色的参数:

	[Range(0.0f, 1.0f)]
	public float edgesOnly = 0.0f;
 
	public Color edgeColor = Color.black;
	
	public Color backgroundColor = Color.white;

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

(4)最后,我们定义OnRenderlmage 函数来进行真正的特效处理:

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

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

下面,我们来实现Shader 的部分。打开Chapter12-EdgeDetection,进行如下修改。

( 1)我们首先需要声明本例使用的各个属性:

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

_MainTex 对应了输入的渲染纹理。

(2)定义用于屏幕后处理的Pass ,设置相关的渲染状态:

	SubShader {
		Pass {  
			ZTest Always Cull Off ZWrite Off

(3)为了在代码中访问各个属性,我们需要在CG 代码块中声明对应的变量:

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

在上面的代码中,我们还声明了一个新的变量 _MainTex_TexelSize 。xxx_Texe!Size 是Unity为我们提供的访问xxx 纹理对应的每个纹素的大小。例如,一张512 × 512 大小的纹理,该值大约 为0.001 953 (即l/512 )。由于卷积需要对相邻区域内的纹理进行采样,因此我们需要利用 _MainTex_TexelSize 来计算各个相邻区域的纹理坐标。

(4)在顶点着色器的代码中,我们计算了边缘检测时需要的纹理坐标:

			struct v2f {
				float4 pos : SV_POSITION;
				half2 uv[9] : TEXCOORD0;
			};
			  
			v2f vert(appdata_img v) {
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, 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;
			}

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

( 5 )片元着色器是我们的重点, 它的代码如下:

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

我们首先调用Sobel 函数计算当前像素的梯度值edge,并利用该值分别计算了背景为原图和纯色下的颜色值,然后利用_EdgeOnly 在两者之间插值得到最终的像素值。Sobel 函数将利用Sobel 算子对原图进行边缘检测, 它的定义如下:

			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,  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];
				}
				
				half edge = 1 - abs(edgeX) - abs(edgeY);
				
				return edge;
			}

我们首先定义了水平方向和坚直方向使用的卷积核Gx 和Gy。接着,我们依次对9 个像素进行采样,计算它们的亮度值,再与卷积核Gx 和Gy 中对应的权重相乘后,叠加到各自的梯度值上。最后,我们从1 中减去水平方向和竖直方向的梯度值的绝对值,得到edge 。edge 值越小, 表明该位置越可能是一个边缘点。至此,边缘检测过程结束。

(6)当然,我们也关闭了该Shader 的Fallback:

	FallBack Off

完成后返回编辑器,并把Chapter12-EdgeDetection 拖曳到摄像机的EdgeDetection.cs 脚本中的edgeDetectShader 参数中。当然,我们可以在EdgeDetection.cs 的脚本面板中将edgeDetectShader参数的默认值设置为Chapter12-EdgeDetection,这样就不需要以后使用时每次都手动拖曳了。图12.6 显示了edgeOnly
参数为1 时对应的屏幕效果。

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

12.4 高斯模糊

在12.3 节中,我们学习了卷积的概念,并利用卷积实现了一个简单的边缘检测效果。在本节中,我们将学习卷积的另一个常见应用一一高斯模糊。模糊的实现有很多方法,例如均值模糊和中值模糊。均值模糊同样使用了卷积操作,它使用的卷积核中的各个元素值都相等,且相加等于1 ,也就是说,卷积后得到的像素值是其邻域内各个像素值的平均值。而中值模糊则是选择邻域内对所有像素排序后的中值替换掉原颜色。一个更高级的模糊方法是高斯模糊。在学习完本节后,我们可以得到类似图12.7 中的效果。

12.4.1 高斯滤波

高斯模糊同样利用了卷积计算,它使用的卷积核名为高斯核。高斯核是一个正方形大小的滤波核,其中每个元素的计算都是基于下面的高斯方程:

其中,σ是标准方差(一般取值为1), x 和y 分别对应了当前位置到卷积核中心的整数距离。要构建一个高斯核,我们只需要计算高斯核中各个位置对应的高斯值。为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为1 。因此, 高斯函数中е 前面的系数实际不会对结果有任何影响。图12.8 显示了一个标准方差为1 的5x5 大小的高斯核。
高斯方程很好地模拟了邻域每个像素对当前处理像素的影响程度一一距离越近,影响越大。
高斯核的维数越高,模糊程度越大。使用一个NxN的高斯核对图像进行卷积滤波,就需要NxNxWxH(W 和H 分别是图像的宽和高〉次纹理来样。当N 的大小不断增加时,采样次数会变得非常巨大。
幸运的是,我们可以把这个二维高斯函数拆分成两个一维函数。也就是说,我们可以使用两个一维的高斯核〈图12.8 中的右图〉先后对图像进行滤波,它们得到的结果和直接使用二维高斯核是一样的,但采样次数只需要2xNxWxH。我们可以进一步观察到,两个一维高斯核中包含了很多重复的权重。对于一个大小为5 的一维高斯核,我们实际只需要记录3 个权重值即可。

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

12.4.2 实现

为此,我们需要进行如下准备工作。
(1)新建一个场景。在本书资源中,该场景名为Scene_12_4 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting → Skybox中去掉场景中的天空盒子。
(2)把本书资源中的Assets/Textures/Chapter12/Sakura1.jpg 拖曳到场景中,并调整位置使其可以填充整个场景。注意, Sakura1.jpg 的纹理类型已被设置为Sprite , 因此可以直接拖曳到场景中。
(3)新建一个脚本。在本书资源中,该脚本名为GaussianBlur.cs. 把该脚本拖曳到摄像机上。
(4)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter12-GaussianBlur.
我们首先来编写GaussianBlur.cs 脚本。打开该脚本,并进行如下修改。
(1)首先,继承12.1 节中创建的基类:
public class GaussianBlur : PostEffectsBase {

( 2 )声明该效果需要的Shader,并据此创建相应的材质:

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

在上述代码中, gaussianBlurShader 是我们指定的shader , 对应了后面将会实现的Chapter12-GaussianBlur.

(3)在脚本中, 我们还提供了调整高斯模糊迭代次数、模糊范围和缩放系数的参数:

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

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

( 4 )最后,我们需要定义关键的OnRenderlmage 函数。我们首先来看第一个版本,也就是最简单的OnRenderlmage 的实现:

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

与上两节的实现不同, 我们这里利用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 来释放之前分配的缓存。

( 5 )在理解了上述代码后,我们可以实现第二个版本的OnRenderImage 函数。在这个版本中,我们将利用缩放对图像进行降采样, 从而减少需要处理的像素个数, 提高性能。

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

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

( 6 )最后一个版本的代码还考虑了高斯模糊的迭代次数:

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

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

下面,我们来实现Shader 的部分。打开Chapter12-GaussianBlur,进行如下修改。

(1)我们首先需要声明本例使用的各个属性:
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_BlurSize ("Blur Size", Float) = 1.0
	}

_MainTex 对应了输入的渲染纹理。

( 2)在本节中, 我们将第一次使用CGINCLUDE 来组织代码。我们在SubShader 块中利用CGINCLUDE 和ENDCG 语义来定义一系列代码:

	SubShader {
		CGINCLUDE
		……
		ENDCG 
		……

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

(3 )在CG 代码块中,定义与属性对应的变量:

	sampler2D _MainTex;  
	half4 _MainTex_TexelSize;
	float _BlurSize;

由于要得到相邻像素的纹理坐标,我们这里再一次使用了Unity 提供的 _MainTex_TexelSize 变量,以计算相邻像素的纹理坐标偏移量。

( 4)分别定义两个Pass 使用的顶点着色器。下面是竖直方向的顶点着色器代码:

		struct v2f {
			float4 pos : SV_POSITION;
			half2 uv[5]: TEXCOORD0;
		};
		  
		v2f vertBlurVertical(appdata_img v) {
			v2f o;
			o.pos = mul(UNITY_MATRIX_MVP, 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;
		}

在本节中我们会利用5x5 大小的高斯核对原图像进行高斯模糊, 而由12.4.1 节可知,一个5x5的二维高斯核可以拆分成两个大小为5 的一维高斯核, 因此我们只需要计算5 个纹理坐标即可。

为此,我们在v2f 结构体中定义了一个5 维的纹理坐标数组。数组的第一个坐标存储了当前的采样纹理,而剩余的四个坐标则是高斯模糊中对邻域采样时使用的纹理坐标。我们还和属性 _BlurSize 相乘来控制采样距离。在高斯核维数不变的情况下, _BlurSize 越大,模糊程度越高, 但采样数却不会受到影响。但过大的 _BlurSize 值会造成虚影, 这可能并不是我们希望的。通过把计算采样纹

理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算, 提高性能。由于从顶点着色器到片元着色器的插值是线性的, 因此这样的转移并不会影响纹理坐标的计算结果。

水平方向的顶点着色器和上面的代码类似, 只是在计算4 个纹理坐标时使用了水平方向的纹素大小进行纹理偏移。
( 5 ) 定义两个Pass 共用的片元着色器:
		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);
		}

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


( 6 )然后,我们定义了高斯模糊使用的两个Pass:

		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
		}

注意,我们仍然首先设置了渲染状态。和之前实现不同的是,我们为两个Pass 使用NAME语义(见3.3.3 节)定义了它们的名字。这是因为,高斯模糊是非常常见的图像处理操作,很多屏幕特效都是建立在它的基础上的,例如Bloom 效果(见12.5 节)。为Pass 定义名字,可以在其他 Shader 中直接通过它们的名字来使用该Pass,而不需要再重复编写代码。

(7)最后,关闭该Shader 的Fallback:

	FallBack Off

完成后返回编辑器, 并把Chapter12-GaussianBlur 拖曳到摄像机的GaussianBlur.cs 脚本中的gaussianBlurShader 参数中。当然,我们可以在GaussianBlur.cs 的脚本面板中将gaussianBlurShader参数的默认值设置为Chapter12-GaussianBIur,这样就不需要以后使用时每次都手动拖曳了。

12.5 Bloom效果


Bloom 特效是游戏中常见的一种屏幕效果。这种特效可以模拟真实摄像机的一种图像效果,它让画面中较亮的区域“扩散”到周围的区域中,造成一种朦胧的效果。图12.9 给出了动画短片《大象之梦》(英文名: Elephants Dream )中的一个Bloom 效果。

本节将会实现一个基本的Bloom 特效,在学习完本节后, 我们可以得到类似图12.10 中的效果。

Bloom 的实现原理非常简单: 我们首先根据一个阀值提取出图像中的较亮区域, 把它们存储 在一张渲染纹理中,再利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果, 最后 再将其和原图像进行混合, 得到最终的效果。
为此,我们需要进行如下准备工作。
(1)新建一个场景。在本书资源中,该场景名为Scene_12_5 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting →Skybox 中去掉场景中的天空盒子。
(2)把本书资源中的Textures/Chapter12/Sakural.jpg 拖曳到场景中,并调整它的位置使其可以填充整个场景。注意, Sakura1.jpg 的纹理类型已被设置为Sprite , 因此可以直接拖曳到场景中。
(3)新建一个脚本。在本书资源中,该脚本名为Bloom.cs 。把该脚本拖曳到摄像机上。
(4)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter12-Bloom 。
我们首先来编写Bloom.cs 脚本。打开该脚本,并进行如下修改。
( 1) 首先, 继承12.1 节中创建的基类:
	public class Bloom : PostEffectsBase {

(2)声明该效果需要的Shader,并据此创建相应的材质:

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

在上述代码中, bloomShader 是我们指定的Shader, 对应了后面将会实现的Chapterl2-Bloom 。

(3 )由于Bloom 效果是建立在高斯模糊的基础上的, 因此脚本中提供的参数和12.4 节中的几乎完全一样, 我们只增加了一个新的参数luminanceThreshold 来控制提取较亮区域时使用的阀值大小:

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

尽管在绝大多数情况下,图像的亮度值不会超过1 。但如果我们开启了HDR,硬件会允许我们把颜色值存储在一个更高精度范围的缓冲中,此时像素的亮度值可能会超过1 。因此, 在这里我们把luminanceThreshold 的值规定在[0, 4 ]范围内。更多关于HDR 的内容, 可以参见18.4.3 节。

( 4 )最后, 我们需要定义关键的OnRenderlmage 函数:

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

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

下面,我们来实现Shader 的部分。打开Chapter12-Bloom,进行如下修改。

( 1 )我们首先需要声明本例使用的各个属性:
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_Bloom ("Bloom (RGB)", 2D) = "black" {}
		_LuminanceThreshold ("Luminance Threshold", Float) = 0.5
		_BlurSize ("Blur Size", Float) = 1.0
	}

_MainTex 对应了输入的渲染纹理。_Bloom 是高斯模糊后的较亮区域, _LuminanceThresbold是用于提取较亮区域使用的阀值,而_BlurSize 和12.4 节中的作用相同,用于控制不同迭代之间高斯模糊的模糊区域范围。

(2 )在本节中,我们仍然使用CGINCLUDE 来组织代码。我们在SubShader 块中利用CGINCLUDE 和ENDCG 语义来定义一系列代码:

SubShader {
	CGINCLUDE
	……
	ENDCG
	……

(3 )声明代码中需要使用的各个变量:

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

(4)我们首先定义提取较亮区域需要使用的顶点着色器和片元着色器:

		struct v2f {
			float4 pos : SV_POSITION; 
			half2 uv : TEXCOORD0;
		};	
		
		v2f vertExtractBright(appdata_img v) {
			v2f o;
			
			o.pos = mul(UNITY_MATRIX_MVP, 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;
		}

顶点着色器和之前的实现完全相同。在片元着色器中,我们将采样得到的亮度值减去阀值 _LuminanceThreshold,并把结果截取到0~
1 范围内。然后,我们把该值和原像素值相乘,得到提取后的亮部区域。

(5)然后,我们定义了混合亮部图像和原图像时使用的顶点着色器和片元着色器:

		struct v2fBloom {
			float4 pos : SV_POSITION; 
			half4 uv : TEXCOORD0;
		};
		
		v2fBloom vertBloom(appdata_img v) {
			v2fBloom o;
			
			o.pos = mul (UNITY_MATRIX_MVP, 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);
		} 

这里使用的顶点着色器与之前的有所不同,我们定义了两个纹理坐标,并存储在同一个类型为half4 的变量UV 中。它的xy分量对应了 _MainTex, 即原图像的纹理坐标。而它的zw 分量是 _Bloom , 即模糊后的较亮区域的纹理坐标。我们需要对这个纹理坐标进行平台差异化处理〈详见5.6.1 节〉。

片元着色器的代码就很简单了。我们只需要把两张纹理的采样结果相加混合即可。

( 6 )接着,我们定义了Bloom 效果需要的4 个Pass:

		ZTest Always Cull Off ZWrite Off
		
		Pass {  
			CGPROGRAM  
			#pragma vertex vertExtractBright  
			#pragma fragment fragExtractBright  
			
			ENDCG  
		}
		
		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  
		}

其中, 第二个和第三个Pass 我们直接使用了12.4 节高斯模糊中定义的两个Pass, 这是通过UsePass 语义指明它们的Pass 名来实现的。需要注意的是, 由于Unity 内部会把所有Pass 的Name 转换成大写字母表示,因此在使用UsePass 命令时我们必须使用大写形式的名字。

(7 )最后,我们关闭了该Shader 的Fallback:

FallBack Off

完成后返回编辑器, 并把Chapter12-Bloom 拖曳到摄像机的Bloom.cs 脚本中的bloomShader参数中。当然,我们可以在Bloom .cs 的脚本面板中将bloomShader 参数的默认值设置为Chapter12-Bloom,这样就不需要以后使用时每次都手动拖曳了。

12.6 运动模糊

运动模糊是真实世界中的摄像机的一种效果。如果在摄像机曝光时,拍摄场景发生了变化,就会产生模糊的画面。运动模糊在我们的日常生活中是非常常见的,只要留心观察,就可以发现无论是体育报道还是各个电影里,都有运动模糊的身影。运动模糊效果可以让物体运动看起来更加真实平滑,但在计算机产生的图像中,由于不存在曝光这一物理现象,渲染出来的图像往往都棱角分明,缺少运动模糊。在一些诸如赛车类型的游戏中, 为画面添加运动模糊是一种常见的处理方法。在这一节中,我们将学习如何在屏幕后处理中实现运动模糊的效果。在本节结束后,我们将得到类似图12.11 中的效果。
运动模糊的实现有多种方法。一种实现方法是利用一块累积缓存( accumulation buffer )来混合多张连续的图像。当物体快速移动产生多张图像后,我们取它们之间的平均值作为最后的运动模糊图像。然而,这种暴力的方法对性能的消耗很大,因为想要获取多张帧图像往往意味着我们需要在同一帧里渲染多次场景。另一种应用广泛的方法是创建和使用速度缓存( velocity buffer),这个缓存中存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。
在本节中,我们将使用类似上述第一种方法的实现来模拟运动模糊的效果。我们不需要在一帧中把场最渲染多次,但需要保存之前的渲染结果,不断把当前的渲染图像叠加到之前的渲染图像中,从而产生一种运动轨迹的视觉效果。这种方法与原始的利用累计缓存的方法相比性能更好,但模糊效果可能会略有影响。
为此,我们需要进行如下准备工作。
(1)新建一个场景。在本书资源中,该场景名为Scene_12_6 。在Unjty 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting → Skybox 中去掉场景中的天空盒子。
(2)我们需要搭建一个测试运动模糊的场景。在本书资源的实现中,我们构建了一个包含3面墙的房间,并放置了4 个立方体,它们均使用了我们在9.5 节中创建的标准材质。同时,我们把本书资源中的Translating.cs 脚本拖曳给摄像机,让其在场景中不断运动。
(3)新建一个脚本。在本书资源中,该脚本名为MotionBlur.cs 。把该脚本拖曳到摄像机上。
(4) 新建一个Unity Shader。在本书资源中,该Shader 名为Chapter12-MotionBlur。
我们首先来编写MotionBlur.cs 脚本。打开该脚本,并进行如下修改。
(1)首先,继承12.1 节中创建的基类:
	public class MotionBlur : PostEffectsBase {

(2 )声明该效果需要的Shader , 并据此创建相应的材质:

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

(3 )定义运动模糊在混合图像时使用的模糊参数:

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

blurAmount 的值越大,运动拖尾的效果就越明显,为了防止拖尾效果完全替代当前帧的渲染结果, 我们把它的值截取在0.0~0.9 范围内。

( 4 )定义一个RenderTexture 类型的变量,保存之前图像叠加的结果:

	private RenderTexture accumulationTexture;
 
	void OnDisable() {
		DestroyImmediate(accumulationTexture);
	}

在上面的代码里, 我们在该脚本不运行时, 即调用OnDisable 函数时,立即销毁accumulationTexture 。这是因为,我们希望在下一次开始应用运动模糊时重新叠加图像。

( 5 )最后, 我们需要定义运动模糊使用的OnRenderlmage 函数:

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

在确认材质可用后, 我们首先判断用于混合图像的accumulationTexture 是否满足条件。我们不仅判断它是否为空, 还判断它是否与当前的屏幕分辨率相等,如果不满足, 就说明我们需要重新创建一个适合于当前分辨率的accwnulationTexture 变量。创建完毕后, 由于我们会自己控制该变量的销毁, 因此可以把它的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 的部分。本节实现的运动模糊非常简单,我们打开Chapter12-MotionBiur, 进行如下修改。
(1)我们首先需要声明本例使用的各个属性:
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_BlurAmount ("Blur Amount", Float) = 1.0
	}

_MainTex 对应了输入的渲染纹理。_BlurAmount 是混合图像时使用的混合系数。

(2 )在本节中,我们使用CGINCLUDE 来组织代码。我们在SubShader 块中利用CGINCLUDE 和 ENDCG 语义来定义一系列代码:

SubShader {
    CGINCLODE
    ……
    ENDCG```

(3 )声明代码中需要使用的各个变量:

```csharp
	sampler2D _MainTex;
	fixed _BlurAmount;

(4)顶点着色器的代码与之前章节使用的代码完全一样:

		struct v2f {
			float4 pos : SV_POSITION;
			half2 uv : TEXCOORD0;
		};
		
		v2f vert(appdata_img v) {
			v2f o;
			
			o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
			
			o.uv = v.texcoord;
					 
			return o;
		}

( 5 )下面,我们定义了两个片元着色器, 一个用于更新渲染纹理的RGB 通道部分,第一个用于更新渲染纹理的A 通道部分:

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

RGB 通道版本的Shader 对当前图像进行采样,并将其A 通道的值设为BlurAmount,以便在后面混合时可以使用它的透明通道进行混合。A 通道版本的代码就更简单了,直接返回采样结果。实际上,这个版本只是为了维护渲染纹理的透明通道值,不让其受到混合时使用的透明度值的影响。

( 6 )然后,我们定义了运动模糊所需的Pass。在本例中我们需要两个Pass , 一个用于更新渲染纹理的RGB 通道, 第一个用于更新A 通道。之所以要把A 通道和RGB 通道分开,是因为在更新RGB 时我们需要设置它的A 通道来混合图像, 但又不希望A 通道的值写入渲染纹理中。

		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
		}

( 7 )最后, 我们关闭了Shader 的Fallback:

 	FallBack Off
  
  
完成后返回编辑器,并把Chapter12-MotionBlur 拖曳到摄像机的MotionBlur.cs 脚本中的motionBlurShader 参数中。当然,我们可以在MotionBlur.cs 的脚本面板中将motionBlurShader 参数的默认值设置为Chapter12-MotionBIur,这样就不需要以后使用时每次都手动拖曳了。
本节是对运动模糊的一种简单实现。我们混合了连续帧之间的图像, 这样得到一张具有模糊拖尾的图像。然而,当物体运动速度过快时,这种方法可能会造成单独的帧图像变得可见。在第13 章中, 我们会学习如何利用深度纹理重建速度来模拟运动模糊效果。

12.7 扩展阅读

本章介绍了如何在Unity 中利用渲染纹理实现屏幕后处理效果,并且介绍了几种常见的屏幕特效的实现方法。这些效果都使用了图像处理中的一些算法,以达到特定的图像效果。除了本章介绍的这些效果外,读者可以在Unity 的Image Effect 
( http://docs.unity3d.com/Manual/compImageEffects.html )包中找到更多特效的实现。在GPU Gems 系列
(http://developer.nvidia.com/gpugems/GPUGems )中, 也介绍了许多基于图像处理的渲染技术。例如, 《GPU Gems 3》 的第27章, 介绍了一种景深效果的实现方法。除此之外,读者也可以在Unity 的资源商店和其他网络资源中找到许多出色的屏幕特效。
























  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值