Unity Shader-后处理:高斯模糊

时间:2023-03-08 20:30:43
Unity Shader-后处理:高斯模糊

一.简介

上一篇文章学习了模糊的原理以及基本的模糊实现,对于清晰和模糊这个定义感觉还是比较说明问题,这里再贴出一下:“清晰的图片,各个像素之间会有明显的过渡,而如果各个像素之间的差距不是很大,那么图像就会模糊了”。鉴于这个定义,我们就可以通过代码来实现模糊的效果。上一篇Unity Shader-后处理:均值模糊中实现了一个基本的均值模糊,也就是将一个像素和其周围的像素取平均值从而进行模糊,并且通过迭代处理的方式,增强了模糊的效果。但是,均值模糊由于采样次数较少,每个像素以及其周围像素的权值是相同的,模糊出来的效果不佳,而多次迭代处理虽然可以增强模糊效果,但是迭代大大地增加了性能的消耗,虽然在学习时可以用迭代来达到效果,但是要实际使用的时候,效率就不得不成为我们考虑的重要因素。所以,这一次,我们来学习一下更加高级的模糊效果-高斯模糊。
高斯模糊(Gaussian Blur),又叫做高斯平滑。高斯模糊主要的功能是对图片进行加权平均的过程,与均值模糊中周围像素取平均值不同,高斯模糊进行的是一个加权平均操作,每个像素的颜色值都是由其本身和相邻像素的颜色值进行加权平均得到的,越靠近像素本身,权值越高,越偏离像素的,权值越低。而这种权值符合我们比较熟悉的一种数学分布-正态分布,又叫高斯分布,所以这种模糊就是高斯模糊啦。

二.概念介绍

1.正态分布

先来复习一下正太分布,上一次听说这个词儿应该还是大二时的《概率与数理统计》课上。正太分布,又名高斯分布,这个分布函数具有很多非常漂亮的性质,使得其在诸多领域都有非常重要的影响力。而且,正态分布是一个比较自然的分布,在特定条件下,大量统计独立的随机变量的平局值的分布趋近于正态分布,这也就是传说中的中心极限定理。说了这么多,归结起来就是一句话,高斯分布比较好看,所以我们就用高斯分布作为我们进行加权计算时的权值参考。
高斯分布的定义如下:
Unity Shader-后处理:高斯模糊
其中μ是正态分布随机变量的均值,也就是期望值,也就是下图中x轴上最中间的位置,随机变量在μ两侧对称分布,而第二个参数σ^2是这个随机变量的方差,因而正态分布记为N(μ,σ2 ),符合正态分布的随机变量越靠近μ则概率越大,而越远离μ概率越小,σ越小,分布越集中在μ附近,σ越大,分布越分散。而当μ=0,σ^2=1时,称为标准正态分布,记为N(0,1)。正态分布的图像如下图所示:
                                                       Unity Shader-后处理:高斯模糊
那么,这个分布和我们的高斯模糊有什么关系呢?简单来说,我们需要让我们的采样符合高斯分布。那么,我们处理每个像素的时候,像素本身的点就对应着μ对应的权值,而我们要在像素周围采样,这个采样的范围就可以用σ表示,比如我们的方差为1,那么我们直接向外采一个像素的值,基本就可以达到模糊的效果了;而如果方差为0,那么μ点的权值最大,加权平均后仍然为原像素值,等于没模糊;而方差很大,那么采样的范围就很广,就会更加模糊。
关于高斯模糊,还有一点,就是采样的个数,也就是传说中的高斯模板(高斯核)的大小。也就是我们要取几个采样点的问题,μ对应像素点本身,μ±1σ,μ±2σ分别代表向外1个采样点,两个采样点,当然由于越向外,对应的权值越小,所以后面的我们基本可以不予考虑,这里我们就采用一个μ,μ±1σ,μ±2σ,μ±3σ一共7个采样点作为高斯高斯核。
关于高斯核的权值设定,本人看了好几篇文章以及书籍,然而每个高斯模糊用的高斯核都不同,这里也没有什么标准。正如某图形学大牛说的:“图形学这东西,看起来是对的,那就是对的”,不管怎么样,毕竟最后都是给人看的,效果最好计算简单比什么都强!下面代码里面我自己设置了一套高斯权重,虽然看起来山寨了一点,不过满足相加等于1就行啦。

2.卷积

