快速高斯模糊的Shader实现

在整理代码的时候发现了以前clone的一个高斯模糊的repo,于是打算整理一下写篇blog。高斯模糊在游戏开发中最常见的用法就是PostPorocess、Image Effect了,但是为了获得更好的模糊效果就需要更多的贴图采样次数,会有很多性能上的压力。一个高效的高斯模糊算法是很必要的。

本文导读

  • 介绍一种复杂度为O(n)的高斯模糊算法。

  • 该模糊算法的GPU迁移。

  • 在Unity中的实现的两种模糊方式以及对比。

最快速的高斯模糊算法

高斯函数与模糊卷积

在中学的概率知识中我们都了解过正态分布,正态分布的权重就是高斯函数。

f(x)=12πρe(xμ)22σ2\displaystyle \large f(x)= \frac{1}{\sqrt{2\pi}\rho}e^{-\frac{(x-\mu)^2}{2\sigma^2}}

对于一张二维图片我们使用高斯函数的权重值作为矩阵,计算window region的卷积就可以得到这个region中心值的高斯模糊值。高斯函数的权重积分为1所以最后的图片的颜色值不会整体变亮或变暗。

用公式来表示就是对于一个像素[i,j][i,j],取rr为半径,计算该像素的blur值为:

b[i,j]=y=iri+rx=jrj+rf[x,y]w[x,y]\displaystyle \large b[i,j] = \sum^{i+r}_{y=i-r} \sum^{j+r}_{x=j-r} f[x,y] * w[x,y]

然而直接计算高斯模糊,算法的复杂度为$ O(n*r^{2}) $。

BoxBlur 均值模糊

均值模糊是另外一种模糊算法,相当于模糊计算时权重全部相同,即$ w[i,j] = $。对于均值模糊来说,权重相同省去了权重的乘法计算,可以提升性能,但是由于均值是以一块r*r的大小区域计算的,模糊的效果会出现方形的artifact。而高斯权重分布式按照像素距离中心的距离,大于模糊半径权重就为0,会有柔和的过渡。

Peter Kovesi 在Paper中提出了,Box blur在多次iteration之后会逼近Gaussian分布。于是我们可以重复多次Box blur来替代高斯分布的权重计算。

同时Peter Kovesi还提出,高斯模糊的标准差和BoxBlur的box半径大小存在如下的关系。

w=12σ2n+1\displaystyle w = \sqrt{\frac{12\sigma^2}{n}+1} 其中w为box的半径 σ\sigma为标准差(Standard Deviation)

Box Blur优化

BoxBlur的公式:

bb[i,j]=y=ibri+brx=jbrj+brf[x,y]/(2br)2\displaystyle \large bb[i,j] = \sum^{i+br}_{y=i-br} \sum^{j+br}_{x=j-br} f[x,y] /(2 \cdot br)^2

对于一个pixel 我们仍要采样br*br个像素的颜色值。由于是均值模糊,我们可以先对每个像素做横向的模糊再进行纵向的模糊,最终得到的结果不变。

bb[i,j]=y=ibri+brx=jbrj+brf[x,y]/(2br)2\displaystyle \large bb[i,j] = \sum^{i+br}_{y=i-br} \sum^{j+br}_{x=j-br} f[x,y] /(2 \cdot br)^2

=y=jbrj+br(x=ibri+brf[x,y]/(2br))/(2br)\displaystyle \large = \sum^{j+br}_{y=j-br}\left(\sum^{i+br}_{x=i-br} f[x,y]/(2 \cdot br)\right) / (2 \cdot br)

这样我们将算法复杂度降到O(nr)O(n \cdot r)

计算代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

public void BoxBlur(color[] src,color[] dst,int r){
BlurHor(src,dst,r);
BlurVer(src,dst,r);
}

public void BlurHor(color[] src,color[] dst, int r){
for(var j=0;j<h;j++){
for(var i=0;i<w;i++){

var val = 0;
for(var ix = i-r;ix <i+r;ix++){
val += src[j*w + ix];
}
dst[j*w + i] = val /(r+r+1);
}
}
}

public void BlurVer(color[] src,color[] dst, int r){
for(var j=0;j<h;j++){
for(var i=0;i<w;i++){
var val = 0;
for(var jy = j-r;jy <j+r;jy++){
val += src[i + jy*w];
}
dst[j*w + i] = val /(r+r+1);
}
}
}

使用累加器减少采样

对于横向的的计算来说,相邻两个像素i,i+1。

