您的位置:首页 > 移动开发 > Unity3D

【猫猫的Unity Shader之旅】之高斯模糊

2017-10-02 18:20 288 查看
  在初识屏幕特效这篇文章我们已经讲过如何在Unity中通过C#脚本和Shader配合实现最简单的屏幕特效,但是这个特效过于简单了,只是对最后将要渲染到屏幕上的颜色(即图像,或者说一张2D纹理)进行了简单的修正。由于屏幕特效本质上是对将要渲染到屏幕上的图像进行处理,所以屏幕特效用到的很多技术都是从图像处理来的,于是乎也就需要一点点(许多多)数学知识。

什么是高斯模糊

  在谈什么是高斯模糊之前我们先来看看什么是模糊。话说在一个幼儿园里面有五个小朋友坐成一排分别叫A、B、C、D、E,有一天老师给大家发糖果,五位小朋友得到的糖果数分别是10、10、20、10、10。What?C小朋友幼儿园阿姨真的不是你亲姐姐吗,怎么可以这么多?这时候C小朋友也觉得自己太特殊了,脸上都有点挂不住了,于是把多出来的糖分给了其他小朋友,这次小朋友们的糖果数变成了12、12、12、12、12,这下大家都没有意见了,C小朋友也没有了那种搞特殊的感觉,我们称C小朋友的这种做法叫做模糊。没错,图形学要从娃娃抓起,C小朋友已经成功领悟模糊的真谛,此处应有掌声ヽ(゚∀゚)メ(゚∀゚)ノ 呱唧呱唧~

  上面的例子真是生动而又不失严谨,我们可以总结一下:如果有人搞特殊,那么就不够模糊,如果大家都一样,那就是完全模糊,有更模糊,也有最模糊。比如上面的C小朋友可能稍有私心,毕竟幼儿园阿姐把糖分给我了嘛,那怎么分配就得我说了算,于是C小朋友按照其他小朋友离自己位置的远近进行分配,结果大家的糖果数变成了11、12、14、12、11,虽然不是完全平等,但是大家似乎也没有太多怨言,毕竟都多拿到糖了嘛,这样的分配方式虽然不是完全模糊,但是也相对模糊一些了。可见,模糊的方式也是多种多样的,至于高斯模糊,大概(并不)是高斯小时候分糖果使用的模糊方法吧。

回到图形学上来

  想感受图形图像上关于高斯模糊的应用非常简单,打开Photoshop,滤镜->模糊->高斯模糊。下面是投毒环节:



  可以很直观地看到经过模糊处理后的右图细节严重缺失,这也是模糊最大的特点,牺牲细节来换取数据上的平滑。仔细观察两幅图中的筷子,可以看到模糊后的筷子似乎变粗了一些,而且颜色变浅了一些,这就一点像我们之前说的小朋友分糖,糖最多的小朋友(像素)把自己的糖(颜色细节)分给了其他小朋友(其他像素),让数据看起来更加平滑,如果他选择第二种分糖方式还会保留自己的一部分细节。高斯模糊更接近第二种分糖方式。

  高斯模糊实际上是对周围像素进行加权平均的结果,所谓加权,就是有所偏向,就像第二种分糖方式,小朋友选择给离自己近(关系好)的小朋友多分一些,给离自己远(关系一般)的小朋友少分一切,当然自己分到的一定得是最多的。这样做既平滑了数据(自己不那么特殊)又保留了细节(自己拿到了更多的糖),这位小朋友不学图形学可惜了。

  对于图像来说,我们已经知道了要进行高斯模糊就要把每个像素和周围的像素进行加权平均,那么这个加权要加多少呢,这就要涉及正太分布了。

正太分布

  正太分布,又叫高斯分布。哈,终于真相了,高斯模糊就是利用高斯分布的方式对于图像上的每一点与其周围像素进行加权平均嘛。那是不是正太分布就是专门为图像模糊设计的呢,答案是否定的。事实上,正态分布是自然界中一种普遍存在的规律,主要用来描述随机变量的概率分布。举个简单的例子,比如一名运动员的百米成绩是10秒上下,那么我们取他多次训练成绩画出成绩-次数映射图像大概是这样:



  而且随着我们取样次数的增多统计结果会越来越趋近正太分布的曲线。把来源于自然界的数学规律应用到用于描述自然界的图像上,这大概也是高斯模糊的设计初衷吧。

  那么如果计算高斯模糊的具体权重呢,我们先来看一下高斯分布的密度函数,也称作高斯函数:

           f(x)=1σ2π√e−(x−μ)22σ2

  其中的μ表示高斯分布趋近的值,例如运动员的训练成绩趋近10秒,σ是该分布的方差,方差用来衡量分布的离散程度,方差越小分布越稳定,方差越大分布越离散。

  上面的公式其实只是一维高斯分布,因为毕竟只有一个自变量嘛,对于一幅图像来说,我们至少需要两个自变量x、y,同时不难发现,由于上面公式中的μ是正太分布趋近的一个值,那么公式中的x−μ就代表我们考虑的值(比如运动员的某次成绩)和它的趋近的成绩(10秒)的差距,对于要进行高斯模糊处理的一幅图像来说,显然我们要进行模糊的这个点就是μ,而用来和他平均的点就是公式中的x这样我们就可以得出对某一点P进行高斯模糊时其周围(也可能是自己)的点P0对应的权重:

           f(x,y)=1σ2π√e−(x2+y2)2σ2

  其中的x和y分别表示P0与P在x轴和y轴的距离。比如我们要对P方圆5x5个像素的范围进行高斯模糊,方差假定是1,那么各个点的权重应该是

