💡 本系列文章收录于个人专栏 ShaderMyHead。
💡 本文案例可以在 Github 上进行演示。
一、仅通过着色器实现的高斯模糊
在《高斯模糊和径向模糊的实现》一文中我们已经介绍了高斯模糊的原理和着色器实现,其原理为通过正态分布规律,分配权重来混合每个像素各方向附近的像素色值。
下图为常规在水平和垂直方位做混合处理的高斯模糊实现流程:

如果希望实现一个高质量的实时模糊效果,一般是需要对被模糊物体进行多次采样的 ------ 采样次数越高,模糊的效果越细腻,但性能也越差。
下方为一台红米低端机的表现情况:

读者可以在线上演示页进行体验。
其表现为:
- 在不应用模糊特效时,可以稳定在 60 FPS 的帧率;
- 在「单次采样 10 次(双方位共采样 20 次)」的情况下,帧率降到 50 FPS 左右,且画面存在可察觉的晶格方块;
- 在「单次采样 30 次(双方位共采样 60 次)」的情况下,画面模糊效果非常细腻,但游戏帧率只能维持在 30 FPS 左右。
GPU 负载可以被简单地量化为公式:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 负载 = 像素数量 × 采样次数 × 刷新频率 负载 = 像素数量 \times 采样次数 \times 刷新频率 </math>负载=像素数量×采样次数×刷新频率
就能看出着色器在单次 DrawCall 里采样的次数所造成的 GPU 负载,会是线性提升的关系。 这兴许也是 Cocos Creator 没有内置高斯模糊组件的原因 ------ 怕被滥用导致游戏性能崩溃。
在移动端图形开发中,纹理采样通常是仅次于 Overdraw(过度绘制)的性能杀手。对于中低端手机而言,在全屏分辨率下进行采样,单次 DrawCall 内采样次数与性能的影响大致如下(仅供参考):
-
安全区 :10 次以内
- 这是标准 Shader 的开销,几乎所有手机都能跑满 60 帧;
-
警戒区 :20~30 次
- 中低端机型开始出现发热,或者在画面复杂时掉帧到 45-50 左右;
-
卡顿区 :40~60 次
- 可能会导致千元机掉帧到 30 帧以下,甚至严重发热;
-
幻灯片区 :70次以上
- 如果不降分辨率,在低端机上会表现得异常卡顿,甚至高端机也会因为带宽过热而降频。
💡 对于游戏画面是静态、可预知的场景,可以不走着色器方案,改而让美术同学提供一张模糊后的图片叠在顶层即可。例如下方是一个仅使用图片实现的高斯模糊、遮罩方案:
然而在大部分游戏场景中,需要被高斯模糊的画面内容都是不可预期的,那么这种纯图片的高性能模糊方案便难以应用。
二、用小尺寸 Render Texture 放大画面
在往期的着色器案例中我们经常使用 Render Texture 来存储摄像机捕获的离屏画面,常规而言会给 Render Texture 配置一个和拍摄画面一致的尺寸。
那么如果一开始就给 Render Texture 设置一个很小的尺寸(例如拍摄画面的 1/10),再以拍摄画面的完整尺寸进行渲染,是否就能达成一个放大、高斯模糊的效果?
下方图片是对这个有趣的想法的实践:

可以看到画面随着 Render Texture 被放大变得更加模糊,但是质量也变得更差、出现越来越明显的方块。
这个问题其实也很容易理解 ------ 原本画面里的细节、边缘、光影变化,全部在缩小时被抹除掉了,相当于我们拿一张很小的图片强行把它进行放大:

然而该方案有个巨大的优势 ------ 可以大幅缩减 GPU 需要采样的像素点,从而进一步减轻 GPU 负载,因此我们可以将其利用起来,只要避免放大太多倍即可。
三、Render Texture 放大 + 着色器低采样的高性能方案
当 Render Texture 只放大五倍时,会产生模糊程度较明显、失真程度也尚可接受的效果:

如果此时再搭配着色器低采样(单方向仅采样 10 次,双方位共 20 次),便能达到一个质量良好的高性能模糊方案:

我们可以使用前文提及的 GPU 负载公式来量化此方案的性能优化成果:
- 仅使用着色器采样实现同效果的高斯模糊时,采样次数大概需要共 40 次,则 <math xmlns="http://www.w3.org/1998/Math/MathML"> G P U 负载 = 像素数量 × 40 × 刷新频率 GPU 负载 = 像素数量 \times 40 \times 刷新频率 </math>GPU负载=像素数量×40×刷新频率;
- 使用此方案时,采样次数大概需要共 20 次,像素数量仅为
1/5,则 <math xmlns="http://www.w3.org/1998/Math/MathML"> G P U 负载 = 像素数量 / 5 × 20 × 刷新频率 GPU 负载 = 像素数量 / 5 \times 20 \times 刷新频率 </math>GPU负载=像素数量/5×20×刷新频率;
假设刷新频率一致,那后者的 GPU 负载仅为前者的 1/10,这是个极大的性能优化了。
在安卓低端机中,该方案依旧可以保持在 60 FPS 左右的帧率:

四、烘焙并复用模糊效果,让高性能和高质量并驾齐驱
对于要求比较苛刻的美术同学而言,上一节的高性能方案可能无法达成其满意的视觉效果(如果仔细看确实还能看出一些方块)。
对此我们可以在前文方案的原理上做进一步优化处理 ------ 对高负载的处理进行「烘焙」和「时间切片」处理。
4.1 烘焙和复用单帧模糊效果
我们先继续维持上一节的「Render Texture 放大 + 着色器采样」的方案,不过我们把 RT 仅放大 2 倍(减少失真),并将着色器单次采样的数量从 10 次增加到 20 次(让模糊效果更细腻),效果如下:

此举虽然进一步提升了高斯模糊的质量(模糊的效果已经非常细腻了),但定然也会大幅增加 GPU 的负载,然而重点来了 ------ 我们只需对画面进行单次模糊处理(处理后不再进行着色器采样),在后续一直复用这一帧的画面即可。
其实现方案并不玄乎 ------ 新增一个摄像头用于捕获模糊后的离屏画面到 Render Texture 并在最顶层新增一个 Sprite 节点渲染出来,然后使用 scheduleOnce 在下一帧禁用新增的摄像头、之前进行着色器采样的节点即可:
js
// 启用单帧模糊烘焙
startBaking() {
// 略...
// 在下一帧关掉摄像头,并隐藏所有使用着色器的节点
this.scheduleOnce(() => {
this.singleFrameCamera.enabled = false; // 关闭新增的摄像头
this.BGHoriNode.active = false; // 禁用水平方位采样节点
this.BGVertNode.active = false; // 禁用垂直方位采样节点
});
}
此时虽然新增的摄像头失效了,但其捕获的高斯模糊画面依旧留存在对应的 Render Texture 中(并渲染在顶层 Sprite 节点),因此能在后续一直看到这一高质量的高斯模糊画面,且不再进行任何的着色器采样(性能极高):

可以看到在安卓低端机中能实现高质量的、实时的单帧模糊(留意会维持烘焙的静态画面),同时保持在 60 FPS 的游戏帧率。
如果你需要被模糊的画面是不可预知的,但又是静态的,那么该方案会特别适合你。
4.2 通过时间切片实现动态模糊
上一小节的方案有个明显的问题 ------ 原本的动画被截停了,如果需要实现一个实时的、动态的、高质量且高性能的高斯模糊效果,该方案就无法使用。
我们先了解一个有趣的事实 ------ 人眼对高频细节(清晰的物体)的运动极其敏感,但对低频细节(模糊的物体)的运动感知非常迟钝,即使在 3A 主机游戏中(如《赛博朋克2077》、《最终幻想》等),也经常使用「前景 60 帧 + 背景 30 帧」的混合渲染技术。
这意味着被模糊的画面(通常都是背景)其实无需高帧率更新。
基于这一点,我们可以利用时间切片的方法,在 update 生命周期里限定单帧烘焙方法以 30 帧的频率来执行:
js
private _timer: number = 0;
private _bakeInterval: number = 1 / 30; // 锁定 30 FPS 的模糊效果刷新频率
update(deltaTime: number) {
this._timer += deltaTime;
if (this._timer >= this._bakeInterval) {
this._timer = 0;
this.startBaking(); // 定时烘焙
}
}
理论上对于高斯模糊的背景,将更新频率降低到 15 FPS 用户也未必看得出来。
这意味着 GPU 仅会在每 33ms 左右才进行一次高斯模糊采样处理,对于绝大部分手机而言,33ms 是一个足够大的窗口期了。
最终,通过多管齐下的方案搭配,我们得以在安卓低端机上也实现 60 FPS 左右的高质量模糊动画:

💡 本文的图片会被平台压缩丢失质量,建议读者在线上演示页进行体验。