卷积是一个神奇的概念,最近看图像处理倒是经常看到这个词儿,想到上学的时候也没有搞懂这个东西,于是强迫症发作,决定查一查卷积到底怎么解释。对于卷积,百度百科上是这么说的:卷积是通过两个函数f和g生成第三个函数的一种数学算子。不过,高手在民间,知乎上对卷积的解释更加通俗易懂,这里摘抄一小段个人认为最为精辟的:
比如说你的老板命令你干活,你却到楼下打台球去了,后来被老板发现,他非常气愤,扇了你一巴掌(注意,这就是输入信号,脉冲),于是你的脸上会渐渐地(贱贱地)鼓起来一个包,你的脸就是一个系统,而鼓起来的包就是你的脸对巴掌的响应,好,这样就和信号系统建立起来意义对应的联系。下面还需要一些假设来保证论证的严谨:假定你的脸是线性时不变系统,也就是说,无论什么时候老板打你一巴掌,打在你脸的同一位置(这似乎要求你的脸足够光滑,如果你说你长了很多青春痘,甚至整个脸皮处处连续处处不可导,那难度太大了,我就无话可说了哈哈),你的脸上总是会在相同的时间间隔内鼓起来一个相同高度的包来,并且假定以鼓起来的包的大小作为系统输出。好了,那么,下面可以进入核心内容——卷积了!

如果你每天都到地下去打台球,那么老板每天都要扇你一巴掌,不过当老板打你一巴掌后,你5分钟就消肿了,所以时间长了,你甚至就适应这种生活了……如果有一天,老板忍无可忍,以0.5秒的间隔开始不间断的扇你的过程,这样问题就来了,第一次扇你鼓起来的包还没消肿,第二个巴掌就来了,你脸上的包就可能鼓起来两倍高,老板不断扇你,脉冲不断作用在你脸上,效果不断叠加了,这样这些效果就可以求和了,结果就是你脸上的包的高度随时间变化的一个函数了(注意理解);如果老板再狠一点,频率越来越高,以至于你都辨别不清时间间隔了,那么,求和就变成积分了。可以这样理解,在这个过程中的某一固定的时刻,你的脸上的包的鼓起程度和什么有关呢?和之前每次打你都有关!但是各次的贡献是不一样的,越早打的巴掌,贡献越小,所以这就是说,某一时刻的输出是之前很多次输入乘以各自的衰减系数之后的叠加而形成某一点的输出,然后再把不同时刻的输出点放在一起,形成一个函数,这就是卷积,卷积之后的函数就是你脸上的包的大小随时间变化的函数。本来你的包几分钟就可以消肿,可是如果连续打,几个小时也消不了肿了,这难道不是一种平滑过程么?反映到剑桥大学的公式上,f(a)就是第a个巴掌,g(x-a)就是第a个巴掌在x时刻的作用程度,乘起来再叠加就ok了

 
简单来说,卷积就是一个进行数学处理的一个算子。在图像处理中,设图像为f(x),模板g(x),然后图像处理就是将模板g(x)在图像f中移动,每移动到一个像素位置,就把f(x)与g(x)定义域相交的元素进行乘积并求和,得出新的图像中的该像素点,当全部像素点操作完成后,就得到了卷积后的图像,模板就是卷积核,上文中我们定义的高斯核就是一个卷积核。
上面我们说过高斯核,正常来看,我们应该是取像素为μ点,然后像素上下左右分别取一些像素点作为采样点,然后根据距离μ点的距离分别乘以相应的权值,作为处理后的这一点的像素值。但是这样做有一个弊端,就是我们在处理每个像素点的时候,需要进行大量的采样计算,需要像素点以及像素点周围几圈的采样点才能将中间像素周围所有的像素进行加权平均。而这样的操作是逐像素计算的,更可怕的是,这种效果是全屏幕后处理效果!!假设屏幕分辨率是M*N,我们的高斯核大小是m*n,那么进行一次后处理的时间复杂度为O(M*N*m*n)。
有什么好办法进行优化吗?
高斯模糊就是一个卷积操作,而这个操作是一个线性操作,换句话说这个系统是一个线性系统。所谓线性系统是一个系统的输入和输出是线性关系,就是说整个系统可以拆分成多个无关的独立变化,而整个系统就是这些变化的累加。比如一个系统,输入x1(t)产生输出y1(t),表示为x1(t)->y1(t),而另一个输入x2(t)产生输出y2(t)即x2(t)->y2(t)。这个系统是线性的,当且仅当x1(t)+x2(t)->y1(t)+y2(t)。
我画了一个简单的算平均数的图示,希望可以解释清楚这样的问题:
         Unity Shader-后处理:高斯模糊