0.00730.03270.05390.03270.0073
0.00730.03270.05390.03270.0073
0.00730.03270.05390.03270.0073
0.00730.03270.05390.03270.0073
0.00730.03270.05390.03270.0073
  计算这25个权重的和会发现是2.4610,而不是1,为了方便我们可以将每个数字除以这个总和,这样就可以得到周围每个点的比例:

0.00290.01330.02190.01330.0029
0.01330.05960.09830.05960.0133
0.02190.09830.16210.09830.0219
0.01330.05960.09830.05960.0133
0.00290.01330.02190.01330.0029
  数据来自我写的一个简单的计算器:http://download.csdn.net/download/dbtxdxy/10005887

  看上去似乎完美了,我们只需要对要处理的点和它周围的24个点进行取样,再将取样结果与上面的权重进行加权平均就好了。不过问题是,我们需要进行多达25次取样,如果需要更大的模糊半径的话,需要的采样次数将会以平方级增长,效率自然有点太差啦。一种相对优化的方式是将“方圆N*N”个点进行加权平均改为“对x方向周围N个点和y方向周围N个点分别进行加权平均”,这样只需要2*N次采样即可实现,也就是用第三行和第三列的权重进行加权平均。细心的同学可能发现了,第三行和第三列的数字以及顺序都是一样的,这是因为这些权重相同的点距离中心的距离是一样的,当然一定不要忘记把这些权重“归一”

实战环节

  告别了有趣(烦人)的理论环节,终于可以实践啦。在上篇屏幕特效的文章中我们写了一些C#代码,这些代码可以帮助我们检测设备是否支持屏幕特效,以及协助Shader完成屏幕特效实现的工作,但是这里面除了OnRenderImage方法外,其实其他代码都是通用的,所以今天我们对这些代码进行一下重构:

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

public Shader shader;
void Start() {
if (!_checkForSupport())
{
Debug.LogError("PostEffect Not Support!");
this.enabled = false;
}
}

private bool _checkForSupport()
{
return SystemInfo.supportsImageEffects && SystemInfo.supports3DRenderTextures;
}

private Material _material;
public Material material
{
get
{
if (_material == null)
{
if (shader == null)
return null;
_material = new Material(shader);
_material.hideFlags = HideFlags.DontSave;
}
return _material;
}
}
}


  这样我们以后的每个屏幕特效的C#代码就可以直接继承PostEffectBase ,专心实现OnRenderImage方法就可以了。

  最直接的使用高斯模糊的C#代码长这样:

private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (material != null)
{
int texWidth = source.width;
int texHeight = source.height;

RenderTexture buffer0 = RenderTexture.GetTemporary(texWidth, texHeight, 0);
buffer0.filterMode = FilterMode.Bilinear;
Graphics.Blit(source, buffer0, material, 0);
Graphics.Blit(buffer0, destination, material, 1);
RenderTexture.ReleaseTemporary(buffer0);
}
else
{
Graphics.Blit(source, destination);
}
}


  这里出现了新的方法GetTemporary和ReleaseTemporary,作用分别是创建和释放一张渲染纹理(RenderTexture)资源,这种方式产生的RenderTexture和OnRenderImage的参数中的资源本质上是一样的,因为我们的特效需要进行两次采样,一次水平方向一次垂直方向,因此需要用一个额外的RenderTexture作为中转。

  高斯模糊需要的Shader代码相对复杂些:

