最近工作上在研究将Android 12的窗口背景模糊移植到Android 11的系统上,就是下面这张图中的a效果所示:

方案是研究出来了,但是考虑到潜在的法律风险因素,不能往外发,因此这篇文章就来对Android 11 上背景模糊的核心------BlurFilter的原理做一个简单的剖析吧。
1. 模糊原理
所谓模糊,可以理解成对图像上的每一个像素都取周边像素的平均值,在图形上,就相当于产生模糊效果,中间点失去细节。计算平均值时,取值范围越大,模糊效果越强烈。
模糊的算法由很多种,我们所熟知的"高斯模糊"只是众多模糊算法中名气最大的一个,常见的模糊算法有以下这些:
- 高斯模糊(Gaussian Blur)
- 方框模糊(Box Blur)
- Kawase模糊(Kawase Blur)
- 双重模糊(Dual Blur)
- 散景模糊(Bokeh Blur)
- 移轴模糊(Tilt Shift Blur)
- 光圈模糊(Iris Blur)
- 粒状模糊(Grainy Blur)
- 径向模糊(Radial Blur)
- 方向模糊(Directional Blur)
这些算法都有什么优缺点,哪个算法更好呢?在评价这些模糊算法的时候,一般从以下三个维度考虑:
(1)模糊品质(Quality):模糊品质的好坏是模糊算法是否优秀的主要指标。
(2)模糊稳定性(Stability):模糊的稳定性决定了在画面变化过程中,模糊是否稳定,不会出现跳变或者闪烁。
(3)性能(Performance):性能的好坏是模糊算法是否能被广泛使用的关键所在。
按照上述标准,它们的性能对比如下表所示:
模糊算法 | 模糊品质 | 稳定性 | 性能 |
---|---|---|---|
高斯模糊(Gaussian Blur) | 高 | 好 | 一般 |
方框模糊(Box Blur) | 高 | 好 | 一般 |
Kawase模糊(Kawase Blur) | 高 | 好 | 较好 |
双重模糊(Dual Blur) | 高 | 好 | 好 |
散景模糊(Bokeh Blur) | 高 | 好 | 差 |
移轴模糊(Tilt Shift Blur) | 高 | 好 | 差 |
光圈模糊(Iris Blur) | 高 | 好 | 差 |
粒状模糊(Grainy Blur) | 一般 | 好 | 好 |
径向模糊(Radial Blur) | 高 | 好 | 一般 |
方向模糊(Directional Blur) | 高 | 好 | 一般 |
Android 官方系统采用的模糊算法采用了Kawase Blur和Dual Blur相结合的算法。Kawase Blur其模糊效果与高斯模糊十分接近, 但在相似的模糊表现下,其效率比高斯模糊要快1.5到3倍。下文先介绍Kawase Blur,只有了解清除了这个才能讲解Dual Blur。
关于Kawase算法的原理,目前互联网上各资料都讲的不太透彻,看起来让人一头雾水。本文重新总结拆解了一下,尽量让它看起来容易理解一点。先从步长为1个单位(下文简称步长1)的情况开始看起。

上图是Kawase算法采样步长1的示意图。当处理由黑点表示的像素时,选择由周围四个红点表示的采样点来求和平均计算所得。采样时选择的采样点(即红点)是红点四周的四个像素通过插值计算所得。在3*3像素矩阵的情况下,其最终计算效果等效于对黑点及黑点周围的8个像素按照上图右侧图示的权重进行加权求和,得到的即为黑点的采样像素。
那么问题来了,为什么不直接采用右侧的加权值,对像素进行计算呢?原因在于,采用左侧的方式可以充分利用GPU的性能,让GPU来执行插值的操作,通过这种方式,读取像素纹理的次数已从9次减少到4次,充分利用GPU双线性插值采样开销低的优势实现了算法的优化。

