上篇文章地址:https://www.yukicat.net/1390/

今天记录一下高斯模糊是如何实现的,高斯模糊是实现图像模糊的一种方式,它的本质是通过高斯函数去定义一个卷积核,这个卷积核叫做高斯核,再利用这个高斯核对图像进行卷积运算,得到“平滑”后的图像,也就是俗称的模糊。

那么,这个高斯核是如何得到的呢?它是通过高斯函数得到的:

σ 是标准方差,求高斯核时通常取1,x和y分别对应了当前位置到该核中心的整数距离

假设核的中心为(0, 0),这个高斯核的大小为3x3,那么将所有点放入这九个格子中就可以得到:

其中,(-1, 1)分别对应了x、y的值为-1和1,为了求得未归一化之前的高斯核各个权重,这里需要先确定标准方差σ的值,随便取一个σ=1.5吧,以核中心(0, 0)为例,计算权重:G(0, 0) = 1 / 2π * 1.5² * 1 ≈ 0.0707.

再把其余的权重按照将x和y代入高斯方程的方式计算出来即可:

可以发现,高斯方程可以模拟领域每个像素对当前处理像素的影响程度,距离越近则权重越大,影响也越大,为了保证卷积后的图像不变暗,需要对高斯核的权重进行归一化,让每个权重除以所有权重的和:

权重和 = 0.0707 + 0.0566 * 4 + 0.0453 * 4 = 0.4783