Shader "Hidden/GaussianBlur"
{
Properties
{
_MainTex("Main Texture", 2D) = ""{}
}

SubShader
{
ZTest Always Cull Off ZWrite Off

CGINCLUDE
sampler2D _MainTex;
float4 _MainTex_TexelSize;
#include "UnityCG.cginc"
struct v2f
{
float4 pos : SV_POSITION;
half2 uv[5] : TEXCOORD0;
};

v2f vertHorizon(appdata_img a)
{
v2f o;
o.pos = UnityObjectToClipPos(a.vertex);
o.uv[0] = a.texcoord + float2(-2, 0) * _MainTex_TexelSize.x;
o.uv[1] = a.texcoord + float2(-1, 0) * _MainTex_TexelSize.x;
o.uv[2] = a.texcoord + float2(0, 0) * _MainTex_TexelSize.x;
o.uv[3] = a.texcoord + float2(1, 0) * _MainTex_TexelSize.x;
o.uv[4] = a.texcoord + float2(2, 0) * _MainTex_TexelSize.x;
return o;
}

v2f vertVertical(appdata_img a)
{
v2f o;
o.pos = UnityObjectToClipPos(a.vertex);
o.uv[0] = a.texcoord + float2(0, -2) * _MainTex_TexelSize.y;
o.uv[1] = a.texcoord + float2(0, -1) * _MainTex_TexelSize.y;
o.uv[2] = a.texcoord + float2(0, 0) * _MainTex_TexelSize.y;
o.uv[3] = a.texcoord + float2(0, 1) * _MainTex_TexelSize.y;
o.uv[4] = a.texcoord + float2(0, 2) * _MainTex_TexelSize.y;
return o;
}

fixed4 frag(v2f v) : SV_TARGET
{
const float weight[5] = { 0.0544, 0.2442, 0.4027, 0.2442, 0.0544 };
fixed3 col = tex2D(_MainTex, v.uv[0]).rgb * weight[0];
col += tex2D(_MainTex, v.uv[1]).rgb * weight[1];
col += tex2D(_MainTex, v.uv[2]).rgb * weight[2];
col += tex2D(_MainTex, v.uv[3]).rgb * weight[3];
col += tex2D(_MainTex, v.uv[4]).rgb * weight[4];
return fixed4(col, 1);
}

ENDCG

Pass
{
Name "BlurHorizon"
CGPROGRAM
#pragma vertex vertHorizon
#pragma fragment frag
ENDCG
}

Pass
{
Name "BlurVertical"
CGPROGRAM
#pragma vertex vertVertical
#pragma fragment frag
ENDCG

}
}
}


  这里我们用了CGINCLUDE和ENDCD来组织代码,通过这种方式我们可以把常用的代码放进来实现在不同的Pass中的重复利用。由于Shader中经常会出现多个Pass的情况,使用这种方式组织代码可以有效地减少我们的代码篇幅,在这里目的是可以省去一段通用的fragment shader的代码。

  另一个需要解释的是_MainTex_TexelSize,它是以纹理名称后面加上“_TexelSize”组成,表示该纹理的纹素大小。那么什么是纹素呢,看名字就知道它和像素非常类似。我们知道像素是屏幕上一个具体的位置,我们可以说屏幕上从上往下第一行,第100个像素,但是不能说第100.2个像素,即使我们这样指定了,程序也会根据自己的规则将这个100.2转换成100或者101,同理,纹素是纹理空间的基本单位,我们的uv坐标必须是纹素大小的整数倍。所以Shader中两个Pass的不同就在于在vertex shader阶段计算了不同方向的纹素的坐标,然后在fragment shader中只要按照统一的方法采样、加权平均就可以了。

优化方式

  我们知道屏幕特效非常消耗性能,尽管我们通过优化的方式,将每个点的采样方式从N*N降低到了2*N,但是由于屏幕特效逐像素处理的特点,每帧需要进行的纹理采样的次数仍然惊人。一种优化的方式是,先将纹理渲染到低分辨率的RenderTexture上进行模糊处理,反正都是要进行模糊,先降低分辨率再模糊影响也不是太大嘛。

  优化后的C#代码长这样:

[Range(1, 3)]
public int LowQuality = 1;

private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (material != null)
{
int texWidth = source.width / LowQuality;
int texHeight = source.height / LowQuality;

RenderTexture buffer0 = RenderTexture.GetTemporary(texWidth, texHeight, 0);
buffer0.filterMode = FilterMode.Bilinear;
Graphics.Blit(source, buffer0);
RenderTexture buffer1 = RenderTexture.GetTemporary(texWidth, texHeight, 0);
buffer1.filterMode = FilterMode.Bilinear;
Graphics.Blit(buffer0, buffer1, material, 0);
RenderTexture.ReleaseTemporary(buffer0);
Graphics.Blit(buffer1, destination, material, 1);
RenderTexture.ReleaseTemporary(buffer1);
}
else
{
Graphics.Blit(source, destination);
}
}


  注意Shader采样是从源纹理进行采样,所以我们要先把source纹理Blit到低分辨率的buffer0中再进行模糊处理,否则就失去意义了。

结束语

  学习图形学的过程永远避不开数学。猫猫也曾是个视数学为洪水猛兽的人,但是图形学让我有了面对数学的勇气,同时也让我认识到了“哇,原来数学这么有用”,所以那些我们害怕或者不敢接受的东西,是不是只是没有认识到它的价值呢?
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  unity 高斯模糊