找回密码
 立即注册
查看: 229|回复: 0

[笔记] Unity的屏幕后处理效果(3):高斯模糊

[复制链接]
发表于 2022-8-8 19:47 | 显示全部楼层 |阅读模式
本文主要参考《Unity Shader入门精要》12.4节。
模糊的实现有很多方法,例如均值模糊和中值模糊。均值模糊同样使用了卷积操作,它使用的卷积核中的各个元素值都相等,且相加等于1,也就是说,卷积后得到的像素值是其邻域内各个像素值的平均值。而中值模糊则是选择邻域内对所有像素排序后的中值替换掉原颜色。
一个更高级的模糊方法是高斯模糊。



左边为原效果,右边为高斯模糊后的效果

1. 高斯滤波

高斯模糊同样利用了卷积计算 ,它使用的卷积核名为高斯核 。高斯核是一个正方形大小的滤波核,其中每个元素的计算都是基于下面的高斯方程:
G(x,y)=\frac{1}{2\pi \sigma^{2}} e^{\frac{x^{2}+y^{2}}{2\sigma^{2}}}
其中, σ是标准方差(一般取值为 1),x和y分别对应了当前位置到卷积核中心的整数距离。要构建一个高斯核 ,我们只需要计算高斯核中各个位置对应的高斯值。为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为 1。因此 高斯函数中 e 前面的系数实际不会对结果有任何影响。
下图显示了一个标准方差为 5x5 大小的高斯核。



一个 5x5 大小的高斯核。左图显示了标准方差为 1 的高斯核的权重分布,我们可以把这个二维高斯核拆分成两个一维的高斯核(右图)

高斯方程很好地模拟了邻域每个像素对当前处理像素的影响程度——距离越近,影响越大。高斯核的维数越高,模糊程度越大。使用一个 NxN 的高斯核对图像进行卷积滤波,就需要 NxNxWxH (W 和 H 分别是图像的宽和高)次纹理采样。当N的大小不断增加时,采样次数会变得非常巨大。
幸运的是,我们可以把这个二维高斯函数拆分成两个一维函数。也就是说,我们可以使用两个一维的高斯核(上图的右图)先后对图像进行滤波,它们得到的结果和直接使用二维高斯核是一样的,但采样次数只需要 2xNxWxH 。我们可以进一步观察到,两个一维高斯核中包含了很多重复的权重。对于一个大小为 5 的一维高斯核,我们实际只需要记录 3 个权重值即可。
2. 具体实现

在本节,我们将会使用上述 5x5 的高斯核对原图像进行高斯模糊。我们将先后调用两个 Pass,第一个 Pass 将会使用竖直方向的一维高斯核对图像进行滤波,第二个 Pass 再使用水平方向的一维高斯核对图像进行滤波,得到最终的目标图像。在实现中,我们还将利用图像缩放来进一步提高性能,并通过调整高斯滤波的应用次数来控制模糊程度(次数越多,图像越模糊)。
具体细节参考书的12.4节,下面仅列出一些注意事项。
在脚本中,我们提供了调整高斯模糊迭代次数,模糊范围和缩放系数的参数:
[Range(0, 4)]
public int iterations = 3;
       
// Blur spread for each iteration - larger value means more blur
[Range(0.2f, 3.0f)]
public float blurSpread = 0.6f;
       