bb(i+1)=bb(i)v(ir)+v(i+1+r)bb(i+1) = bb(i) - v(i-r) + v(i+1+r) 所以我们可以使用一个累加器,对于每个像素的计算就不需要采样2r+12 \cdot r + 1次像素了。 这样我们又可以将算法的复杂度从O(nr)O(n \cdot r) 降到O(n)O(n)

到目前为止使用多次BoxBlur来逼近GaussianBlur包括优化就完成了。这篇Blog里有相应的BenchMark Fastest Gaussian Blur (in linear time)

使用GPU计算模糊

在很多GPU计算高斯模糊的算法中,将高斯分布的权重预计算存储到一张LookUpTexture中,这样减少shader中计算的指令数,我们这里不采用这种使用高斯分布权重直接计算的方式,而使用上文所描述的均值模糊来逼近。

虽然优化过后性能已经是原始算法的1/2001/200,但对于游戏渲染来说几十ms的时间还是太久了。 考虑将BoxBlur迁移到GPU中进行计算时,对于上文提到的使用累加器减少采样这一步优化,也就是将复杂度降低到O(n)O(n)的步骤在Shader中是无法实现的。

由于GPU执行时高度并行化并且乱序的,累加器完全无法工作,这样我们就失去了最后一步的优化。

对于完全正确的高斯模糊算法来说。模糊的半径大小是严重影响算法的计算量的。半径越大,每个像素采样的次数越多。而在Shader执行中贴图采样也是性能开销最大的。

Shader模糊效果的一个需求是,shader的效率不应该随模糊半径而变化。不论是1个像素的模糊,还是100个像素半径的模糊,性能开销如果以$ (n^2) $增长是无法接受的。不过GPU有个好处是我们可以通过贴图的Downsample来通过一次采样获取到更多像素的颜色值。

高斯模糊实现思路

用一句话来概括就是,固定每个像素的采样次数,通过将原始图形按照模糊的半径进行缩放,以达成不同模糊半径有同样的运行效率。

1
2
3
float scale = 2.0f / boxsizeHalf;
tempWith = Mathf.CeilToInt(src.width * scale);
tempHeight = Mathf.CeilToInt(src.height * scale);

其中boxsizeHalf是通过高斯模糊的radius计算出的,tempWithtempHeight是原始图片被缩放后的大小。

这样我们在shader中进行采样时,每次采样的uv的跨度是一个texel,跨度太大就会产生artifact。

1
2
3
4
for(int x = -4; x <= 4;x++){        //每个像素采样4+1次
float u = i.uv.x + x * _MainTex_TexelSize.x;
col += tex2D(_MainTex, float2(u,i.uv.y)).xyz;
}

不同的模糊质量

同时可以定义不同的模糊质量,分别每个像素采样(4、8、16)+1次。

BoxBlur次数

由于Peter Kovesi提出的高斯模糊逼近,可以进行任意次box blur。在具体实现的时候进行3次。也就是三次horizontal模糊和3次纵向模糊。同时在高斯模糊的半径与均值模糊box半径的计算中,多次box模糊的半径是不相同的。但是不同的半径我们需要多生成一张不同大小的rendertexture来计算,所以这里优化的时候取相同的半径,结果不会有太大差别。

blur radius 12px

blur radius 12px

另一种实现方式

由于上面的实现方式是根据通过缩放texture来实现的不同模糊半径的采样。这样在实现动态模糊的时候,也就是模糊半径动态变化时,会allocate大量的rendertexture。对显存的影响较大。但是如果模糊半径一直不变就不会产生这种影响。

还是一种实现方式就是根据blur质量固定降低texture的分辨率,1/2、1/4、1/8等。但是在采样时还是使用固定的采样次数。这种方式在blur半径较少模糊效果好,但是半径提升到每个采样跨越了多个texel,就会出现明显的artifact。如果要减少这种artifact就需要提升采样的次数。

blur-artifact

blur-artifact

Unity中实现代码