从上面的图中我们看出,直接计算整个的平均数,和先计算横向的平均,再计算竖向的平均,得到的结果是相同的,也就是说两者是等价的(注意,本图只是一个类比,并不代表高斯模糊的原理,证明线性系统可以拆分成多个独立的操作,或者哪位高人有更好的证明方式也可以指点小弟一下)。
我们的高斯模糊操作,如果整个图像进行采样,那么会进行M*N*m*n次采样操作,而如果是先横向,再竖向,那么我们在横向方向需要M*m*n次采样操作,而在竖向方向需要N*m*n次采样操作,总共的时间复杂度就是O((M+N)*m*n)。从M*N降到M+N,一般地,M和N为屏幕分辨率,比如1024*768,那么,这样一个操作就大大降低了时间复杂度!!!不过需要一点点空间作为中间结果的缓存,不过这点缓存对于性能的优化还是很值得的。

三.高斯模糊的实现

扯了这么久的理论,终于要开始写代码了,这里不多说,直接上带有注释的代码了。
shader部分:
  1. Shader "Custom/GaussianBlur"
  2. {
  3. Properties
  4. {
  5. _MainTex("Base (RGB)", 2D) = "white" {}
  6. }
  7. //通过CGINCLUDE我们可以预定义一些下面在Pass中用到的struct以及函数,
  8. //这样在pass中只需要设置渲染状态以及调用函数,shader更加简洁明了
  9. CGINCLUDE
  10. #include "UnityCG.cginc"
  11. //blur结构体,从blur的vert函数传递到frag函数的参数
  12. struct v2f_blur
  13. {
  14. float4 pos : SV_POSITION;   //顶点位置
  15. float2 uv  : TEXCOORD0;     //纹理坐标
  16. float4 uv01 : TEXCOORD1;    //一个vector4存储两个纹理坐标
  17. float4 uv23 : TEXCOORD2;    //一个vector4存储两个纹理坐标
  18. float4 uv45 : TEXCOORD3;    //一个vector4存储两个纹理坐标
  19. };
  20. //shader中用到的参数
  21. sampler2D _MainTex;
  22. //XX_TexelSize,XX纹理的像素相关大小width,height对应纹理的分辨率,x = 1/width, y = 1/height, z = width, w = height
  23. float4 _MainTex_TexelSize;
  24. //给一个offset,这个offset可以在外面设置,是我们设置横向和竖向blur的关键参数
  25. float4 _offsets;
  26. //vertex shader
  27. v2f_blur vert_blur(appdata_img v)
  28. {
  29. v2f_blur o;
  30. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  31. //uv坐标
  32. o.uv = v.texcoord.xy;
  33. //计算一个偏移值,offset可能是(0,1,0,0)也可能是(1,0,0,0)这样就表示了横向或者竖向取像素周围的点
  34. _offsets *= _MainTex_TexelSize.xyxy;
  35. //由于uv可以存储4个值,所以一个uv保存两个vector坐标,_offsets.xyxy * float4(1,1,-1,-1)可能表示(0,1,0-1),表示像素上下两个
  36. //坐标,也可能是(1,0,-1,0),表示像素左右两个像素点的坐标,下面*2.0,*3.0同理
  37. o.uv01 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1);
  38. o.uv23 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 2.0;
  39. o.uv45 = v.texcoord.xyxy + _offsets.xyxy * float4(1, 1, -1, -1) * 3.0;
  40. return o;
  41. }
  42. //fragment shader
  43. fixed4 frag_blur(v2f_blur i) : SV_Target
  44. {
  45. fixed4 color = fixed4(0,0,0,0);
  46. //将像素本身以及像素左右(或者上下,取决于vertex shader传进来的uv坐标)像素值的加权平均
  47. color += 0.4 * tex2D(_MainTex, i.uv);
  48. color += 0.15 * tex2D(_MainTex, i.uv01.xy);
  49. color += 0.15 * tex2D(_MainTex, i.uv01.zw);
  50. color += 0.10 * tex2D(_MainTex, i.uv23.xy);
  51. color += 0.10 * tex2D(_MainTex, i.uv23.zw);
  52. color += 0.05 * tex2D(_MainTex, i.uv45.xy);
  53. color += 0.05 * tex2D(_MainTex, i.uv45.zw);
  54. return color;
  55. }
  56. ENDCG
  57. //开始SubShader
  58. SubShader
  59. {
  60. //开始一个Pass
  61. Pass
  62. {
  63. //后处理效果一般都是这几个状态
  64. ZTest Always
  65. Cull Off
  66. ZWrite Off
  67. Fog{ Mode Off }
  68. //使用上面定义的vertex和fragment shader
  69. CGPROGRAM
  70. #pragma vertex vert_blur
  71. #pragma fragment frag_blur
  72. ENDCG
  73. }
  74. }
  75. //后处理效果一般不给fallback,如果不支持,不显示后处理即可
  76. }