[Range(1, 8)]
public int downSample = 2;
blurSpread 和 downSample 都是出于性能的考虑。在高斯核维数不变的情况下,_BlurSize 越大,模糊程度越高 ,但采样数却不会受到影响。但过大的 _BlurSize 值会造成虚影,这可能并不是我们希望的。而 downSample 越大,需要处理的像素数越少,同时也能进一步提高模糊程度,但过大的 downSample 可能会使图像像素化。
我们需要定义关键的 OnRenderlmage 函数。我们首先来看第一个版本:
        /// 1st edition: just apply blur
        void OnRenderImage(RenderTexture src, RenderTexture dest) {
                if (material != null) {
                        int rtW = src.width;
                        int rtH = src.height;
                        RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);

                        // Render the vertical pass
                        Graphics.Blit(src, buffer, material, 0);
                        // Render the horizontal pass
                        Graphics.Blit(buffer, dest, material, 1);

                        RenderTexture.ReleaseTemporary(buffer);
                } else {
                        Graphics.Blit(src, dest);
                }
        }

  • 我们这里利用 RenderTexture.GetTemporary 函数分配了一块与屏幕图像大小相同的缓冲区。这是因为高斯模糊需要调用两个 Pass,我们需要使用一块中间缓存来存储第一个 Pass 执行完毕后得到的模糊结果。
  • 如代码所示,我们首先调用 Graphics.Blit(src, buffer, material, 0), 使用 Shader 中的第一个 Pass(即使用竖直方向的一维高斯核进行滤波)对 src 进行处理,并将结果存储在了 buffer 中。然后,再调用 Graphics.Blit(buffer, dest, material, 1), 使用 Shader中的第二个 Pass (即使用水平方向的一维高斯核进行滤波)对 buffer 进行处理,返回最终的屏幕图像。
  • 最后还需要调用 RenderTexture.ReleaseTemporary 来释放之前分配的缓存。

我们可以进一步实现第二个版本的代码,在这个版本中,我们将利用缩放对图像进行降采样,从而减少需要处理的像素个数,提高性能。
        /// 2nd edition: scale the render texture
        void OnRenderImage (RenderTexture src, RenderTexture dest) {
                if (material != null) {
                        int rtW = src.width/downSample;
                        int rtH = src.height/downSample;
                        RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0);
                        buffer.filterMode = FilterMode.Bilinear;

                        // Render the vertical pass
                        Graphics.Blit(src, buffer, material, 0);
                        // Render the horizontal pass
                        Graphics.Blit(buffer, dest, material, 1);

                        RenderTexture.ReleaseTemporary(buffer);
                } else {
                        Graphics.Blit(src, dest);
                }
        }
与第一个版本代码不同的是,我们在声明缓冲区的大小时,使用了小于原屏幕分辨率的尺寸,并将该临时渲染纹理的滤波模式设置为双线性。这样,在调用第一个 Pass 时,我们需要处理的像素个数就是原来的几分之一。对图像进行降采样不仅可以减少需要处理的像素个数,提高性能,而且适当的降采样往往还可以得到更好的模糊效果。尽管 downSample 值越大,性能越好,但过大的 downSample 可能会造成图像像素化

最后一个版本的代码还考虑了高斯模糊的迭代次数:
        /// 3rd edition: use iterations for larger blur
        void OnRenderImage (RenderTexture src, RenderTexture dest) {
                if (material != null) {
                        int rtW = src.width/downSample;
                        int rtH = src.height/downSample;

                        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);

                                RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

                                // Render the vertical pass
                                Graphics.Blit(buffer0, buffer1, material, 0);

                                RenderTexture.ReleaseTemporary(buffer0);
                                buffer0 = buffer1;
                                buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

                                // Render the horizontal pass
                                Graphics.Blit(buffer0, buffer1, material, 1);

                                RenderTexture.ReleaseTemporary(buffer0);
                                buffer0 = buffer1;
                        }

                        Graphics.Blit(buffer0, dest);
                        RenderTexture.ReleaseTemporary(buffer0);
                } else {
                        Graphics.Blit(src, dest);
                }
        }
上面的代码显示了如何利用两个临时缓存在迭代之间进行交替的过程。

  • 在迭代开始前,我们首先定义了第一个缓存 buffer0,并把 src 中的图像缩放后存储到 buffer0 中。
  • 在迭代过程中,我们又定义了第二个缓存 buffer1 。
  • 在执行第一个 Pass 时,输入 buffer0,输出是 buffer1,完毕后首先把 buffer0 释放,再把结果值 buffer1 存储到 buffer0 中,重新分配 buffer1,然后再调用第二个 Pass,重复上述过程。
  • 迭代完成后 buffer0 将存储最终的图像,我们再利用 Graphics.Blit(buffer0, dest) 把结果显示到屏幕上,并释放缓存。