SuperFastBlur.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SuperFastBlur : MonoBehaviour
{
public enum Quality : int
{
Low = 2,
Media = 4,
High = 8,
}
private Material m_matBlur;

[Range(0, 100)]
public float Radius = 0;
public float boxsizeHalf;

public Quality BlurQuality = Quality.Media;

public int tempWith;
public int tempHeight;

public bool ModeBlurScaling = false;

private void Awake()
{
m_matBlur = new Material(Shader.Find("Unlit/SuperFastBlurShader"));
}

public float[] boxesForGauss(float sigma, int n) // standard deviation, number of boxes
{
var wIdeal = Mathf.Sqrt((12 * sigma * sigma / n) + 1); // Ideal averaging filter width
var wl = Mathf.Floor(wIdeal); if (wl % 2 == 0) wl--;
var wu = wl + 2;

var mIdeal = (12 * sigma * sigma - n * wl * wl - 4 * n * wl - 3 * n) / (-4 * wl - 4);
var m = Mathf.RoundToInt(mIdeal);

var sizes = new float[n];
for (var i = 0; i < n; i++)
{
sizes[i] = (i < m ? wl : wu);
}
return sizes;
}

private void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if (Radius <= 0)
{
Graphics.Blit(src, dest);
return;
}

boxsizeHalf = boxesForGauss(Radius,3)[0];
float boxsize = boxsizeHalf * 2.0f;

if (ModeBlurScaling)
{
float scale = (int)BlurQuality * 2.0f / boxsizeHalf;
tempWith = Mathf.CeilToInt(src.width * scale);
tempHeight = Mathf.CeilToInt(src.height * scale);
}
else
{
tempWith = Mathf.CeilToInt(src.width * (int)BlurQuality / 16);
tempHeight = Mathf.CeilToInt(src.height * (int)BlurQuality / 16);
}

var temprtx1 = RenderTexture.GetTemporary(tempWith, tempHeight, 0, src.format, RenderTextureReadWrite.Default);
var temprtx2 = RenderTexture.GetTemporary(tempWith, tempHeight, 0, src.format, RenderTextureReadWrite.Default);

m_matBlur.SetInt("_quality", (int)BlurQuality);
if (!ModeBlurScaling)
{
m_matBlur.DisableKeyword("BlurScaling_ON");
m_matBlur.SetFloat("_boxBlurRadius", boxsizeHalf);
}
else
{
m_matBlur.EnableKeyword("BlurScaling_ON");
}

Graphics.Blit(src, temprtx1, m_matBlur, 1);
Graphics.Blit(temprtx1, temprtx2, m_matBlur, 0);

Graphics.Blit(temprtx2, temprtx1, m_matBlur, 0);
Graphics.Blit(temprtx1, temprtx2, m_matBlur, 1);

Graphics.Blit(temprtx2, temprtx1, m_matBlur, 0);
Graphics.Blit(temprtx1, dest, m_matBlur, 1);

RenderTexture.ReleaseTemporary(temprtx1);
RenderTexture.ReleaseTemporary(temprtx2);
}
}

SuperFastBlurShader.shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
Shader "Unlit/SuperFastBlurShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Cull Off ZWrite On ZTest Less
Pass
{
Name "BlurH"
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile BlurScaling_OFF BlurScaling_ON

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};

v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}

#if !BlurScaling_ON
float _boxBlurRadius = 0;
#endif

sampler2D _MainTex;
uniform float4 _MainTex_TexelSize;

int _quality = 2; //2 4 /8
fixed4 frag (v2f i) : SV_Target
{
float3 col =0;

#if BlurScaling_ON
float stepSize = _MainTex_TexelSize.x;
#else
float stepSize = _MainTex_TexelSize.x * _boxBlurRadius / _quality /4.0;
#endif

for(int x = -_quality; x <= _quality;x++){
float u = i.uv.x + x * stepSize;
col += tex2D(_MainTex, float2(u,i.uv.y)).xyz;
}
col = (col / (_quality *2.0+ 1.0));


return float4(col,1);
}
ENDCG
}

Pass
{
Name "BlurV"
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile BlurScaling_OFF BlurScaling_ON

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};

v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}

#if !BlurScaling_ON
float _boxBlurRadius = 0;
#endif

sampler2D _MainTex;
uniform float4 _MainTex_TexelSize;

int _quality = 2; //2 4 /8
fixed4 frag (v2f i) : SV_Target
{
float3 col= 0;

#if BlurScaling_ON
float stepSize = _MainTex_TexelSize.y;
#else
float stepSize = _MainTex_TexelSize.y * _boxBlurRadius / _quality / 4.0;
#endif

for(int y = -_quality; y <= _quality;y++){
float v = i.uv.y + y * stepSize;
col += tex2D(_MainTex, float2(i.uv.x,v)).xyz;
}
col = (col / (_quality *2+ 1));
return float4(col,1);
}
ENDCG
}

}
}

总结

GPU blur上的优化依旧是时间与空间的取舍,更多的显存消耗,固定的计算时间开销。固定的显存就可能需要更多的计算时间,所以优化还是需要看特定的情形来。有时间把Gaussian standard deviation与box radius的计算推导一下再来更新。


Reference

Fastest Gaussian Blur (in linear time)

Fast Almost-Gaussian Filtering