Unity Shader入门精要学习笔记 - 第12章 屏幕后处理效果

发表于2017-09-01
评论0 3.2k浏览
屏幕后处理效果

屏幕后处理效果(screen post-processing effects ) 是游戏中实现屏幕特效的常见方法。 在本篇中, 我们将学习如何在 Unity 中利用渲染纹理来实现各种常见的屏幕后处理效果。我们首先会解释在 Unity 中实现屏幕后处理效果的原理, 并建立一个基本的屏幕后处理脚本系统。随后 我们会使用这个系统实现一个简单的调整画面亮度、 饱和度和对比度的屏幕特效。 接下来, 我们会接触到图像滤波的概念, 并利用 Sobel 算子在屏幕空间中对图像进行边缘检测, 实现描边效果。 在此基础上,将会介绍如何实现一个高斯模糊的屏幕特效,后期我们会分别介绍如何实现 Bloom 和运动模糊效果。

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

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

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

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

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

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

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

基类PostEffectBase.cs 的代码如下
//希望在编辑器状态下也可以执行该脚本来查看效果
[ExecuteInEditMode]
//所有的屏幕后处理效果都需要绑定在某个摄像机上
[RequireComponent(typeof(Camera))]
public class PostEffectsBase : MonoBehaviour {
	protected void CheckResource(){
		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(){
		CheckResource();
	}
	//第一个参数指定了该特效需要使用的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;
		}
		material = new Material(shader);
		material.hideFlags = HideFlags.DontSave;
		return material;
	}
}

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

在上面,我们了解了实现屏幕后处理特效的技术原理。我们现在先来实现一个非常简单的屏幕特效——调整屏幕的亮度、饱和度和对比度。我们的效果图如下所示

我们需要进行如下准备工作。
1)新建一个场景,去掉天空盒子
2)拖拽一张图片Sakura0.jpg(设置为Sprite)到场景中,调整位置使它可以填充整个场景。
3)新建一个脚本,名为BrightnessSaturationAndContrast.cs。添加到摄像机上。
4)新建一个Unity Shader。

我们先修改BrightnessSaturationAndContrast.cs
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);
		}
	}
}

然后我们再修改Shader 的代码:
Shader "Unity Shaders Book/Chapter 12/Brightness Saturation And Contrast" {
	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;
			};
			//屏幕特效使用的顶点着色器代码通常比较简单,我们只需要进行必须的顶点变换
			//更重要的是,我们需要把正确的纹理坐标传递给片元着色器,以便对屏幕图像进行正确的采样
			//使用了内置appdata_img 结构体作为顶点着色器的输入
			v2f vert(appdata_img v) {
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, 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;
				fixed3 luminanceColor = fixed3(luminance, luminance, luminance);
				finalColor = lerp(luminanceColor, finalColor, _Saturation);
				//调整对比度
				fixed3 avgColor = fixed3(0.5, 0.5, 0.5);
				finalColor = lerp(avgColor, finalColor, _Contrast);
				return fixed4(finalColor, renderTex.a);  
			}  
			ENDCG
		}  
	}
	Fallback Off
}

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

边缘检测

边缘检测是描边效果的一种实现方法。原理是利用一些边缘检测算子对图像进行卷积操作。

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

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

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

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

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

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

我们使用Sobel算子进行边缘检测,实现描边效果。为此,我们做如下准备工作。
1)新建一个场景,去掉天空盒子
2)拖拽一张图片Sakura0.jpg(设置为Sprite)到场景中,调整位置使它可以填充整个场景。
3)新建一个脚本,名为EdgeDetectiont.cs。添加到摄像机上。
4)新建一个Unity Shader。

我们首先修改EdgeDetectiont.cs 的代码
public class EdgeDetection : PostEffectsBase {
	public Shader edgeDetectShader = null;
	private Material edgeDetectMaterial = null;
	public Material material{
		get{
			edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader,edgeDetectMaterial);
			return edgeDetectMaterial;
		}
	}
	[Range(0.0f,1.0f)]
	//当edgesOnly为0时,边缘将会叠加在原渲染图像上,为1时,则会只显示边缘,不显示原渲染图像
	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.SetFloat("_BackgroundColor",backgroundColor);
			Graphics.Blit(src, dest, material);
		}
		else{
			Graphics.Blit(src, dest);
		}
	}
}