下面我们来实现Shader部分的代码:
我们首先需要声明本例使用的各个属性:
        Properties {
                _MainTex ("Base (RGB)", 2D) = "white" {}
                _BlurSize ("Blur Size", Float) = 1.0
        }
_MainTex 对应了输入的渲染纹理。
在本节中,我们将第一次使用 CGINCLUDE 来组织代码。我们在 SubShader 块中利用 CGINCLUDE 和 ENDCG 语义来定义一系列代码:
SubShader {
    CGINCLUDE
    ...
    ENDCG
    ...
这些代码不需要包含在任何 Pass 语义块中,在使用时只需要在 Pass 中直接指定需要使用的顶点着色器和片元着色器函数名即可。
CGINCLUDE 类似于 C++中头文件的功能。由于高斯模糊需要定义两个 Pass,但它们使用的片元着色器代码是完全相同的,使用 CGINCLUDE 可以避免编写两个完全一样的 frag 函数
在 CG 代码块中,定义与属性对应的变量:
sampler2D _MainTex;  
half4 _MainTex_TexelSize;
float _BlurSize;
由于要得到相邻像素的纹理坐标,我们这里再一次使用了 Unity 提供的 _MainTex_TexelSize 变量,以计算相邻像素的纹理坐标偏移量。
分别定义两个 Pass 使用的顶点着色器。下面是竖直方向的顶点着色器代码:
struct v2f {
        float4 pos : SV_POSITION;
        half2 uv[5]: TEXCOORD0;
};
                  
v2f vertBlurVertical(appdata_img v) {
        v2f o;
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
                       
        half2 uv = v.texcoord;
                       
        o.uv[0] = uv;
        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;
}

  • 在本节中我们会利用 5x5 大小的高斯核对原图像进行高斯模糊,而由上节可知,一个 5x5 的二维高斯核可以拆分成两个大小为 5 的一维高斯核,因此我们只需要计算 5 个纹理坐标即可。
  • 为此,我们在 v2f 结构体中定义了一个 5 维的纹理坐标数组。数组的第一个坐标存储了当前的采样纹理,而剩余的四个坐标则是高斯模糊中对邻域采样时使用的纹理坐标。
  • 我们还和属性 _BlurSize 相乘来控制采样距离。在高斯核维数不变的情况下,_BlurSize 越大,模糊程度越高,但采样数却不会受到影响。但过大的 _BlurSize 值会造成虚影,这可能并不是我们希望的。
  • 通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。
水平方向的顶点着色器和上面的代码类似,只是在计算4个纹理坐标时使用了水平方向的纹素大小进行纹理偏移。
定义两个 Pass 共用的片元着色器:
fixed4 fragBlur(v2f i) : SV_Target {
        float weight[3] = {0.4026, 0.2442, 0.0545};
                       
        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);
}
由上节可知, 一个 5x5 的二维高斯核可以拆分成两个大小为 5 的一维高斯核,并且由于它的对称性,我们只需要记录 3 个高斯权重, 也就是代码中的 weight 变量。我们首先声明了各个邻域像素对应的权重 weight,然后将结果值 sum 初始化为当前的像素值乘以它的权重值。 根据对称性, 我们进行了两次迭代, 每次迭代包含了两次纹理采样,并把像素值和权重相乘后的结果叠加到 sum 中。 最后,函数返回滤波结果 sum 。
然后, 我们定义了高斯模糊使用的两个 Pass:
ZTest Always Cull Off ZWrite Off
               
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
}
注意, 我们仍然首先设置了渲染状态。和之前实现不同的是,我们为两个 Pass 使用 NAME 语义定义了它们的名字。 这是因为,高斯模糊是非常常见的图像处理操作,很多屏幕特效都是建立在它的基础上的。 为 Pass 定义名字, 可以在其他 Shader 中直接通过它们的名字来使用该 Pass, 而不需要再重复编写代码。



左边为原效果,右边为高斯模糊后的效果

Reference
[1] 《Unity Shader入门精要》—— 冯乐乐

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-3 21:24 , Processed in 0.090603 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表