对快速实时 GPU 图像模糊算法的调查





0/5 (0投票)
在这篇文章中,我将开始探讨模糊滤镜这个主题。
引言
在这篇博客文章中,我将开始探讨模糊滤镜这个主题。我最初的打算是……
- 为几种流行的实时图像模糊滤镜提供概述和优化思路,这些滤镜适用于各种不同的硬件(从低于4W的移动设备GPU到高端250W+的桌面GPU)。
- 提供一个在 Windows 桌面 OpenGL 和 Android OpenGL ES 3.0 及 3.1 上运行的技术示例,包括在 OpenGL ES 3.1 上使用计算着色器。
- ...但我意识到,要恰当地探讨这个主题可能需要不止一篇博客文章(以及更多的工作),所以下面是第一部分。因此,请随时在底部的评论区提出想法和修正。
图像模糊滤镜
图像模糊滤镜常用于计算机图形学中——无论是作为景深(Depth of Field)或HDR泛光(HDR Bloom)效果的组成部分,还是作为其他后期处理效果,模糊滤镜几乎存在于所有3D游戏引擎中,并且常常在多个地方出现。模糊一张图像是一件相当简单的事情:只需收集邻近的像素,将它们平均一下,就能得到新的值,对吧?
嗯,是的,但是有不同的实现方式,它们会产生不同的视觉效果、质量和性能。
在本文中,我将主要关注性能(以及质量上的权衡),因为一个朴素的实现和一个更优化的解决方案之间的成本差异有时可能达到一个数量级,而且不同的算法在不同的硬件上可能表现更优。
我打算探索和对比三种最常见的技术,以及它们在一系列现代GPU硬件上的表现,这些硬件从移动GPU到集成显卡和独立显卡都有。如果您正在使用更好或不同的方法,请随时评论——我很乐意将其加入到性能分析和测试的样本中,并在下一次更新中提供。
这三种算法是:
- 使用高斯分布的经典卷积模糊
- Kawase Bloom的泛化——一种古老但仍然非常适用的滤镜,由Masaki Kawase在他GDC2003的演讲“Frame Buffer Postprocessing Effects in DOUBLE-S.T.E.A.L (Wreckless)”中提出
- 一种基于计算着色器的移动平均算法,灵感来自Fabian "ryg" Giesen的文章:http://fgiesen.wordpress.com/2012/07/30/fast-blurs-1/ 和 http://fgiesen.wordpress.com/2012/08/01/fast-blurs-2/。
它们都是通用算法,完全适用于像泛光HDR滤镜这样的效果。然而,在特定场景下,例如用于景深效果时,它们需要额外的定制(比如加权采样以避免光晕/渗色),这可能会影响相对性能。要实现类似于中值滤波(http://en.wikipedia.org/wiki/Median_filter)的效果,则需要更多的修改。然而,即使您打算实现像焦外成像(Bokeh,http://en.wikipedia.org/wiki/Bokeh)、艾里斑(Airy disk,http://en.wikipedia.org/wiki/Airy_disk)或各种图像空间的镜头光晕效果等特殊模糊,本文介绍的优化技术和数据也应该会有所帮助。该示例还提供了一个实时的图表可视化功能,可用于分析任何自定义的模糊技术。
还应注意,尽管通用算法更易于理解、实现、重用和维护,但在某些情况下,专门的算法可能会做得更好。一个这样的例子(尽管我没有将其与本文介绍的算法进行比较)在这里有介绍:http://software.intel.com/en-us/articles/compute-shader-hdr-and-bloom(基于“HDR: The Bungie Way. Microsoft Gamefest 2006.”)。
二维高斯模糊滤镜
让我们从“高斯模糊滤镜”开始,这是一种广泛使用的滤镜,用于减少图像细节和噪声(例如,模拟镜头失焦模糊)。它之所以被称为高斯,是因为图像是使用高斯曲线(http://en.wikipedia.org/wiki/Gaussian_function)进行模糊处理的。
朴素实现
实现高斯模糊的常见方法是使用卷积核(http://en.wikipedia.org/wiki/Kernel_(image_processing)#Convolution)。这简单来说就是,我们构建一个由标准化的预计算高斯函数值组成的二维矩阵,并将其用作权重,然后对所有邻近像素进行加权求和,以计算每个像素的新(过滤后)值。
对于一个2D滤镜,这也意味着为了计算每个像素,我们必须进行一个与卷积核大小相同的双重循环,因此每个像素的算法复杂度变为 O( n^2 ) (其中n是卷积核的高度和/或宽度,因为它们是相同的)。对于除了最小尺寸的卷积核之外的任何情况,这都会很快变得非常昂贵。
可分离的水平/垂直处理
幸运的是,2D高斯滤镜核是可分离的,因为它可以表示为两个向量的外积(参见 http://blogs.mathworks.com/steve/2006/10/04/separable-convolution/ 和 http://blogs.mathworks.com/steve/2006/11/28/separable-convolution-part-2/),这反过来意味着该滤镜可以被分解为两个通道,一个水平通道和一个垂直通道,每个通道每个像素的复杂度为 O( n ) (其中n是卷积核大小)。这极大地降低了计算成本,同时提供了完全相同的结果。
值得注意的是,在这个阶段,同样的算法可以用于任何其他适用的卷积(更多细节请参见上面的链接),例如一些锐化滤镜或边缘检测,如Sobel滤镜。
使用GPU固定功能采样的更优可分离实现
到目前为止,我们忽略了实际的硬件实现,但当我们将此算法应用于真实场景时,高斯模糊通道需要在实时中进行,通常处理1280x720或更高像素的图像。现代GPU(无论是移动设备还是桌面设备)在设计时都考虑到了这一点,因此毫不奇怪它们可以在几毫秒内完成——然而,节省那几毫秒仍然可能意味着游戏运行在欠佳的25帧或流畅的30帧每秒之间的区别。
在算法上好得多,这种可分离滤镜可以运行在高度并行的GPU执行单元上,因为它以GPU着色器的形式执行图像读取、数学运算和写入。
水平可分离高斯7x7滤镜通道的着色器伪代码
color = vec3( 0, 0, 0 );
for( int i = 0; i < 7; i++ )
{
color += textureLoad( texCoordCenter + vec2( i, 0 ) ) * SeparableGaussWeights[p];
}
return color;
然而,正如在 http://rastergrid.com/blog/2010/09/efficient-gaussian-blur-with-linear-sampling/ 中详细介绍的,我们可以利用GPU的固定功能硬件,即采样器,它可以加载两个(在我们的例子中)相邻的像素值,并根据提供的纹理坐标值返回一个插值结果,而这一切的成本大约相当于一次纹理读取。这大约将着色器指令的数量减少了一半(采样指令的数量减半,算术指令的数量略有增加),并且可以将性能提高两倍(或更少,如果我们受到内存带宽限制的话)。
要做到这一点,我们必须计算一套新的权重和采样偏移量,这样当使用线性滤镜对两个相邻像素进行采样时,我们得到的它们之间的插值平均值,其比例相对于它们原始的高斯可分离滤镜权重是正确的。然后,我们使用这套新的权重将所有采样值相加,最终我们得到一个速度快得多但质量与我们开始时未优化的朴素实现完全相同的滤镜。
示例中用于执行7x7滤镜的可分离水平或垂直通道的实际GLSL代码
// automatically generated by GenerateGaussFunctionCode in GaussianBlur.h
vec3 GaussianBlur( sampler2D tex0, vec2 centreUV, vec2 halfPixelOffset, vec2 pixelOffset )
{
vec3 colOut = vec3( 0, 0, 0 );
////////////////////////////////////////////////;
// Kernel width 7 x 7
//
const int stepCount = 2;
//
const float gWeights[stepCount] ={
0.44908,
0.05092
};
const float gOffsets[stepCount] ={
0.53805,
2.06278
};
////////////////////////////////////////////////;
for( int i = 0; i < stepCount; i++ )
{
vec2 texCoordOffset = gOffsets[i] * pixelOffset;
vec3 col = texture( tex0, centreUV + texCoordOffset ).xyz +
texture( tex0, centreUV – texCoordOffset ).xyz;
colOut += gWeights[i] * col;
}
return colOut;
}
测试表明,这种方法可以将性能提升近两倍,且随着卷积核尺寸的增加而增加。然而,对于非常大的卷积核,该算法会受到内存带宽(硬件相关)的限制,从而收益递减:在英特尔GPU上,当卷积核尺寸达到或超过127x127(RGBA8)时,这种效应开始变得明显,但在独立GPU上可能更高(未测试)。
在独立桌面GPU(Nvidia GTX 650)和带有集成显卡的Bay Trail平板电脑CPU(Atom Z3770)上,使用测试的卷积核尺寸(7x7、35x35、127x127),观察到了非常相似的性能差异。
FastBlurs示例演示了这种优化的高斯滤镜实现,并包含一个GLSL着色器代码生成器(在GaussianBlur.h中),用于生成任何自定义的卷积核尺寸。此外,可以通过运行示例,选择所需的卷积核宽度并重复点击“Gaussian Blur”界面按钮几次来生成GLSL代码。
在较低分辨率下工作
好了,我们有了一个很好的高斯模糊实现——但是我们的游戏中的“被击中”效果需要一个大的模糊核,而我们的目标硬件是一个高分辨率的移动设备。高斯模糊仍然有点太昂贵了,我们能做得更好吗?
嗯,事实证明,牺牲一些质量是相当普遍的做法,而且由于模糊是一种低通滤波器(保留低频信号,衰减高频信号),我们可以降采样到一个较小的分辨率缓冲区,例如降到 ½ x ½,执行模糊效果,然后再升采样回原始的帧缓冲区。对于模糊滤镜来说,这有两个巨大的好处:
- 对于一个 ½ x ½ 的中间模糊缓冲区,需要处理的像素数量减少了4倍。
- 在较小的缓冲区中,同样大小的卷积核覆盖了2x2倍的区域,因此为了达到相同的最终效果,模糊核在每个维度上应减少½。
这两点结合起来,当使用 ½ x ½ 的中间缓冲区时,达到类似效果的成本降低了8倍,尽管降采样到中间缓冲区和将结果升采样回主缓冲区存在固定的开销。因此,这只对例如7x7及以上的模糊核才有意义(更小的核也不足以隐藏因降采样和升采样造成的质量损失)。
主要缺点是,当模糊量较小(模糊核较小)时,从低分辨率降采样再升采样回来的“块状”模式会变得明显,所以如果一个效果需要淡入/淡出,这可能会有问题,尤其是在动态图像中。对于淡入淡出效果,可以通过在模糊的降采样图像和最终图像之间对小核值进行插值来解决这个问题。在其他一些情况下,例如使用模糊滤镜产生泛光(Bloom)效果时,低分辨率中间缓冲区的问题会因运动和泛光函数而进一步减弱,因此通常需要反复试验才能找到在质量与性能方面更优的模糊滤镜分辨率。
为了强调在可能的情况下使用更小(½ x ½,甚至 ¼ x ¼)中间缓冲区的重要性,我使用了附带的示例来测量在一台大型桌面AMD R9-290X GPU上模糊一张2560x1600的图像,以及在一台Nexus 7平板电脑上模糊一张1280x720的图像所需的时间。
在R9-290X的情况下,对全分辨率图像使用了一个127x127的大模糊核,计算时间约为3毫秒。使用½ x ½的中间缓冲区需要一个63x63的模糊核,执行时间为0.5毫秒,产生的图像质量几乎相同,而时间仅为1/6;¼ x ¼的中间缓冲区仅需0.17毫秒。在Nexus 7安卓平板电脑上的性能差异比例相似(运行适当的、小得多的工作负载)。
“Kawase模糊”滤镜
好了,我们有了一个优化的高斯模糊实现,并且我们正在以½ x ½的分辨率处理我们的HDR泛光效果(在一个假设的游戏开发场景中),但它仍然每帧花费我们3毫秒。我们能做得更好吗?将分辨率降至¼ x ¼看起来不够好,因为它会导致泛光在移动时闪烁,而减小卷积核尺寸又不能产生足够的效果。
嗯,一个针对泛光的替代方案是(已经提到过的)http://software.intel.com/en-us/articles/compute-shader-hdr-and-bloom(基于“HDR: The Bungie Way. Microsoft Gamefest 2006.”)。然而,它需要多个渲染目标,实现和维护起来有点麻烦,作为通用模糊算法的可重用性不高,而且默认情况下,它固定为一种卷积核大小(这使得效果依赖于屏幕分辨率,并且不容易被美术师“调整”)。
然而,事实证明,有一种通用的模糊算法,其性能甚至可以超过我们优化的高斯解决方案——尽管在滤镜质量/正确性方面存在权衡(它提供的分布远非理想,但对于大多数游戏引擎的目的来说是可以接受的)——由 Masaki Kawase 在他 GDC2003 的演讲“Frame Buffer Postprocessing Effects in DOUBLE-S.T.E.A.L (Wreckless)”(www.daionet.gr.jp/~masa/archives/GDC2003_DSTEAL.ppt)中介绍。最初用于泛光效果,它可以被泛化以在外观上与高斯模糊非常接近。
它是一个多通道滤镜,每个通道都使用前一个通道的结果并应用少量模糊,累积起来以近似高斯分布。在每个通道中,新的像素值是距像素中心可变距离的矩形模式中16个样本的平均值。通道的数量和距中心的采样距离根据预期结果而变化。
这种方法充分利用了GPU采样器硬件(http://www.realworldtech.com/ivy-bridge-gpu/6/)来通过一次采样调用获取四个像素(每个通道4次采样/16个像素值)。为了达到与特定高斯分布核大小相近的效果所需的通道数,其增长速度低于高斯核大小的线性增长。
用于匹配特定高斯滤镜核的通道数和四个样本的核模式,目前是基于经验比较得出的——本文的未来更新将探讨一种更自动化的方法。
该示例提供了一个图表显示,用于更精确地“匹配”不同技术以模拟高斯滤镜分布,并确保分布的正确性。
质量
性能
虽然在几乎所有测试案例中性能更优,但Kawase模糊滤镜的主要影响在于较大的卷积核,并且某些硬件从中受益更多(特别是Nexus 7)。此外,“更强”的GPU需要更大的工作纹理才能使差异变得明显。
该示例测量了绘制基本场景(截图和移动三角形)与绘制基本场景外加降采样到工作纹理尺寸、应用模糊并将结果应用回全分辨率帧缓冲纹理之间的差异。
结果表明,在广泛的硬件、分辨率和卷积核尺寸范围内,Kawase模糊似乎比优化的高斯模糊滤镜实现少用1.5到3.0倍的计算时间,尽管它在更大的卷积核和更大的工作纹理尺寸上,以及在较低功率的GPU上扩展性特别好。
不幸的是,尽管示例涵盖了高达127x127的尺寸,但我(目前)还没有一个确定性的方法来生成Kawase核以匹配任意大小的高斯滤镜——这是我计划在下一篇文章中讨论的几个问题之一。
移动平均计算着色器版本
最后,事实证明,通过多次应用“移动平均”盒式滤镜,可以设计出一个简单的、每个像素线性时间的模糊滤镜(复杂度为 O(1))。然而,由于这种方法有很高的固定成本,它似乎只有在非常大的模糊核尺寸下,才能在现代GPU上开始达到高斯/Kawase滤镜的性能,因此它可能只适用于非常特定的场景。
尽管如此,将它收入自己的算法工具箱中也是一件很酷的事情,而且肯定会派上用场。移动平均盒式滤镜在Ryg的优秀文章(http://fgiesen.wordpress.com/2012/07/30/fast-blurs-1/)中有非常详细的解释,但这里是简要总结:
- 盒式滤镜是一种图像滤镜,它将每个像素的值替换为 m x n 个邻近像素的平均值(所有值都相等且归一化的卷积滤镜核)。
- 2D盒式滤镜可以通过执行2个可分离的1D水平/垂直通道来实现,方式与可分离高斯滤镜相同,复杂度为 O(n)。然而,除此之外,还可以使用“移动平均”来完成每个垂直和水平通道,从而达到 O(1) 的复杂度。
移动平均基本上对整个列(或行)进行一次遍历,迭代整个长度:
- 对于前 kernel size 个元素,计算一个总和,并将平均值写入输出。
- 然后,对于每个新元素,从平均值中减去不再被核尺寸覆盖的元素的值,并加上右侧的值。将平均后的总和(除以核尺寸)写入输出。
其效果是,对于足够大的数组大小,计算时间不会随着核大小的增加而增长。
用于水平移动平均盒式滤镜通道的GLSL代码
vec3 colourSum = imageLoad( uTex0, ivec2( 0, y ) ).xyz * float(cKernelHalfDist);
for( int x = 0; x <= cKernelHalfDist; x++ )
colourSum += imageLoad( uTex0, ivec2( x, y ) ).xyz;
for( int x = 0; x < cTexSizeI.z; x++ )
{
imageStore( uTex1, ivec2( x, y ), vec4( colourSum * recKernelSize, 1.0 ) );
// move window to the next
vec3 leftBorder = imageLoad( uTex0, ivec2( max( x-cKernelHalf, 0 ), y ) ).xyz;
vec3 rightBorder = imageLoad( uTex0, ivec2( min( x+ cKernelHalf +1, cTexSizeI.z-1 ), y ) ).xyz;
colourSum -= leftBorder;
colourSum += rightBorder;
}
- 虽然单次盒式滤镜通道对于大多数用途来说不能提供足够好的模糊效果,但对同一数据集多次重复应用盒式滤镜可以近似高斯分布(参见http://en.wikipedia.org/wiki/Central_limit_theorem)。
附带的示例使用了两通道版本,因为它在合理的性能下提供了足够好的质量,尽管三通道版本在某些情况下确实提供了明显更好的结果。
性能
下图展示了2通道盒式移动平均与高斯和Kawase滤镜的性能对比。正如预期的那样,移动平均滤镜的成本与核大小无关,相当固定,并且在核大小约为127x127时开始变得更优(尤其是在内存带宽更受限的超极本HD4400 GPU上)。
几点说明
- 在Bay Trail(Atom Z3775)上的Windows OpenGL和Android OpenGLES上的性能在所有情况下都几乎相同,因此数据涵盖了两个平台。
- Nexus 7不支持移动平均滤镜,因为它缺少计算着色器支持。
- 在测试的AMD硬件上,多通道计算着色器存在一个问题,其中调用“glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT)”似乎无法正常工作,需要调用“glFinish()”来代替,这使得性能数据的正确性无效,因此这些数据被省略了。
- 在测试的Nvidia硬件上,性能稳定但出乎意料地慢,这暗示了算法实现可能不是最优的(例如,根据计算着色器组的分派大小,性能有很大差异,我无法为两种不同的Nvidia GPU找到最佳匹配),因此在进一步调查之前,这些数据被省略了。
这也说明了要达到计算着色器工作负载的“最佳点”是多么棘手,为了以最佳方式执行,它需要针对特定供应商(甚至是特定供应商的GPU代)进行调整。
摘要
我希望这篇文章为您提供了一些有用的信息:
- 如何可能让您的高斯模糊滤镜运行得更快(以及源代码生成器)。
- 如何编写和使用Kawase滤镜,这是一个从性能角度看,对于除了最小核尺寸之外的所有情况都是高斯模糊的一个很好的替代品。这一点在从Nexus 7平板电脑,到英特尔超极本,再到独立GPU的各种硬件上都成立。
- 在支持计算着色器的平台上(英特尔的Bay Trail是第一个在Android上支持OpenGL ES3.1的平台),两通道(或更多通道)的移动平均盒式滤镜在非常大的核尺寸下会表现得更好。然而,计算着色器方法仅在特定场景下才有实际意义,并且需要针对特定硬件进行调整。
如果您有任何建议、发现任何错误或有任何其他类型的评论,请在页面底部的评论区留言。
一些补充说明
GPU上的内存布局和缓存友好性
- 对每个像素使用大卷积核可能比使用小范围访问的多通道方法更差,因为纹理缓存的原因(而且大卷积核是浪费且低效的)。
- 由于非线性的、平铺的纹理内存寻址(见 http://www.x.org/wiki/Development/Documentation/HowVideoCardsWork/#index1h3),执行水平和垂直计算着色器通道的性能基本相同。这仅适用于访问纹理对象,但不适用于以扫描线方式手动寻址缓冲区的情况,在这种情况下,水平通道中的内存访问将更加缓存友好。在这种情况下,在垂直通道前后进行转置可以获得更好的整体性能。
性能分析说明
所有的性能分析(示例中的“Run Benchmark”按钮)都是通过在选定场景内容大小(1280x720、1600x900、1920x1080和2560x1600)的离屏缓冲区中绘制,并将其显示到1280x720的视口中来完成的,因此这些数字与设备分辨率基本无关。
首先,通过绘制离屏内容并将其应用到屏幕上(1280x720视口)来测量基准成本。
然后,测量工作负载成本(绘制离屏场景、降采样、应用滤镜、升采样、绘制到屏幕)
基准和工作负载之间的差异会显示在屏幕上。
在进行性能分析时,这个过程会循环8次,然后再绘制GUI并显示在屏幕上,以减少干扰。垂直同步被禁用(尽管这在Nvidia最新的驱动程序上似乎不起作用,所以必须在图形控制面板中手动禁用)。
我希望在更新中涵盖的内容
- 通用(和非整数)核大小支持以及效果的淡入/淡出
该示例提供了一个通用的高斯核生成器,适用于任何n*4-1的核大小,但不能用于两者之间的任何大小。“Kawase模糊”有多种预设,但目前没有通用的方法来计算这些。移动平均的实现与高斯类似。
接下来我想做的是:- 为高斯滤镜添加对任何非整数核大小的支持。
- 要么找到一种数学上正确的方法来为任何给定的高斯核创建Kawase通道,要么建立一个暴力破解系统来预生成这些预设,同时处理非整数核大小。
- 为移动平均滤镜实现非整数支持。
- 可能会使用Kawase滤镜实现一个简单的景深/HDR泛光效果以供测试。
- 更优化的移动平均计算着色器实现:需要研究更优化的内存访问模式和GPU线程调度
- 一个更好的高斯滤镜实现?有任何想法——请告诉我(我已经收到了一个很好的想法,正在研究中)!:)
代码示例
该代码示例是使用OpenGL为Windows平台和OpenGL ES为Android平台(NDK)构建的,演示了高斯、Kawase和移动平均(计算着色器)模糊滤镜,并为每种滤镜在各种核大小和屏幕分辨率下提供了基准测试支持。
当检测到OpenGL ES 3.1支持时(例如在英特尔Bay Trail安卓平板电脑上),移动平均计算着色器路径将被启用。
一个包含源代码和二进制文件的包附加在下面。