这样处理得到的高斯核可以真正用来实现高斯模糊了,但是,随着高斯核维数越高,模糊的程度也会越大。如果使用一个大小为N*N的高斯核对图像进行卷积计算的话,就需要N*N*W*H(W和H分别为图像的宽度和高度)次纹理采样,计算量会变得非常大。但二维高斯函数具有可分离性(可分离性可阅读这篇文章:https://blog.csdn.net/zxpddfg/article/details/45912561),可将它拆成两个一维函数,也就是说,可以使用两个一维的高斯核先后对图像进行卷积,其结果与使用二维高斯核进行卷积是一样的,但计算量变为2*N*W*H,同时,一维高斯核中包含了很多重复的权重,即具有对称性,对于一个大小为5的一维高斯核,实际上只要记录三个权重值即可。

在Unity中,可以通过将图像进行缩放来提高性能,并且可以通过反复的卷积计算来控制图像的模糊程度,也就是计算次数越多,得到的图像越模糊。为了实现高斯模糊,需要进行以下的准备工作:

1、新建场景,将天空盒去除;
2、往项目中导入一张需要进行边缘检测的图片,这里我选用一张曾经拍摄过的照片; 
3、新建脚本,将脚本绑定在摄像机上; 
4、新建Shader;
5、将导入的图片类型设置为Sprite,把图片拖入到场景里头,调整大小和位置。

在新建的GaussianBlur脚本中,继承PostEffectsBase基类,这个基类的代码在上一篇文章有贴过完整的代码,这里就不重复记录了。

同样地,声明高斯模糊需要的Shader,并创建相应的材质。

// 声明高斯模糊需要的Shader,并创建相应的材质
public Shader gaussianBlurShader;
private Material gaussianBlurMaterial = null;
public Material material{
    get{
        // 调用PostEffectsBase基类中检查Shader和创建材质的函数
        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;
// 其中,blurSpread和downSample均出于性能考虑
// 在高斯核维数不变的情况下,模糊半径越大,模糊程度越高
// 下采样越多,需要处理的像素就越少,也可进一步提高模糊程度

接着调用OnRenderImage函数实现屏幕后处理,思路、代码、注释如下:(对应书本第255页)

// 调用OnRenderImage函数实现屏幕后处理
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
    // 先检查材质是否可用,如果可用,则将参数传递给材质后
    // 再调用Graphics.Blit进行处理,否则不作处理
    if (material != null){
        // src.width和src.height分别为屏幕图像的宽度与高度
        // 除以下采样得到的rtW和rtH分别为渲染纹理的宽度和高度
        int rtW = src.width / downSample;
        int rtH = src.height / downSample;        
        // 然后利用RenderTexture.GetTemporary得到一块大小小于原屏幕分辨率的缓冲区
        // 设置该临时渲染纹理的滤波模式为双线性
        RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
        buffer0.filterMode = FilterMode.Bilinear;
        Graphics.Blit(src, buffer0);

        // 通过循环迭代高斯模糊
        for(int i = 0; i < iterations; i++)
        {
            // 将模糊半径传入Shader
            material.SetFloat("_BlurSize", 1.0f + i * blurSpread);
            // 定义第二个缓存buffer1
            RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
            // 通过Blit调用Shader中第一个Pass完成竖直方向上的高斯模糊
            // 执行第一个Pass时,输入是buffer0,输出是buffer1
            Graphics.Blit(buffer0, buffer1, material, 0);
            // 接着释放buffer0,把结果重新赋给buffer0
            // 再重新分配一次buffer1
            RenderTexture.ReleaseTemporary(buffer0);
            buffer0 = buffer1;
            buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

            // 执行第二个Pass,完成水平方向上的高斯模糊
            Graphics.Blit(buffer0, buffer1, material, 1);
            // 接着释放buffer0,把结果重新赋给buffer0
            RenderTexture.ReleaseTemporary(buffer0);
            buffer0 = buffer1;
        }
        // 把结果显示在屏幕上,再释放buffer0
        Graphics.Blit(buffer0, dest);
        RenderTexture.ReleaseTemporary(buffer0);
    } else {
        Graphics.Blit(src, dest);
    }
}

脚本方面的代码已经完成了,接下来在Shader中继续实现高斯模糊。首先是定义需要使用到的属性变量:

Properties
{
	// _MainTex为渲染纹理,变量名固定不能改变
	// _BlurSize为模糊半径
    _MainTex ("Base(RGB)", 2D) = "white" {}
	_BlurSize ("Blur Size", Float) = 1.0
}

然后,使用CGINCLUDE和ENDCG来组织代码,作用与头文件类似,使用它的原因是实现高斯模糊需要用到两个Pass,而它们的片元着色器是完全一致的,使用CGINCLUDE则可以通过在Pass中指定需要使用的顶点着色器和片元着色器函数名即可,就无需重复写相同的代码片段。

CGINCLUDE
    // 代码片段
ENDCG

接着定义对应属性的变量:

// 分别定义渲染纹理、纹素大小、模糊半径等变量
// 其中_MainTex_TexelSize用来计算相邻像素的纹理坐标偏移量
sampler2D _MainTex;  
half4 _MainTex_TexelSize;
float _BlurSize;

定义顶点着色器的输出结构体:

struct v2f {
    float4 pos : SV_POSITION;
    // 由于卷积核大小为5x5的二维高斯核可以拆分两个大小为5的一维高斯核
    // 此处定义5维数组用来计算5个纹理坐标
    // uv[0]存储了当前的采样纹理,其他四个则为高斯模糊中对邻域采样时使用的纹理坐标
    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]到[4]分别对应(0, 1)、(0, -1)、(0, 2)、(0, -2)
    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]到[4]分别对应(1, 0)、(-1, 0)、(2, 0)、(-2, 0)
    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};
    // 结果值sum初始化为当前的像素值乘以它对应的权重值
    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);
}

至此,CGINCLUDE和ENDCG中间的代码块结束,然后还需要开启深度测试,关闭剔除和深度写入:

// 开启深度测试,关闭剔除和深度写入
ZTest Always Cull Off ZWrite Off

接着编写刚才提到过的两个Pass,第一个Pass用来进行竖直方向上的高斯模糊,第二个Pass用来进行水平方向上的高斯模糊,其中只需要定义好各自对应的顶点着色器和片元着色器的函数名即可。

