接上篇:UnityShader学习——屏幕后处理效果(亮度等、边缘检测)
3.高斯模糊
模糊的实现有很多方法,例如均值模糊和中值模糊。均值模糊同样使用了卷积操作,它使用的卷积核中的各个元素值都相等,且相加等于1,也就是说,卷积后得到的像素值是其邻域内各个像素值的平均值。而中值模糊则是选择邻域内对所有像素排序后的中值替换掉原颜色。一个更高级的模糊方法是高斯模糊。
高斯模糊同样利用了卷积计算,它使用的卷积核名为高斯核。高斯核是一个正方形大小的滤波核,其中每个元素的计算都是基于下面的高斯方程:
其中,σ
是标准方差(一般取值为1), x
和y
分别对应了当前位置到卷积核中心的整数距离。要构建一个高斯核,我们只需要计算高斯核中各个位置对应的高斯值
。为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化
,即让每个权重除以所有权重的和,这样可以保证所有权重的和为1。因此,高斯函数中e前面的系数实际不会对结果有任何影响。
高斯方程很好地模拟了邻域每个像素对当前处理像素的影响程度——距离越近,影响越大
。高斯核的维数越高,模糊程度越大。使用一个NxN的高斯核对图像进行卷积滤波,就需要N×N×W×H(W和H分别是图像的宽和高)次纹理采样。当N的大小不断增加时,采样次数会变得非常巨大。幸运的是,我们可以把这个二维高斯函数拆分成两个一维函数。也就是说,我们可以使用两个一维的高斯核先后对图像进行滤波
,它们得到的结果和直接使用二维高斯核是一样的,但采样次数只需要2×N×W×H。两个一维高斯核中包含了很多重复的权重。对于一个大小为5的一维高斯核,我们实际只需要记录3个权重值即可。
我们将先后调用两个Pass,第一个Pass将会使用竖直方向的一维高斯核对图像进行滤波,第二个Pass再使用水平方向的一维高斯核对图像进行滤波,得到最终的目标图像。在实现中,我们还将利用图像缩放来进一步提高性能,并通过调整高斯滤波的应用次数来控制模糊程度(次数越多,图像越模糊)。
摄像机脚本关键代码:
在高斯核维数不变的情况下,_BlurSize越大,模糊程度越高,但采样数却不会受到影响。但过大的_BlurSize值会造成虚影,这可能并不是我们希望的。downSample越大,需要处理的像素数越少,同时也能进一步提高模糊程度,但过大的downSample可能会使图像像素化。
......
public class GaussianBlur : PostEffectsBase {
......
[Range(0, 4)] public int iterations = 3; //高斯模糊迭代次数
[Range(0.2f, 3.0f)] public float blurSpread = 0.6f;//模糊范围
[Range(1, 8)] public int downSample = 2;//缩放系数
//【版本1】:just apply blur
void OnRenderImage(RenderTexture src, RenderTexture dest) {
if (material != null) {
//利用 RenderTexture.GetTemporary函数分配了一块与屏幕图像大小相同的缓冲区
//高斯模糊需要调用两个Pass,我们需要使用一块中间缓存来存储第一个Pass执行完毕后得到的模糊结果。
int rtW = src.width;
int rtH = src.height;
RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);
//使用第一个Pass(即使用竖直方向的一维高斯核进行滤波)对src进行处理,并将结果存储在了buffer中
Graphics.Blit(src, buffer, material, 0);
//使用第二个Pass(即使用水平方向的一维高斯核进行滤波)对buffer进行处理,返回最终的屏幕图像
Graphics.Blit(buffer, dest, material, 1);
//调用RenderTexture.ReleaseTemporary来释放之前分配的缓存
RenderTexture.ReleaseTemporary(buffer);
} else {
Graphics.Blit(src, dest);
}
}
//【版本2】:scale the render texture
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
//声明缓冲区的大小时,使用了小于原屏幕分辨率的尺寸
//调用第一个Pass时,我们需要处理的像素个数就是原来的几分之一
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);
}
}
//【版本3】:use iterations for larger blur
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
int rtW = src.width/downSample;
int rtH = src.height/downSample;
定义了第一个缓存buffer0,并把src中的图像缩放后存储到buffer0中
RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
buffer0.filterMode = FilterMode.Bilinear;
Graphics.Blit(src, buffer0);
//【利用两个临时缓存在迭代之间进行交替】
for (int i = 0; i < iterations; i++) {
material.SetFloat("_BlurSize", 1.0f + i * blurSpread);
//定义了第二个缓存buffer1
RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
//执行第一个Pass时,输入是buffer0,输出是buffer1
Graphics.Blit(buffer0, buffer1, material, 0);
//buffer0释放
RenderTexture.ReleaseTemporary(buffer0);
//把结果值buffer1存储到buffer0中
buffer0 = buffer1;
//重新分配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关键代码:
CGINCLUDE的代码不需要包含在任何Pass语义块中,在使用时,我们只需要在Pass中直接指定需要使用的顶点着色器和片元着色器函数名即可。CGINCLUDE类似于C++中头文件的功能。
由于高斯模糊需要定义两个Pass,但它们使用的片元着色器代码是完全相同的,使用CGINCLUDE可以避免我们编写两个完全一样的frag函数。
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader {
//【在SubShader块中利用CGINCLUDE和ENDCG语义来定义一系列代码】
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
float _BlurSize;
struct v2f {
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;
};
//【分别定义两个Pass使用的顶点着色器】
v2f vertBlurVertical(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv;
//和属性_BlurSize相乘来控制采样距离
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] = 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 {
//声明各个邻域像素对应的权重weight
float weight[3] = {0.4026, 0.2442, 0.0545};
//将结果值sum初始化为当前的像素值乘以它的权重值
fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];
//根据对称性,我们进行了两次迭代
//每次迭代包含了两次纹理采样,并把像素值和权重相乘后的结果叠加到sum中
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语义定义了它们的名字】
//为Pass定义名字,可以在其他Shader中直接通过它们的名字来使用该Pass,而不需要再重复编写代码
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"
4.Bloom效果
Bloom特效是游戏中常见的一种屏幕效果。这种特效可以模拟真实摄像机的一种图像效果,它让画面中较亮的区域“扩散”到周围的区域中,造成一种朦胧的效果。
Bloom的实现原理非常简单:
- 根据一个阈值提取出图像中的较亮区域,把它们存储在一张渲染纹理中。
- 利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果。
- 将其和原图像进行混合,得到最终的效果。
Bloom效果是建立在高斯模糊的基础上的,因此脚本中提供的参数和高斯模糊摄像机代码中的几乎完全一样,我们只增加了一个新的参数luminanceThreshold来控制提取较亮区域时使用的阈值大小。尽管在绝大多数情况下,图像的亮度值不会超过1。但如果我们开启了HDR,硬件会允许我们把颜色值存储在一个更高精度范围的缓冲中,此时像素的亮度值可能会超过1。因此,在这里我们把luminanceThreshold的值规定在[0, 4]范围内。
摄像机脚本关键代码:
......
public class Bloom : PostEffectsBase {
......
[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("_LuminanceThreshold", luminanceThreshold);
int rtW = src.width/downSample; int rtH = src.height/downSample;
RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
buffer0.filterMode = FilterMode.Bilinear;
//使用Shader中的第一个Pass提取图像中的较亮区域,提取得到的较亮区域将存储在buffer0
Graphics.Blit(src, buffer0, material, 0);//第1个pass
//进行完全一样的高斯模糊迭代处理,这些Pass对应了Shader的第二个和第三个Pass
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);//第2个pass
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(buffer0, buffer1, material, 2);//第3个pass
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}
//模糊后的较亮区域将会存储在buffer0中,我们再把buffer0传递给材质中的_Bloom纹理属性
material.SetTexture ("_Bloom", buffer0);
//使用Shader中的第四个Pass来进行最后的混合,将结果存储在目标渲染纹理dest
Graphics.Blit (src, dest, material, 3);//第4个pass
RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src, dest);
}
}
}
Shader关键代码:
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 = UnityObjectToClipPos(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);
//将采样得到的亮度值减去阈值_LuminanceThreshold,并把结果截取到0~1范围内
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 = UnityObjectToClipPos (v.vertex);
//定义了两个纹理坐标,并存储在同一个类型为half4的变量uv中
o.uv.xy = v.texcoord;//它的xy分量对应了_MainTex,即原图像的纹理坐标。
o.uv.zw = v.texcoord;//它的zw分量是_Bloom,即模糊后的较亮区域的纹理坐标。
#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
}
//Unity内部会把所有Pass的Name转换成大写字母表示
//在使用UsePass命令时我们必须使用大写形式的名字
UsePass "ShaderBook/Chapter12/Gaussain Blur/GAUSSIAN_BLUR_VERTICAL"
UsePass "ShaderBook/Chapter12/Gaussain Blur/GAUSSIAN_BLUR_HORIZONTAL"
Pass {
CGPROGRAM
#pragma vertex vertBloom
#pragma fragment fragBloom
ENDCG
}
}
FallBack Off
5.运动模糊
运动模糊是真实世界中的摄像机的一种效果。如果在摄像机曝光时,拍摄场景发生了变化,就会产生模糊的画面。运动模糊在我们的日常生活中是非常常见的,只要留心观察,就可以发现无论是体育报道还是各个电影里,都有运动模糊的身影。运动模糊效果可以让物体运动看起来更加真实平滑,但在计算机产生的图像中,由于不存在曝光这一物理现象,渲染出来的图像往往都棱角分明,缺少运动模糊。在一些诸如赛车类型的游戏中,为画面添加运动模糊是一种常见的处理方法。
运动模糊的实现有多种方法:
- 利用一块累积缓存来混合多张连续的图像:当物体快速移动产生多张图像后,我们取它们之间的平均值作为最后的运动模糊图像。然而,这种暴力的方法对性能的消耗很大,因为想要获取多张帧图像往往意味着我们需要在同一帧里渲染多次场景。
- 创建和使用速度缓存:这个缓存中存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。
我们将使用类似上述第一种方法的实现来模拟运动模糊的效果。我们不需要在一帧中把场景渲染多次,但需要保存之前的渲染结果,不断把当前的渲染图像叠加到之前的渲染图像中
,从而产生一种运动轨迹的视觉效果。这种方法与原始的利用累计缓存的方法相比性能更好,但模糊效果可能会略有影响。
摄像机脚本关键代码:
......
public class MotionBlur : PostEffectsBase {
......
//运动模糊在混合图像时使用的模糊参数
//blurAmount的值越大,运动拖尾的效果就越明显
//为了防止拖尾效果完全替代当前帧的渲染结果,我们把它的值截取在0.0~0.9范围内
[Range(0.0f, 0.9f)] public float blurAmount = 0.5f;
private RenderTexture accumulationTexture;
//该脚本不运行时,即调用OnDisable函数时,立即销毁accumulation Texture。
//这是因为,我们希望在下一次开始应用运动模糊时重新叠加图像。
void OnDisable() {
DestroyImmediate(accumulationTexture);
}
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
//判断用于混合图像的accumulationTexture是否满足条件
if (accumulationTexture == null || accumulationTexture.width != src.width || accumulationTexture.height != src.height) {
DestroyImmediate(accumulationTexture);
//如果不满足,需要重新创建一个适合于当前分辨率的accumulationTexture变量
accumulationTexture = new RenderTexture(src.width, src.height, 0);
//把它的hideFlags设置为HideFlags.HideAndDontSave
//这意味着这个变量不会显示在Hierarchy中,也不会保存到场景中
accumulationTexture.hideFlags = HideFlags.HideAndDontSave;
//使用当前的帧图像初始化accumulation Texture
Graphics.Blit(src, accumulationTexture);
}
// 进行一个渲染纹理的恢复操作
//恢复操作(restore operation)发生在渲染到纹理而该纹理又没有被提前清空或销毁的情况下
accumulationTexture.MarkRestoreExpected();
material.SetFloat("_BlurAmount", 1.0f - blurAmount);
//把当前的屏幕图像src叠加到accumulationTexture中
Graphics.Blit (src, accumulationTexture, material);
//把结果显示到屏幕上
Graphics.Blit (accumulationTexture, dest);
} else {
Graphics.Blit(src, dest);
}
}
}
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;
......
//更新渲染纹理的RGB通道部分
//对当前图像进行采样,并将其A通道的值设为_BlurAmount,以便在后面混合时可以使用它的透明通道进行混合
fixed4 fragRGB (v2f i) : SV_Target {
return fixed4(tex2D(_MainTex, i.uv).rgb, _BlurAmount);
}
//更新渲染纹理的A通道部分
half4 fragA (v2f i) : SV_Target {
return tex2D(_MainTex, i.uv);//直接返回采样结果
//维护渲染纹理的透明通道值,不让其受到混合时使用的透明度值的影响
}
ENDCG
ZTest Always Cull Off ZWrite Off
//更新渲染纹理的RGB通道
//更新RGB时我们需要设置它的A通道来混合图像,但又不希望A通道的值写入渲染纹理中
Pass {
Blend SrcAlpha OneMinusSrcAlpha
ColorMask RGB
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRGB
ENDCG
}
//更新A通道
Pass {
Blend One Zero
ColorMask A
CGPROGRAM
#pragma vertex vert
#pragma fragment fragA
ENDCG
}
}
FallBack Off
}
我们混合了连续帧之间的图像,这样得到一张具有模糊拖尾的图像。然而,当物体运动速度过快时,这种方法可能会造成单独的帧图像变得可见。