高斯模糊是我们非常熟悉的一个技术功能。开发者泉小墨通过双线性采样对高斯模糊进行深度优化,示例工程已升级到 Cocos Creator 3.6.2,下载地址见文末。
先来看看最终效果:
模糊前
9x9 高斯核四次迭代效果
高斯模糊是游戏后处理中经常会用到的模糊技术,它是如何被实现的?有什么方法可以优化其性能呢?本次,我们就将了解一下高斯模糊的实现,并探索深度优化的方法,用更少的计算量得到更好的模糊效果。
本文着重围绕以下几点展开:
高斯模糊的基础实现
高斯模糊的线性分解
高斯模糊的双线性采样
Cocos 后处理应用
前期准备
首先我参考了论坛大佬陈皮皮关于高斯模糊的帖子。
高斯模糊 Shader,by 陈皮皮:
https://forum.cocos.org/t/shader/93262
该方案的模糊效果
效果确实可以,也能做到深度模糊的效果,但是性能不是很理想。我的界面的分辨率是 960x640,若设置模糊半径为 20,每一帧的计算量是 960x640x41x41=10.3 亿次。PS:为什么是 41 呢?因为半径是 20,原点是 1,就是 20+1+20,半径为 R,即 (2R+1)x(2R+1) 的高斯核。
简而言之,这个方法反正是把我的电脑干趴了。若将半径调成 4,计算量是 4976 万次,效果即为上面示例图的效果。那有有没有优化空间呢?
注:由于每次乘 960 和 640 较难看出数据变化,后续的计算量都除掉这两个值。
核心思路
高斯模糊在图像处理领域,通常用于减少图像噪声以及降低细节层次,以及对图像进行模糊,其视觉效果就像是经过一个半透明屏幕在观察图像。
从数字信号处理的角度看,图像模糊的本质一个过滤高频信号,保留低频信号的过程。过滤高频的信号的一个常见可选方法是卷积滤波。从这个角度来说,图像的高斯模糊过程即图像与正态分布做卷积。由于正态分布又叫作“高斯分布”,所以这项技术就叫作高斯模糊。而由于高斯函数的傅立叶变换是另外一个高斯函数,所以高斯模糊对于图像来说就是一个低通滤波器。
说到高斯模糊,就得说到高斯核,高斯核的一个基础模型如下:
高斯函数的三维示意图
输入的每个像素点计算时都会将该像素周围一圈的像素点(模糊半径)通过基于高斯核的权重计算一遍然后加起来当做输出值。
高斯模糊也可以在二维图像上对两个独立的一维空间分别进行计算,即满足线性可分(Linearly separable)。这也就是说,使用二维矩阵变换得到的效果也可以通过在水平方向进行一维高斯矩阵变换加上竖直方向的一维高斯矩阵变换得到。从计算的角度来看,这是一项有用的特性,因为这样只需要 M*N*m+M*N*n 的计算复杂度,而原先的计算复杂度为 M*N*n*m,其中 M、N 是需要进行滤波的图像的维数(像素),m、n 是滤波器的维数(模糊半径)。
以下为一个 Gaussian Kernel 的线性分解过程:
上图是滤波 5 次,杨辉三角展示了二项式系数,它可以用来计算卷积核权重(每个元素是上一排的两个相邻元素的和)。
杨辉三角
我们以最下面一行当做数据样本,最下面一行的数字和是 4096,因为 1/4096 和 12/4096 的值比较小,我们为了保持更加 nice 的效果,可将 1 和 12 的参数去掉,那数字和就成了 4070,每个的权重就是 [66,220,495,792,924]/4070。
实现过程
本次我们使用相机将场景渲染成 renderTexture,再用 Sprite 装载,最后用 Canvas 的相机渲染到屏幕。具体流程可以参考我之前发的帖子。
3D 摄像机捕捉到 renderTexture 再渲染到 2D 相机,by 泉小墨:
https://forum.cocos.org/t/topic/142089
我们从总权重为 4070 的 9x9 高斯核开始说起。
N x M -> N + M
_BlurOffsetX: {value: 0,editor: { slide: true, range: [0, 1.0], step: 0.0001 }}
_BlurOffsetY: {value: 0,editor: { slide: true, range: [0, 1.0], step: 0.0001 }}
首先定义 2 个 uniform 去记录单个像素的 uv 偏移,做了 2 个进度条可以动态调整水平和垂直方向的 uv 偏移,其中 size 是屏幕的尺寸,因为进度条是从 0~1 的,所以除以 size 后就变成了单个像素的 uv 偏移(PS:我这里用 2 做了缩放,调到最大相当于每次的偏移量是 2 个像素,只是为了测试效果)。
顶点着色器没做任何修改,片段着色器代码如下,因为是 9x9,所以:
vec4 GaussianBlur() {
// 原点
vec4 color = 0.2270270270 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0);
// 右边/上方的采样点
color += 0.1945945946 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(1.0 * _BlurOffsetX , 1.0 * _BlurOffsetY ));
color += 0.1216216216 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(2.0 * _BlurOffsetX , 2.0 * _BlurOffsetY ));
color += 0.0540540541 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(3.0 * _BlurOffsetX , 3.0 * _BlurOffsetY ));
color += 0.0162162162 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(4.0 * _BlurOffsetX , 4.0 * _BlurOffsetY ));
// 左边/下方的采样点
color += 0.1945945946 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(-1.0 * _BlurOffsetX , -1.0 * _BlurOffsetY ));
color += 0.1216216216 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(-2.0 * _BlurOffsetX , -2.0 * _BlurOffsetY ));
color += 0.0540540541 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(-3.0 * _BlurOffsetX , -3.0 * _BlurOffsetY ));
color += 0.0162162162 * CCSampleWithAlphaSeparated(cc_spriteTexture, uv0 + vec2(-4.0 * _BlurOffsetX , -4.0 * _BlurOffsetY ));
return color;
}
将进度条拉到最大,我们得到了一次模糊的效果,效果如下:
一次模糊
这个效果和 9x9 的模糊效果基本一致,但是计算量从 9x9=81 缩减到了 9+9=18。但是,这个效果其实是有问题的,后面我们会讲到。
多次滤波
想要实现多次滤波,必须是经过一次滤波之后,将本次输出图片的结果当做参数传到第二次滤波。本次我们使用方案:分层+多相机来实现。
在项目设置中,我加了 8 个 Step,并在 Canvas 地方摆放了 8 个精灵,每个精灵对应一个 Step,并创建了 8 个摄像机分别去拍这 8 个精灵,并且摄像机的渲染优先级从低到高,这样 8 个摄像机总共可以滤波 8 次。
此处我参考了论坛的屏幕后处理特效范例技术方案,这个 Demo 真是强大,一共实现了 14 种屏幕后处理特效,对我来说所得即所需。这里封装好的代码我就不细说了,可以直接下载 Demo 了解。
屏幕后处理特效范例:
https://forum.cocos.org/t/topic/137605
我按上面的代码,对模糊结果处理了 4 次,得到如下的效果:
四次模糊
我发现有些模糊后的细节发生了扭曲,这肯定是哪里出了点问题。我们回到之前看到的公式 NxN 转化成 Nx1 与1xN,是需要 Nx1 计算完了再去计算 1xN 的,而我目前的算法是两者一起计算,用大白话说,原本只计算了水平模糊、再计算垂直模糊,现在我水平和垂直交替处理,那么在第二次迭代时,本应该只计算垂直方向的像素值已经被第一次迭代给污染了,导致最终结果有点不一致,所以模糊结果发生了扭曲。
修正后的多次滤波
所以我们要对代码进行修正,下面是修正后的代码:
我们先用一个 pass 处理水平方向,再用一个 pass 处理垂直方向的,水平、垂直交叉出现,我将这个理解成乒乓交叉。虽然经过 4 个 pass 处理,但是它其实还只是算两次迭代。效果如下:
采用乒乓交叉的两次迭代
对比上一张图,可以看出现在的效果前所未有的丝滑,而且模糊效果也比最初的强一些,可以看出随着迭代次数的增加,模糊效果也会增加。那么我们就来试试四次迭代,得到的效果如下:
采用乒乓交叉的四次迭代
此时的模糊程度比最初的效果强得多,计算量为 9x4x2=72 次,比原本的 81 次还要少。我们用更少的计算量得到了更好的模糊效果,但这还不是结束。
线性采样
到此为止,我们假设了必须要做一次贴图读取来获得一个像素的信息,意味着 9 个像素需要 9 次贴图读取。尽管这对于在 CPU 上的实现来说是成立的,但在 GPU 上却不总是这样。这是因为在 GPU 上可以随意地使用双线性插值(bilinear sampling)而没有什么额外的负担。这意味着如果不在纹素中心读取贴图,就可以得到多个像素的信息。既然已经利用了高斯函数的可分离性,实际上是在 1D 下工作,双线性插值会提供 2 个像素的信息。每个纹素贡献对颜色的贡献量则由使用的坐标决定。
通过正确地调整贴图读取的坐标偏移,可以仅通过一次贴图读取得到两个像素或纹素的准确信息。这意味着为了实现一个 9x1 或 1x9 的高斯滤波器只需要 5 次贴图读取。总的来说,实现 Nx1 或 1xN 的滤波器需要 [N/2] 次贴图读取。
怎么理解这一句话呢?
根据上面的公式,算出 A 点的坐标是 1.3846153846,B 点的坐标是 3.2307692308。将其带入到之前的片段着色器中,代码如下:
双线性采样
这时候你会发现跟之前的结果几乎一样,但此时的计算量是 5x4x2=20 次。
如果去掉四次迭代的效果,只需要原本的 9x9 的效果,计算量只需要 5x2=10 次,是不是极大提升了性能!
资源下载
完整工程:
https://gitee.com/onion92/cocos3d-shader-effect
论坛专贴
https://forum.cocos.org/t/topic/142241
总结一下,如果你需要深度模糊的效果,不妨增加迭代次数,或者加大采样半径,采用双线性采样,即使是 13x13 的高斯核,一次迭代也只需要 7x2=14 次计算。
完整工程源码放在我的 gitee 上了,感兴趣的小伙伴可以前往上面的地址下载取用,希望可以对大家有所帮助。同时欢迎大家关注我的个人公众号,我将不定期分享一些 Cocos 开发相关经验与干货!
往期精彩