// 第一个Pass用来进行竖直方向上的高斯模糊
// 通过设置NAME,在后续实现Bloom效果的时候可以通过NAME直接使用该Pass,而无需重复写代码
Pass {
	NAME "GAUSSIAN_BLUR_VERTICAL"
	CGPROGRAM
			  
	#pragma vertex vertBlurVertical  
	#pragma fragment fragBlur
			  
	ENDCG  
}
		
// 第二个Pass用来进行水平方向上的高斯模糊
Pass {  
	NAME "GAUSSIAN_BLUR_HORIZONTAL"
	CGPROGRAM  
			
	#pragma vertex vertBlurHorizontal  
	#pragma fragment fragBlur
			
	ENDCG
}

高斯模糊效果实现的完整Shader如下:

Shader "Unlit/Chapter12/GaussianBlur"
{
    Properties
    {
		// _MainTex为渲染纹理,变量名固定不能改变
		// _BlurSize为模糊半径
        _MainTex ("Base(RGB)", 2D) = "white" {}
		_BlurSize ("Blur Size", Float) = 1.0
    }
	SubShader {
		// 使用CGINCLUDE和ENDCG组织代码(类似头文件)
		// 在Pass中直接指定需要使用的顶点着色器和片元着色器函数名即可
		// 因为高斯模糊需要用到两个Pass,而且片元着色器完全一致
		// 所以使用CGINCLUDE可以避免重复写一样的代码
		CGINCLUDE
		
		#include "UnityCG.cginc"
		
		// 分别定义渲染纹理、纹素大小、模糊半径等变量
		// 其中_MainTex_TexelSize用来计算相邻像素的纹理坐标偏移量
		sampler2D _MainTex;  
		half4 _MainTex_TexelSize;
		float _BlurSize;
		
		// 定义顶点着色器的输出结构体
		struct v2f {
			float4 pos : SV_POSITION;
			// 由于卷积核大小为5x5的二维高斯核可以拆分两个大小为5的一维高斯核
			// 此处定义5维数组用来计算5个纹理坐标
			// uv[0]存储了当前的采样纹理,其他四个则为高斯模糊中对邻域采样时使用的纹理坐标
			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]到[4]分别对应(0, 1)、(0, -1)、(0, 2)、(0, -2)
			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]到[4]分别对应(1, 0)、(-1, 0)、(2, 0)、(-2, 0)
			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};
			// 结果值sum初始化为当前的像素值乘以它对应的权重值
			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,在后续实现Bloom效果的时候可以通过NAME直接使用该Pass,而无需重复写代码
		Pass {
			NAME "GAUSSIAN_BLUR_VERTICAL"
			CGPROGRAM
			  
			#pragma vertex vertBlurVertical  
			#pragma fragment fragBlur
			  
			ENDCG  
		}
		
		// 第二个Pass用来进行水平方向上的高斯模糊
		Pass {  
			NAME "GAUSSIAN_BLUR_HORIZONTAL"
			CGPROGRAM  
			
			#pragma vertex vertBlurHorizontal  
			#pragma fragment fragBlur
			
			ENDCG
		}
	} 
	// 设置FallBack为Diffuse
	FallBack "Diffuse"
}

最后,保存Shader,回到Unity,将这个Shader拖动到脚本对应的位置,可以发现,已经有高斯模糊效果了,此时可以调整迭代次数(Iterations)、模糊范围(Blur Spread)和下采样数值(Down Sample),查看不同的模糊效果。

[s201_bai id="14"]

需要注意的是,如果模糊范围(Blur Spread)越大,模糊程度就会越高,但过大的模糊范围和较高的下采样次数会造成图像产生虚影,所以需要注意参数调整的合理性,不能过于极端,如下图所示。

迭代次数为2,模糊范围为3,下采样次数为8的高斯模糊效果

至此,简单的高斯模糊效果就完成了,下期将会介绍如何实现Bloom效果,它是用来模拟真实摄像机的一种图像效果,让游戏画面中较亮的区域“扩散”到周围的区域中,来体现一种朦胧感,而且它的实现是基于高斯模糊,应用范围也更加广阔。

2020年6月28日 By Neptune

届ける言葉を今は育ててる
最后更新于 2020-06-28