c#部分:
  1. using UnityEngine;
  2. using System.Collections;
  3. //编辑状态下也运行
  4. [ExecuteInEditMode]
  5. //继承自PostEffectBase
  6. public class GaussianBlur : PostEffectBase
  7. {
  8. //模糊半径
  9. public float BlurRadius = 1.0f;
  10. //降分辨率
  11. public int downSample = 2;
  12. //迭代次数
  13. public int iteration = 1;
  14. void OnRenderImage(RenderTexture source, RenderTexture destination)
  15. {
  16. if (_Material)
  17. {
  18. //申请RenderTexture,RT的分辨率按照downSample降低
  19. RenderTexture rt1 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0, source.format);
  20. RenderTexture rt2 = RenderTexture.GetTemporary(source.width >> downSample, source.height >> downSample, 0, source.format);
  21. //直接将原图拷贝到降分辨率的RT上
  22. Graphics.Blit(source, rt1);
  23. //进行迭代高斯模糊
  24. for(int i = 0; i < iteration; i++)
  25. {
  26. //第一次高斯模糊,设置offsets,竖向模糊
  27. _Material.SetVector("_offsets", new Vector4(0, BlurRadius, 0, 0));
  28. Graphics.Blit(rt1, rt2, _Material);
  29. //第二次高斯模糊,设置offsets,横向模糊
  30. _Material.SetVector("_offsets", new Vector4(BlurRadius, 0, 0, 0));
  31. Graphics.Blit(rt2, rt1, _Material);
  32. }
  33. //将结果输出
  34. Graphics.Blit(rt1, destination);
  35. //释放申请的两块RenderBuffer内容
  36. RenderTexture.ReleaseTemporary(rt1);
  37. RenderTexture.ReleaseTemporary(rt2);
  38. }
  39. }
  40. }
注意,这里的GaussianBlur继承了PostEffectBase类。该类在之前的文章《UnityShader-后处理:简单亮度对比度饱和度调整》中有完整的实现,这里就不贴出代码了。
其实GaussianBlur和上一篇文章中的SimpleBlurEffect基本一致,改变的地方就在于我们需要两遍高斯模糊,这两遍模糊分别是针对竖向和横向进行模糊的,区别和设置就在于offset的设置。其实也可以分别使用两个不同的pass分别作为horizontal和vertical方向的模糊,不过这样代码有冗余,所以还是用这种比较“优雅”的方式实现了。

四.效果展示

我们在MainCamera上挂在GaussianBlur脚本,然后把GaussianBlur.shader赋给shader槽,就可以看到模糊的效果了。
首先看一下原图效果,如果不需要后处理,直接关掉后处理控件最好,因为Graphic.Blit等操作也是很费的操作。不过这里为了演示,直接模糊半径为0,不降分辨率,迭代1次,即可显示清晰的原图效果:
Unity Shader-后处理:高斯模糊
当模糊半径设为1,分辨率降低为1/2,迭代1次,轻微的模糊效果:
Unity Shader-后处理:高斯模糊
模糊半径为1,分辨率将为1/4,迭代一次,更加模糊的效果:
Unity Shader-后处理:高斯模糊
模糊半径为1,分辨率将为1/4,迭代两次,可以达到下图这种毛玻璃效果(PS,这种效果在最近新出的《天下》手游里面,点击界面后,屏幕背景就会变成类似的效果):
Unity Shader-后处理:高斯模糊
通过上面的图片我们看到,其实降低分辨率对我们的高斯模糊效果有很大的提升。其实降分辨率的那个操作本身就是一个类似模糊的效果,这种效果在我们想要清晰的图片时看起来会特别蛋疼,如下图所示:
Unity Shader-后处理:高斯模糊
但是,如果不想要清晰的图片,降分辨率反倒让我们的模糊效果更好。而且,最最重要的是,降分辨率之后,pixel shader采样会大大降低,这对我们的效率会有很大的提升。
高斯模糊的效果更加平滑,没有简单的均值模糊那种"近视眼"或者像素块的感觉,下面看一下高斯模糊和上一篇文章中的简单均值模糊的对比:
Unity Shader-后处理:高斯模糊