第三篇,话说第三篇隔了很久,因为之前被一些小问题折磨了半天,所以写的比较晚,但是不管咋地好歹解决了,所以接着写这一篇博客。嗯,感觉这篇文章做一些代码保存和小标记的意味更大一些,所以会着重写一下自己在代码中遇见的坑和感悟。
调色
后处理调色的代码中,比较基础的调色代码无非就是调整屏幕RenderTexture的三个属性:
1.亮度:通过调整当前采样得到的颜色的值即可,我们可以对输出颜色左乘一个值来实现
2.饱和度:饱和度存在经验公式,同等条件下,一个颜色饱和度最低的情况为(假设颜色为Color):
0.2125*Color.r+0.7154*Color.g+0.0721*Color.b
那么我们只需要将当前最低的灰度值与源颜色进行线性插值,然后调整我们需要的饱和度权重比。就可以获得饱和度调整后的颜色。
3.对比度:与饱和度调整相似,颜色对比度最低的情况下就是灰色,即(0.5,0.5,0.5),同样的,与当前颜色进行插值后调整权重比即可。
关于其他的代码这里就不啰嗦了,我们直接上Shader:
Shader "Hidden/EditColor"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Brightness("Brightness",float)=1
_Saturation("Saturation",float)=1
_Contrast("Contrast",float)=1
}
SubShader
{
Cull Off
ZTest Always
ZWrite Off
Pass
{
CGPROGRAM
#pragma vertex myVertex
#pragma fragment myFragment
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
float _Brightness;
float _Saturation;
float _Contrast;
struct VertexData
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct VertexToFragment
{
float2 Mainuv : TEXCOORD0;
float4 pos : SV_POSITION;
};
VertexToFragment myVertex(VertexData v)
{
VertexToFragment VToF;
VToF.pos=UnityObjectToClipPos(v.vertex);
VToF.Mainuv=v.uv;
return VToF;
}
fixed4 myFragment(VertexToFragment VToF) : SV_Target
{
fixed3 renderTex=tex2D(_MainTex,VToF.Mainuv).rgb;
fixed3 finalColor=renderTex.rgb*_Brightness;
fixed luminance=0.2125*renderTex.r+0.7154*renderTex.g+0.0721*renderTex.b;
fixed3 luminanceColor=float3(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,1);
}
ENDCG
}
}
Fallback Off
}
这个后处理的情况比较简单,我们需要注意的是,在这里的Lerp函数权重比需要超过1,来获得比原来颜色更饱和或者对比度更高的情况。Lerp公式在纹理那一篇已经讲过了,原理是:
Lerp(a,b,t)= a * t +( 1 - t )* b
所以它可以超过1,但是在C#中,一般来说t都被限制在了0到1之间,这里反而是需要0到t之间来取值,以此来获得更高的饱和度或者灰度值。
纹素
这一点非常重要,也是下面的模糊和其他效果的基石,我们在上一篇文章最后的玻璃折射中用到了纹素TexelSize来获得相邻像素的颜色,但是并没有说明具体的原理。
纹素实际上来说就是纹理中一个像素的大小:例如一个宽为width、高为height的纹理图_Texture,那么它的纹素_Texture_TexelSize的四个值分别为:
- x=1/width
- y=1/height
- z=width
- w=height
但是这个值看起来好像没什么用,但实际上,纹素是当前需要采样的图片的像素单位,如果将以该像素为原点作为一个二维的坐标系,原点即(0,0),在代码中对应的即为顶点着色器插值后的片元着色器的像素坐标v.uv。那么它左上角的点的坐标为(1,1)*_Texture_TexelSize.xy,右下角的坐标为(-1,-1)*_Texture_TexelSize.xy。推而广之坐标为的xy的像素值在代码中即为:
float2(x,y)*_Texture_TexelSize.xy
通过这种方法,我们可以获得任何我们已知坐标的像素点的颜色,我们写一个小例子,通过抓取屏幕图片像素并获得偏移,然后绘制到一张平面上:
Shader "Hidden/TexelSize"
{
Properties
{
_OffsetX("OffsetX",float)=10
_OffsetY("OffsetY",float)=10
}
SubShader
{
Tags
{
"Queue"="Transparent"
}
GrabPass{"_ScreenTexture"}
Pass
{
CGPROGRAM
#pragma vertex myVertex
#pragma fragment myFragment
#include "UnityCG.cginc"
sampler2D _ScreenTexture;
float4 _ScreenTexture_TexelSize;
float _OffsetX;
float _OffsetY;
struct VertexData
{
float4 vertex : POSITION;
};
struct VertexToFragment
{
float4 pos : SV_POSITION;
float4 srcPos:TEXCOORD1;
};
VertexToFragment myVertex(VertexData v)
{
VertexToFragment VToF;
VToF.pos = UnityObjectToClipPos(v.vertex);
VToF.srcPos=ComputeGrabScreenPos(VToF.pos);
return VToF;
}
fixed4 myFragment(VertexToFragment VToF) : SV_Target
{
fixed4 getColor=tex2D(_ScreenTexture,VToF.srcPos.xy/VToF.srcPos.w+float2(_OffsetX,_OffsetY)*_ScreenTexture_TexelSize);
return getColor;
}
ENDCG
}
}
}
我们当前坐标的原点即为[0,1]范围内的NDC,并且我们手动设置偏移量,例如我们这里设置的偏移量如下,那么就能看见我们偏移后的屏幕展现在一个图片上:
模糊效果
模糊效果应该是我最喜欢的效果了,记得初中的时候IOS7发布,那个状态栏有毛玻璃效果,我当时惊为天人,觉得好看得不行,所以当时拿着自己的联想疯狂刷机就为了那个毛玻璃效果,然而最后用MIUI刷出了一个毛玻璃版本高兴了一整天。这个毛玻璃就是我们所使用的高斯模糊,这篇文章的重点也是各种模糊效果。
我们根据纹素就可以写模糊效果了。在Shader中需要使用卷积核来对每个像素进行处理,所谓卷积核即为一个N维矩阵(一般n为3或者5),我们当前像素即为卷积核的中心点,然后对四周的像素的颜色值分别乘以卷积核中对应的值,相加即可算出中心像素值。意思也就是,我们采样到一个四周的像素,要使用对应的卷积核中的值与之相乘,然后让最终结果与之相加。
在边缘检测中,卷积核可以算出该像素与周围像素的梯度值。在模糊中,卷积核可以算出该像素与周围像素的模糊值。
卷积核可以通过一些方法来获得,例如均值模糊,它的卷积核上的每个值都为它四周采样像素数分之一,这样说起来比较装逼,其实也就是四周的像素颜色之和除以采样的像素总量(求平均数),所以一个3x3的均值模糊卷积核如下:
高斯模糊的卷积核的由来为高斯分布公式(也就是概率论里面学过的正态分布),其中σ为标准方差(一般为1),x和y对应了当前位置到像素中心的整数距离,得到的值即为该像素对应的卷积核的值:
每个点的对应的值即为高斯分布对应的值,我们当知道一个临近像素的坐标的时候,完全可以自己代入公式算出高斯核对应的值(在下文中的平面高斯模糊就使用了这种方法),在《入门精要》里,已经给出了一个算好的高斯核:
这个高斯核看起来很复杂,但用起来可以很憨憨(下文中后处理高斯模糊就是憨憨方法写的),但是实际上并不需要这么做,对于一个高斯核来说可以进行简化,我们可以让它转换成两个一维高斯核:
这两个一维高斯核的获得很简单,只是原本的二维高斯核将每行每列的值往中间相加就可以得到这两个一维高斯核的状态,我们可以使用这两个一维高斯核代替上文中的二维高斯核采样,来节省计算的复杂度,我们在Bloom效果的模糊中间应用了这种写法。
憨憨的高斯模糊:
为了比较清楚的应用二维高斯核,我们写一个憨憨的写法,手动计算每个像素的5x5高斯核结果,这个应该是人类史上最无脑的一个Shader了,来自于我踩坑后的破罐破摔:
Shader "Hidden/MyCussainBlur"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_BlurSize("BlurSize",float)=1.0
}
SubShader
{
Cull Off ZWrite Off ZTest Always
Pass
{
CGPROGRAM
#pragma vertex myVertex
#pragma fragment myFragment
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_TexelSize;
float _BlurSize;
float _BlurSpread;
struct VertexData
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct VertexToFragment
{
float4 pos : SV_POSITION;
float2 getuv:TEXCOORD6;
};
VertexToFragment myVertex(VertexData v)
{
VertexToFragment VToF;
VToF.pos = UnityObjectToClipPos(v.vertex);
VToF.getuv=v.uv;
return VToF;
}
fixed4 ComputeCussainBlurSize(VertexToFragment VToF)
{
fixed4 getColor=fixed4(0,0,0,0);
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 2,-2)*_MainTex_TexelSize.xy)*0.0030;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 2,-1)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 2, 0)*_MainTex_TexelSize.xy)*0.0219;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 2, 1)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 2, 2)*_MainTex_TexelSize.xy)*0.0030;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 1,-2)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 1,-1)*_MainTex_TexelSize.xy)*0.0596;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 1, 0)*_MainTex_TexelSize.xy)*0.0983;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 1, 1)*_MainTex_TexelSize.xy)*0.0596;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 1, 2)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 0,-2)*_MainTex_TexelSize.xy)*0.0219;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 0,-1)*_MainTex_TexelSize.xy)*0.0983;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 0, 0)*_MainTex_TexelSize.xy)*0.1621;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 0, 1)*_MainTex_TexelSize.xy)*0.0983;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2( 0, 2)*_MainTex_TexelSize.xy)*0.0219;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-1,-2)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-1,-1)*_MainTex_TexelSize.xy)*0.0596;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-1,-0)*_MainTex_TexelSize.xy)*0.0983;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-1, 1)*_MainTex_TexelSize.xy)*0.0596;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-1, 2)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-2,-2)*_MainTex_TexelSize.xy)*0.0030;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-2,-1)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-2, 0)*_MainTex_TexelSize.xy)*0.0219;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-2, 1)*_MainTex_TexelSize.xy)*0.0133;
getColor+=tex2D(_MainTex,VToF.getuv+_BlurSize*float2(-2, 2)*_MainTex_TexelSize.xy)*0.0030;
return getColor;
}
fixed4 myFragment(VertexToFragment VToF) : SV_Target
{
return ComputeCussainBlurSize(VToF);
}
ENDCG
}
}
}
调整_BlurSize来设置像素采样范围得到的效果只能说有点“高斯近视”的感觉(如下图),还远远达不到高斯模糊的效果,我们接着需要对屏幕像素RenderTexture进行降采样和反复渲染。
降采样:由于片元着色器会对屏幕上所有的像素都走一遍流程,降采样可以有效减少后处理需要处理的像素数量,而且我们这里本来就是需要模糊屏幕,完成这样既优化了渲染效率,也能让模糊效果更好。我们只需要设定好降采样的范围,然后手动的调整即可。
反复渲染:由于一次的效果再怎么样也无法达到我们需要的高斯模糊,所以我们需要反复渲染这个效果来实现比较准确的模糊效果,在后处理中,我们使用两张RenderTexture来反复渲染(就像调酒一样),最后再将反复渲染的模糊效果渲染到屏幕上:
int rtW = src.width / downSample * 2;
int rtH = src.height / downSample * 2;
RenderTexture RT1 = RenderTexture.GetTemporary(rtW, rtH, 0, src.format);
RenderTexture RT2 = RenderTexture.GetTemporary(rtW, rtH, 0, src.format);
material.SetFloat("_BlurSize", SimpleBlurSize);
Graphics.Blit(src, RT1);
for (int i = 0; i < iterations; i++)
{
Graphics.Blit(RT1, RT2, material);
Graphics.Blit(RT2, RT1, material);
}
Graphics.Blit(RT1, dest);
RenderTexture.ReleaseTemporary(RT1);
RenderTexture.ReleaseTemporary(RT2);
我们将Material设置为我们的Shader,通过计算以像素为中心的5x5范围内的所有像素并乘以高斯核对应的值,来得到高斯模糊后的像素结果,再通过降采样和反复渲染来提高模糊质量,这应该是最简单的高斯模糊效果的写法了,也是二维高斯核最直观的应用,效果如下图:
Bloom溢光效果:
Bloom效果基于我们的高斯模糊来构建,但是我们高斯模糊的目标并不是整个屏幕像素,而是光线比较亮的地方,我们需要使用一个专门的Pass来完成对光线比较亮的位置的采样,具体分为三个Pass
Pass1:通过采样得到的颜色值的灰度值减去我们设定好的阈值,得到的[0,1]之间的差,用它乘以我们当前获得颜色,如果低于差小于0则当前输出的颜色即为黑色,那么非常亮的位置得到的差为1,输出的颜色就比较亮。最终会得到一个只有屏幕亮度部位的贴图,例如我们上面那个屏幕的亮度图如下:
Pass2:对屏幕亮度部位的贴图进行高斯模糊的处理,这里我们不用上文的铁憨憨写法了,我们使用两个一维高斯核来进行采样,这样的话效率要高不少。
Pass3:对以上两张图的结果进行混合来得到最终的效果,简单地输出两张图的采样颜色相加即可。
Pass分为三块,但是有些变量需要共用,例如_MainTex这个后处理的基石属性,所以我们使用CGINCLUDE来把所有的代码“挤”在一起,在Pass中只指明调用:
Shader代码:
Shader "Hidden/Bloom"
{
Properties
{
_MainTex("MainTex",2D)="white"{}
_BloomTexture("BloomTexture",2D)="black"{}
_LuminanceThreshold("LuminanceThreshold",float)=0.5
}
SubShader
{
// No culling or depth
Cull Off ZWrite Off ZTest Always
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _BloomTexture;
float4 _BloomTexture_TexelSize;
int _BlurSize;
float _LuminanceThreshold;
struct VertexData
{
float4 vertex:POSITION;
float2 uv:TEXCOORD0;
};
struct VertexToFragmentBright
{
float4 pos:SV_POSITION;
float2 uv:TEXCOORD0;
};
VertexToFragmentBright vertexBright(VertexData v)
{
VertexToFragmentBright VToFB;
VToFB.pos=UnityObjectToClipPos(v.vertex);
VToFB.uv=v.uv;
return VToFB;
}
float GetLuminance(fixed4 setColor)
{
return 0.2125*setColor.r+0.7154*setColor.g+0.0721*setColor.b;
}
fixed4 fragmentBright(VertexToFragmentBright VToFB):SV_TARGET
{
fixed4 getColor=tex2D(_MainTex,VToFB.uv);
fixed val=saturate(GetLuminance(getColor)-_LuminanceThreshold);
return getColor*val;
}
//
struct VertexToFragmentBloom
{
float4 pos:SV_POSITION;
float2 MainUV:TEXCOORD0;
float2 BloomUV:TEXCOORD1;
};
VertexToFragmentBloom vertexBloom(VertexData v)
{
VertexToFragmentBloom VToFB;
VToFB.pos=UnityObjectToClipPos(v.vertex);
VToFB.MainUV=v.uv;
VToFB.BloomUV=v.uv;
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y<0.0)
VToFB.BloomUV.y=1.0-VToFB.BloomUV.y;
#endif
return VToFB;
}
fixed4 FragmentBloom(VertexToFragmentBloom VToFB):SV_TARGET
{
return tex2D(_MainTex,VToFB.MainUV)+tex2D(_BloomTexture,VToFB.BloomUV);
}
//
struct VertexToFragmentBlur
{
float4 pos:SV_POSITION;
float2 uv[5]:TEXCOORD0;
};
VertexToFragmentBlur vertexBlur(VertexData v)
{
VertexToFragmentBlur VToFB;
VToFB.pos=UnityObjectToClipPos(v.vertex);
VToFB.uv[0]=v.uv;
VToFB.uv[1]=float2(1,0)*_MainTex_TexelSize.xy*_BlurSize;
VToFB.uv[2]=float2(2,0)*_MainTex_TexelSize.xy*_BlurSize;
VToFB.uv[3]=float2(0,1)*_MainTex_TexelSize.xy*_BlurSize;
VToFB.uv[4]=float2(0,2)*_MainTex_TexelSize.xy*_BlurSize;
return VToFB;
}
fixed4 fragmentBlur(VertexToFragmentBlur VToFB):SV_TARGET
{
float weight[3]={0.4026,0.2442,0.0545};
fixed4 getColor=tex2D(_MainTex,VToFB.uv[0])*weight[0];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[1])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[1])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[2])*weight[2];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[2])*weight[2];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[3])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[3])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[4])*weight[2];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[4])*weight[2];
return getColor*0.626;
}
ENDCG
Pass
{//0
CGPROGRAM
#pragma vertex vertexBright
#pragma fragment fragmentBright
ENDCG
}
pass
{//1
CGPROGRAM
#pragma vertex vertexBlur
#pragma fragment fragmentBlur
ENDCG
}
pass
{//2
CGPROGRAM
#pragma vertex vertexBloom
#pragma fragment FragmentBloom
ENDCG
}
}
}
这里有两点需要我们注意:
1. 由于DirectX和OpenGL的屏幕坐标空间存在差异,OpenGL的屏幕原点位于屏幕的左下角,DirectX 的屏幕原点位于屏幕的左上角,如图:
Unity基于OpenGL,当在DirectX的平台进行后处理时,会为我们翻转屏幕图像 ,以达到不同平台的一致性。所以上面的Shader都没有针对屏幕坐标系进行翻转的代码。
如果在DirectX平台下,当我们开启抗锯齿并且进行后处理时,Unity首先会渲染得到屏幕图像,然后硬件再进行抗锯齿处理,然后再执行后处理Shader。如果我们的后处理Shader只是将屏幕处理完后就输出,那么仍然不会出现问题(Graphic.Blit为我们处理了纹理采样坐标)。但是如果需要处理多张纹理(例如我们例子中的亮度的模糊纹理或者是深度或者法线纹理)并且开启抗锯齿的时候,图像纹素的y分量是反的。我们此时需要手动翻转我们的纹理坐标,使之符DirectX的规则,我们通过在顶点着色器中写如下代码:
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y<0.0)
VToFB.BloomUV.y=1.0-VToFB.BloomUV.y;
#endif
通过宏 UNITY_UV_STARTS_AT_TOP来判断是否为DirectX平台,再通过纹素值是否为负来确定是否开启了抗锯齿,如果二者都符合,则手动翻转纹理坐标的y轴。
2.我们观察上文中的二维高斯卷积核,能发现卷积核所有值的和为1,同样的两个一维高斯核的和也各为1。如果我们使用两个一维卷积核合同时输出时,最终的颜色的和的对应的卷积核权重为2,那么屏幕会由于颜色不正常过暗。
我们如果不使用直接使用二维高斯核(上文中的憨憨方法),或者分开使用一维高斯核(《入门精要》中的例子),所以每次的输出权重都能听保证1。而是混合两个一维高斯核(本例),或者直接使用高斯分布公式(下文中的平面模糊),此时每个高斯权重不可控制,必须将权重归一化才能输出正确的模糊效果,否则屏幕会过暗或过亮。
一般来说可以让高斯核中的每个权重除以权重的总和来实现权重归一化。如果我们手动输入某个值来修正权重也是可行的,这样能减少计算,但是使用权重总和往往要更准确和易于控制。我们上面的例子的归一化的值是我自己测试出来的,为0.626。两中方法我们视情况而使用就好。
C#代码:后处理脚本中我们首先将Pass获得的亮度纹理存储在一张RenderTexture中(调用Pass1),然后再和上文一样降采样和反复混合来获得亮度纹理的高斯模糊效果(调用Pass2),再将它赋值到Shader中,与当前的屏幕纹理一并输出(调用Pass3):
[Header("Bloom效果:图像亮度范围:"), Range(0, 4)]
public float LuminanceThreshold = 0.6f;
//省略若干代码
material.SetFloat("_LuminanceThreshold", LuminanceThreshold);
int rtWidth = src.width / BlurDownSample;
int rtHeight = src.height / BlurDownSample;
RenderTexture Buffer0 = RenderTexture.GetTemporary(rtWidth, rtHeight, 0);
RenderTexture Buffer1 = RenderTexture.GetTemporary(rtWidth, rtHeight, 0);
Buffer0.filterMode = FilterMode.Bilinear;
Graphics.Blit(src, Buffer0, material, 0);
for (int i = 0; i < BlurIterator;i++)
{
material.SetFloat("_BlurSize", GussainBlurSize);
Graphics.Blit(Buffer0, Buffer1, material, 1);
Graphics.Blit(Buffer1, Buffer0, material, 1);
}
material.SetTexture("_BloomTexture", Buffer0);
Graphics.Blit(src, dest, material, 2);
RenderTexture.ReleaseTemporary(Buffer0);
RenderTexture.ReleaseTemporary(Buffer1);
考虑到HDR的情况,此处的亮度阈值设置为0到4之间,并且我们需要注意每次调用时的Pass是从0开始算的。然后我们就可以得到一个好看的Bloom溢光效果,就是红圈里那个:
我们在FrameDebugger里面,可以看到在后处理里面被高斯模糊的亮度贴图:
有这个贴图除了亮度的部分其他的都是黑色的,所以可以放心地与屏幕纹理叠加而不用关心过曝的问题。
平面模糊
我们的模糊效果当然也可以用在平面上,但是由于平面上的一个物体就是一个独立的Shader了,所以不能降采样或者反复渲染来增加模糊层级了(不过仍然可以增加一个摄像机再调用RenderImage,不过我自己试过感觉效果不太好,有点延迟),我们可以通过对采样的数量进行调整来代替降采样和反复渲染的手段。
平面均值模糊:
我们先写一个平面的均值模糊,由于均值模糊每次的采样的像素的权重都相同,我们如果将均值模糊看成是一个我们自己指定大小的卷积核,那么这个N*N的卷积核的每一个分量权重都是1/(N*N)。在实际的写法中,我们可以使用一个For循环来调整采样像素的数量,最后除以采样像素的总量即可。
Shader "Hidden/PlanBlur"
{
Properties
{
_BlurSize("BlurSize",int)=1.0
}
SubShader
{
Cull Off
GrabPass{"_GrabTexture"}
Tags
{
"Queue"="Transparent"
}
Pass
{
CGPROGRAM
#pragma vertex vertexBlur
#pragma fragment fragmentBlur
#include "UnityCG.cginc"
struct VertexData
{
fixed4 vertex:POSITION;
float4 uv:TEXCOORD0;
};
struct VertexToFragment
{
float4 pos:SV_POSITION;
float4 uv:TEXCOORD0;
float4 srcPos:TEXCOORD1;
};
sampler2D _GrabTexture;
float4 _GrabTexture_TexelSize;
float _BlurSize;
float _TextureSize;
VertexToFragment vertexBlur(VertexData v)
{
VertexToFragment VToF;
VToF.pos=UnityObjectToClipPos(v.vertex);
VToF.srcPos=ComputeGrabScreenPos(VToF.pos);
VToF.uv=v.uv;
return VToF;
}
fixed4 GetBlurColor(VertexToFragment VToF)
{
int count=_BlurSize*2;
count*=count;
float4 colorTmp=float4(0,0,0,0);
for(int x=-_BlurSize;x<=_BlurSize;x++)
{
for(int y=-_BlurSize;y<=_BlurSize;y++)
{
float4 color=tex2D(_GrabTexture,VToF.srcPos.xy/VToF.srcPos.w+float2(x,y)*_GrabTexture_TexelSize);
colorTmp+=color;
}
}
return colorTmp/count;
}
fixed4 fragmentBlur(VertexToFragment VToF):SV_TARGET
{
fixed4 getColor=GetBlurColor(VToF);
return getColor;
}
ENDCG
}
}
}
均值模糊的效果如下图,虽然已经能获得比较好的模糊效果,但是和高斯模糊一比还是存在差距(不过我觉得对于平面来说均值模糊已经是最好的选择):
平面高斯模糊:
我们在上面后处理的高斯模糊就可以看到,高斯模糊的每个值来源于高斯分布公式。平面的高斯分布可以手动带入高斯分布公式来计算权值:
此时如果我们归一化的时候不使用一个手动的值去调整,而是比较“专业”地使用总权值对每个权值进行修正。那么由于除法的左边的1/2πσ每次都会被约掉,所以标准归一化时的高斯模糊公式为:
然后我们计算时动态地调整采样的像素数量就可以调整出比较好的平面高斯模糊了,我们给出代码:
Shader "Unlit/PlanCussainBlur"
{
Properties
{
_BlurSize("BlurSize",int)=1.0
_BlurAmount("BlurAmount",Range(0,10))=0
_BlurWeight("BlurWeight",Range(0.0,1.0))=1.0
}
SubShader
{
Tags
{
"Queue"="Transparent"
}
GrabPass
{
"_ScreenTexture"
}
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _ScreenTexture;
float4 _ScreenTexture_TexelSize;
int _BlurSize;
float _BlurAmount;
float _BlurWeight;
struct VertexData
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct VertexToFragment
{
float4 pos : SV_POSITION;
float4 srcPos:TEXCOORD1;
};
VertexToFragment myVertex(VertexData v)
{
VertexToFragment VToF;
VToF.pos = UnityObjectToClipPos(v.vertex);
VToF.srcPos=ComputeGrabScreenPos(VToF.pos);
return VToF;
}
fixed getGussainColor(float x,float y)
{
//fixed left=1/(2*3.141592);
return exp(-(x*x+y*y)/2);
}
fixed4 myFragment(VertexToFragment VToF):SV_TARGET
{
float2 screenUV=VToF.srcPos.xy/VToF.srcPos.w;
fixed weightFinal=0.0;
fixed weight=0.0;
fixed4 getColor=fixed4(0,0,0,0);
for(int i=-_BlurSize;i<=_BlurSize;i++)
{
for(int j=-_BlurSize;j<=_BlurSize;j++)
{
weightFinal+=getGussainColor(i*_ScreenTexture_TexelSize.x,j*_ScreenTexture_TexelSize.y);
}
}
for(int i=-_BlurSize;i<=_BlurSize;i++)
{
for(int j=-_BlurSize;j<=_BlurSize;j++)
{
weight=getGussainColor(i*_ScreenTexture_TexelSize.x,j*_ScreenTexture_TexelSize.y)/weightFinal;
getColor+=tex2D(_ScreenTexture,screenUV+float2(i,j)*_ScreenTexture_TexelSize.xy*_BlurAmount)*weight;
}
}
//return fixed4(getColor.rgb*_BlurWeight,1.0);
return fixed4(getColor.rgb,1.0);
}
ENDCG
Pass
{
CGPROGRAM
#pragma vertex myVertex
#pragma fragment myFragment
ENDCG
}
}
}
这样虽然比均值模糊的计算要复杂得多得多,但是耐不住效果好,下图左边是高斯模糊,右边是均值模糊,可以看出高斯模糊要比均值模糊顺滑一丢丢:
一般说来,GPU并不推荐我们写流程控制语句,这样两个For循环要放在其他语言的算法题里有可能都超出了时间复杂度了,更何况里面还存在复杂的计算。所以我们也可以舍弃第一个for循环,而是手动使用一个值_BlurWeight来归一化,上面的代码我们注释掉第一个for循环,改成这样:
fixed4 myFragment(VertexToFragment VToF):SV_TARGET
{
float2 screenUV=VToF.srcPos.xy/VToF.srcPos.w;
//fixed weightFinal=0.0;
fixed weight=0.0;
fixed4 getColor=fixed4(0,0,0,0);
//for(int i=-_BlurSize;i<=_BlurSize;i++)
//{
// for(int j=-_BlurSize;j<=_BlurSize;j++)
// {
// weightFinal+=getGussainColor(i*_ScreenTexture_TexelSize.x,j*_ScreenTexture_TexelSize.y);
// }
//}
for(int i=-_BlurSize;i<=_BlurSize;i++)
{
for(int j=-_BlurSize;j<=_BlurSize;j++)
{
weight=getGussainColor(i*_ScreenTexture_TexelSize.x,j*_ScreenTexture_TexelSize.y);///weightFinal;
getColor+=tex2D(_ScreenTexture,screenUV+float2(i,j)*_ScreenTexture_TexelSize.xy*_BlurAmount)*weight;
}
}
return fixed4(getColor.rgb*_BlurWeight,1.0);
//return fixed4(getColor.rgb,1.0);
}
那么我们需要手动设置_BlurWeight来修正高斯核权重,我自己试了半天,得出的值是0.0044,虽然这样做感觉有点野狐禅,但实际效果还是杠杠滴(有一说一确实):
如果这样的for循环你仍然不喜欢,还可以加个副相机(假设为Camera2)专门渲染没有这个Plan的情况,将Camera2的RenderTexture传入OnRenderImage函数,然后再使用原来后处理的方法将模糊的图像渲染到平面即可。
附录:简单边缘检测
同样的,卷积核也不止高斯卷积核一种,在后处理边缘检测的时候同样可以使用卷积核来计算。
如果一个像素与其他相邻的像素之间存在明显差别的颜色亮度或者纹理,它们之间的差值一定比较大。在Shader中,这样的差值被称为梯度,像素差别越大的地方,梯度值越明显。
我们得到梯度的方式即为使用卷积核来对周围像素采样,与上面的高斯核不同的是,边缘检测的卷积核分为X轴和Y轴两个方向。同样的,我们也需要为每个像素的周遭像素计算X分量和Y分量:
我们在计算边缘梯度值时,只需要让1减去得出来一个像素的卷积核的X分量和Y分量的绝对值即可获得最终该像素的周遭梯度值,然后我们再让它与当前屏幕和我们既定的边缘颜色进行插值即可得到最终的边缘效果。
我们这里使用Prewitt卷积核,相较起来,它要比Sobel卷积核的边缘更淡一点点:
Shader "Hidden/EditEdge"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_EdgeOnly("EdgeOnly",float)=1
_EdgeColor("EdgeColor",Color)=(1,1,1,1)
_BackgroundColor("BackGroundColor",Color)=(1,1,1,1)
}
SubShader
{
// No culling or depth
Cull Off ZWrite Off ZTest Always
Pass
{
CGPROGRAM
#pragma vertex myVertex
#pragma fragment myFragment
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
float4 _EdgeColor;
float _EdgeOnly;
float4 _BackgroundColor;
struct VertexData
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct VertexToFragment
{
half2 uv[9]:TEXCOORD0;
float4 pos : SV_POSITION;
};
VertexToFragment myVertex(VertexData v)
{
VertexToFragment VToF;
VToF.pos=UnityObjectToClipPos(v.vertex);
half2 uv=v.uv;
VToF.uv[0]=uv+_MainTex_TexelSize.xy*half2(-1,-1);
VToF.uv[1]=uv+_MainTex_TexelSize.xy*half2(0,-1);
VToF.uv[2]=uv+_MainTex_TexelSize.xy*half2(1,-1);
VToF.uv[3]=uv+_MainTex_TexelSize.xy*half2(-1,0);
VToF.uv[4]=uv+_MainTex_TexelSize.xy*half2(0,0);
VToF.uv[5]=uv+_MainTex_TexelSize.xy*half2(1,0);
VToF.uv[6]=uv+_MainTex_TexelSize.xy*half2(-1,1);
VToF.uv[7]=uv+_MainTex_TexelSize.xy*half2(0,1);
VToF.uv[8]=uv+_MainTex_TexelSize.xy*half2(1,1);
return VToF;
}
fixed4 luminance(fixed4 color)
{
return 0.2125*color.r+0.7154*color.g+0.0721*color.b;
}
fixed4 Sobel(VertexToFragment VToF)
{
const half GX[9]={-1,-2,-1,
0,0,0,
1,2,1};
const half GY[9]={-1,0,1,
-2,0,2,
-1,0,1};
//两个卷积核
half texColor;
half edgeX=0;
half edgeY=0;
for(int it=0;it<9;it++)
{
texColor=tex2D(_MainTex,VToF.uv[it]);
edgeX+=texColor*GX[it];
edgeY+=texColor*GY[it];
}
half edge=1-abs(edgeX)-abs(edgeY);
return edge;
}
fixed4 Prewitt(VertexToFragment VToF)
{
const half GX[9]={-1,-1,-1,
0,0,0,
1,1,1};
const half GY[9]={-1,0,1,
-1,0,1,
-1,0,1};
//两个卷积核
half texColor;
half edgeX=0;
half edgeY=0;
for(int it=0;it<9;it++)
{
texColor=luminance(tex2D(_MainTex,VToF.uv[it]));
edgeX+=texColor*GX[it];
edgeY+=texColor*GY[it];
}
half edge=1-abs(edgeX)-abs(edgeY);
return edge;
}
fixed4 myFragment(VertexToFragment VToF) : SV_Target
{
half edge=Sobel(VToF);
fixed4 withEdgeColor=lerp(_EdgeColor,tex2D(_MainTex,VToF.uv[4]),edge);
fixed4 onlyEdgeColor=lerp(_EdgeColor,_BackgroundColor,edge);
return lerp(withEdgeColor,onlyEdgeColor,_EdgeOnly);
}
ENDCG
}
}
}
中间的顶点着色器的是一个比较憨憨的获得当前像素的方法,然后我们将像素坐标与对应的卷积核的权重相乘,得到卷积后的X和Y方向上的分量。最终我们可以看到画面效果:
这样的效果基于屏幕的颜色而来,并不是一个比较标准的边缘检测,如果我们需要比较准确的边缘,需要使用深度法线纹理来采样才能得到比较准确的效果(我觉得我明年这个时候才能讲清楚如何使用深度法线纹理)。但是这个效果我自己挺喜欢的,有点浮雕的感觉,给力。