我们再修改Shader中的代码:
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为我们提供访问xxx纹理对应的每个纹素的大小。
			//例如一张512×512的纹理,该值大小为0.001953(即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 = mul(UNITY_MATRIX_MVP, v.vertex);
				half2 uv = v.texcoord;
				//我们在v2f结构体中定义了一个维数为9的纹理数组,对应了使用Sobel算子采样时需要的9个
				//邻域纹理坐标。通过把计算采样纹理坐标的代码从片元着色器转移到顶点着色器中,可以减少
				//运算,提供性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移不会影响
				//纹理坐标的计算结果。
				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; 
			}
			//利用Sobel算子计算梯度值
			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];
				}
				//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);
				return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
 			}
			ENDCG
		} 
	}
	FallBack Off
}

高斯模糊

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

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

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

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

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

我们需要做如下准备工作:
1)新建一个场景,去掉天空盒子
2)拖拽一张图片Sakura1.jpg(设置为Sprite)到场景中,调整位置使它可以填充整个场景。
3)新建一个脚本,名为GaussianBlur.cs。添加到摄像机上。
4)新建一个Unity Shader。

首先先修改GaussianBlur.cs代码
public class GaussianBlur : PostEffectsBase{
	public Shader gaussianBlurShader;
	private Material gaussianBlurMaterial;
	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;
	//缩放系数
	[Range(1, 8)]
	public int downSample = 2;
	//第一个版本
	void OnRenderImage(RenderTexture src,RenderTexture dest){
		if(material == null){
			int rtW = src.width;
			int rtH = src.height;
			//分配一个缓冲区
			RenderTexture buffer = RenderTexture.GetTemporary(rtW,rtH,0);
			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 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);
			//临时渲染纹理的滤波模式设置为双线性
			buffer.filterMode = FilterMode.Bilinear;
			Graphics.Blit(src,buffer0);
			//进行迭代模糊
			for(int i=0;i<iterations;i++){
				material.SetFloat("_BlurSoze",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的代码:
Shader "Unity Shaders Book/Chapter 12/Gaussian Blur" {
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_BlurSize ("Blur Size", Float) = 1.0
	}
	SubShader {
		//在SubShader 块中利用CGINCLUDE 和 ENDCG 来定义一系列代码
		//这些代码不需要包含在Pass语义块中,在使用时,我们只需要在Pass中指定需要
		//使用的顶点着色器和片元着色器函数名即可。
		//使用CGINCLUDE可以避免我们编写两个完全一样的frag函数
		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 = 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;
		}
		v2f vertBlurHorizontal(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(_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 {
			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"
}

Bloom效果

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

我们先来实现一个基本的Bloom特效,可以得到类似下图中的效果。

Bloom的实现原理非常简单:我们首先根据一个阈值提取出图像中较亮的区域,把它们存储在一张渲染纹理中,再利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果,最后再将其和原图像进行混合,得到最终的效果。

为此,我们需要做如下准备工作:
1)新建一个场景,去掉天空盒子
2)把一张图片Sakuara1.jpg(已设为Sprite)拖入场景,调整大小位置以填充整个场景。
3)在摄像机上新建脚本Bloom.cs
4)新建一个Unity Shader。

我们先修改Bloom.cs
public class Bloom : PostEffectsBase{
	public Shader bloomShader;
	private Material blooMaterial = null;
	public  Material material{
		get{
			blooMaterial = CheckShaderAndCreateMaterial(bloomShader,blooMaterial);
			return blooMaterial;
		}
	}
	[Range(0,4)]
	public int iterations = 3;
	[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("_LuminanceTheshold",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);
				Graphics.Blit(buffer0,buffer1,material,1);
				RenderTexture.ReleaseTemporary(buffer0);
				buffer0 = buffer1;
				buffer1 = RenderTexture.GetTemporary(rtW,rtH,0);
				Graphics.Blit(buffer0,buffer1,material,2);
				buffer0 = buffer1;
			}
			material.SetTexture("_Bloom",buffer0);
			Graphics.Blit(src,dest,material,3);
			RenderTexture.ReleaseTemporary(buffer0);
		}
		else{
			Graphics.Blit(src,dest);
		}
	}
}

修改shader代码
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 = 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;
		}
		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);
		} 
		ENDCG
		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  
		}
	}
	FallBack Off
}

运动模糊

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

我们可以得到类似下图的效果。

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

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

在摄像机上新建脚本MotionBlur.cs
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);
		}
	}
}

新建一个Unity Shader:
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 = mul(UNITY_MATRIX_MVP, 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
}

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引