上面这张图为步长2和步长3的情况。为了尽可能提高模糊效果,Kawase算法可以选择对进行多次模糊,并对更多像素进行采样。例如,将上述方法用作第一次模糊,可以通过增加采样点的偏移量来执行下一次模糊。如上图所示,假如图像模糊的1个单位步长代表2个像素,那么在下一次模糊时,黑点会选择偏移量为2个步长(即4个像素)处的红点,用于计算自身的像素值。而且这种迭代采取了一种Ping-Pong Blit 的方式进行。所谓Ping-Pong Blit,指的是将上一次的模糊好的图片,在下一次迭代时以素材的形式输入,进行更进一步的模糊。
按照以上方法,进行多次模糊后,就可以实现类似于高斯模糊的效果了。
解释完Kawase算法后,下面可以来了解一下Dual Blur了。严格来说,Dual Blur并不是一种算法,它只是一种优化思想。它可以搭配Kawase Blur使用,也能够搭配Gaussian Blur 使用。它的实现思路在于:在原有模糊方法的基础上,采用降采样、升采样的方法进行迭代,从而达到模糊的优化。
那么什么叫降采样?什么叫升采样呢?
图像的渲染是基于画布进行的,画布存在宽高的属性。如果画布宽高越来越小,则采样的速度越快,渲染速度也越快,相比之下画质也会变模糊,这个过程叫降采样。
相反,如果画布越来越大,采样的速度变慢,但画质渲染会更加精细,渲染速度变慢,这样过程就叫做升采样。
Dual Blur就是不断地在迭代过程中,反复利用降采样和升采样的方法,在降采样后模糊,然后再升采样,来达到优化模糊效果和效率的目的。
2. Android BlurFilter
在Android 11中增加模糊效果,有一点特别的好处就是,谷歌官方已经在系统中提供了一个基于opengl es3.1的模糊滤镜类,不需要我们从头去开发。具体的类路径如下所示:
frameworks/native/libs/renderengine/gl/filters/BlurFilter.cpp
下面来具体分析一下Android 11 模糊方案的具体实现。先从GLSL开始看起,看看BlurFilter是如何控制GPU去渲染来实现模糊效果的。
首先是顶点着色器,具体代码如下:

这段代码就是原封不动地将传入的顶点坐标数据传递给GPU,可以看到代码中没有任何的变换修改,另外,定义了vUV变量保存传入的纹理坐标,用于传递给接下来的片段着色器中,对纹理实施变换操作。
BlurFilter中定义了两个片段着色器,片段着色器一是用来生成将纹理素材模糊后的图像的,代码如下:

注意到代码的第5行,in highp vec2 vUV这个vUV变量么,它的数据就是从前文提到的顶点着色器中传入的纹理坐标了。从代码上看,android在做模糊采样时,并没有严格按照采样点四周的四个像素通过插值计算的结果来作为采样像素值,仅仅只是取样了指定偏移量下的四个方位的像素点的数据来直接代入计算。这种设计方式有两种考量,一是考虑到效率的因素,需要效率优先而舍弃一定的模糊质量。二是引入了Dual Blur,Dual Blur在纹理的缩放过程中就已经对纹理进行了线性插值,一定程度上弥补了算法设计上的不足。
片段着色器二是对模糊后的纹理与原始图像进行混色处理,代码如下:

上述代码有3个关键变量,uCompositionTexture、uTexture以及uMix。uCompositionTexture是模糊前的原始纹理,uTexture是模糊后的纹理,uMix是混合因子。两张纹理图片会通过指定的uMix值来进行混合插值。经过插值处理后的图像,就是最终的模糊效果图了。
在前文的模糊原理中,我们从未提到过要和原图做一次混色的操作,这里为什么需要做这样的行为呢?原因在于,为了更好的显示效果。
为了更进一步解释这个问题,首先得先重点介绍一下GLSL中的mix这个函数。
GLSL语言提供了Mix函数,可以用来混合不同的颜色。Mix函数接受三个参数,它们分别是两个需要混合的值和一个混合因子。混合因子是一个0-1之间的值,它表示第一个值填充的比例。如果混合因子为0,则返回第一个值;如果混合因子为1,则返回第二个值;如果混合因子为0.5,则返回两个值的平均值。
因此,传入的uMix值决定了最终生成的模糊图像的呈现效果。uMix为0,表示输出了原图,最终呈现的图像没有任何模糊效果;uMix为1,表示呈现的图像是完全模糊后的图像;如果传入的值为0.2呢?那可能的视觉效果是,图像整体细节清晰,但又带一点轻微模糊的感觉。
BlurFilter中具体设置的uMix值到底是多少呢?我们可以从它的render方法里的这一行代码中找到答案:
GLfloat mix = fmin(1.0, mRadius / kMaxCrossFadeRadius);
其中mRadius指的是模糊半径,kMaxCrossFadeRadius是预设的常量,表示开始完全模糊的最小模糊半径,这里定义的是30。也就是说,但指定模糊效果的模糊半径小于30时,GPU就会启用混色算法,将模糊的图像与原图按照特定的混合因子进行混合,以优化视觉效果。
前文还提到,BlurFilter在模糊时,还采用了Dual Blur么。这一点,我们可以在prepare方法中找到相关的处理代码:

可以看到,这里采用了两张画布来进行渲染,draw以及read。两张画布的大小是不相同的,其中draw画布大小是read画布的1/4。当纹理被渲染到draw画布后,对纹理进行Kawase模糊,并将模糊后的图像放大到read画布上。放大时GPU会对图像中的像素进行双线性差值,使图像的颜色过渡尽量显得平滑,不会出现断层现象。然后交换read和draw画布,将此次输出的图像作为下次迭代的输入,同时增加第二次迭代时的步长。如此反复,直到达到限定的passes次数。
那么,Kawase算法到底要迭代多少次呢?代码中是这么规定的:
c++
const auto radius = mRadius / 6.0f;
const auto passes = min(kMaxPasses, (uint32\_t)ceil(radius));
迭代次数与模糊半径有关,但最多不超过kMaxPasses次(这里是4次)。也就是说,当模糊半径大于24时,它的迭代次数也最多只能是4次了。
3. GLES渲染引擎的处理
前面讲了模糊原理,接下来要讲一下GLES渲染引擎在模糊方案所做的一些处理了。我们需要对窗口后方的屏幕做模糊效果,但是怎么样才能知道,具体是哪些层级的窗口需要模糊呢?
在渲染引擎中,我们用Layer的概念来表示一块独立的图像缓冲区,这些独立的图像缓冲区保存着将要渲染到屏幕上的图像内容。为了便于理解,一定程度上,我们可以把Layer与android 上层的Window划等号(不完全是一一对应关系)。在WindowManager中,android 11提供了一个flag ------ FLAG_BLUR_BEHIND,用于给Window的Attrs打上一个标记,这个标记意味着当前Window所在的Layer以下的所有图层,都需要进行模糊处理。
然而,在GLES渲染引擎中,你无法找到任何有关这个flags的标识。渲染引擎判断模糊的条件非常简单,如果当前layer的背景模糊半径backgroundBlurRadius值大于0,则表示当前类以下的图层都需要模糊。
那前文提到的FLAG_BLUR_BEHIND到底有什么用,渲染引擎压根就不关注它?其实,这个参数是在framework层处理的,只有设置了这个flag,Window才能被设置模糊半径,否则它的值就是0。
Layer在渲染引擎里保存的容器是vector,且窗口层级越低的Layer,越早被压入vector中。请注意这一点顺序,它对于特定层级下的内容模糊有着至关重要的作用。
渲染引擎对Layer的渲染方式由于牵涉到opengl的帧缓冲区对象(FBO)的相关知识,解释起来会非常复杂。为了能更好地方便理解,还是用一个比喻来解释一下渲染流程。
我们用建房子来类比渲染引擎对一帧图像的渲染过程,假如我们要造7层楼,每建造一层楼,就好比于渲染引擎完成一个长度为7的Layer vector的渲染。
造房子要从第一层开始造起,渲染也是如此,要从最底层的Layer开始渲染。
今后每修一层,都要先拿建造图纸看一下,看建造图纸上在这一层上有没有特殊的需求,比如给下面所有已经修好的楼层外墙给贴上瓷砖。有的话,先贴好瓷砖,再开始修建这一层。渲染逻辑也是如此,在layer开始渲染前,先判断当前layer有没有模糊的需求(即layer的backgroundBlurRadius属性值大于0),有的话,对之前已完成渲染的纹理增加模糊效果,然后再将当前Layer的纹理渲染上去。
如此反复,完成7层楼的修建,然后交付房屋。渲染引擎也是如此,完成全部Layer的渲染后,将离屏渲染的内容输出到屏幕上,显示1帧的画面。