💡 本系列文章收录于个人专栏 ShaderMyHead。
💡 本文案例可以在 Github 上进行演示。
在游戏画面的实时渲染中,模糊效果不仅能提升画面质感,还常用于引导玩家视线、制造景深或动态速度感等视觉效果。
其中,高斯模糊(Gaussian Blur) 以其平滑柔和的特性,被广泛应用于景深、UI 背景虚化等场景。
(高斯模糊示意图)
径向模糊(Radial Blur) 则能营造出速度感与视觉聚焦效果,非常适合表现冲刺、爆炸或特殊技能释放等瞬间。
(径向模糊示意图)
本文将介绍这两种模糊效果的原理及其基于 Cocos Creator 着色器的实现方法。
一、高斯模糊
1.1 核心原理
图像的模糊算法有很多,比如:
-
均值模糊: 将中心像素和周围像素颜色数值加起来求平均,作为中心像素的模糊结果;
-
中值模糊 把中心像素和周围像素的颜色排个顺序,取中间像素的颜色数值作为模糊结果。
高斯模糊则通过将图像与高斯函数进行卷积来模糊图像、减少图像噪声和细节层次,其数学公式为:
💡 在 GLSL 中可封装为以下函数:
float gaussian(float x, float sigma) { return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(6.2831853) * sigma); }
其中 σ(sigma) 是标准差(standard deviation),它控制了高斯曲线的宽度和平滑程度:
该函数乍一看很复杂,其实它就是我们一直都很熟悉的 正态分布(高斯分布) 的概率密度函数,它们是同一个数学概念在不同领域的称呼。
高斯模糊的核心原理正是利用正态分布的权重分配,对像素周围指定区域进行加权混合,最终得到模糊的混合效果。
1.2 单方向 11 点采样实现
根据高斯函数(假设 σ=4.0),我们可以设定中心像素,以及中心像素两侧共 10 个像素的权重:
// 11点采样的高斯权重(σ=4.0),和权重总和为 1.0
const float w0 = 0.11992; // 中心点的权重(最大)
const float w1 = 0.11618; // 距离中心第1个像素的权重
const float w2 = 0.10583; // 距离中心第2个像素的权重
const float w3 = 0.09052;
const float w4 = 0.07272;
const float w5 = 0.05483; // 距离中心第5个像素的权重
💡 权重总和应该等于
1.0(保证能量守恒),才能保持混合后的像素亮度不变。
以水平方向为例,把该区域的像素分量值按相应权重混合,来得到中心点(在水平方向)模糊后的像素:
在片元着色器中的实现如下:
uniform UBO {
vec2 textureSize; // 纹理宽高
};
// 11点采样的高斯权重(σ=4.0)
const float w0 = 0.11992;
const float w1 = 0.11618;
const float w2 = 0.10583;
const float w3 = 0.09052;
const float w4 = 0.07272;
const float w5 = 0.05483;
vec4 frag () {
vec2 u_texel = vec2(1.0, 1.0) / textureSize; // 计算单像素在UV空间的尺寸(1 像素大小是多少 UV 单位)
vec4 sum = vec4(0.0);
// 单步采样偏移量
vec2 texel = u_texel.x * vec2(1.0, 0.0);
sum += texture(cc_spriteTexture, uv) * w0; // 中心点
// 对称采样
for(int i = 1; i <= 5; i++) {
float weight;
if(i == 1) weight = w1;
else if(i == 2) weight = w2;
else if(i == 3) weight = w3;
else if(i == 4) weight = w4;
else weight = w5;
sum += texture(cc_spriteTexture, uv + texel * float(i)) * weight;
sum += texture(cc_spriteTexture, uv - texel * float(i)) * weight;
}
return sum;
}
该段代码在水平方位实现了两侧对称采样和混合,此时效果如下:
可见高斯模糊效果不明显,这是因为目前采样的距离过于紧凑,我们可以新增 blurSize 参数来设定模糊半径:
uniform UBO {
vec2 textureSize;
float blurSize; // 模糊半径
};
// 略...
vec4 frag () {
// 略...
// 单步采样偏移量(受 blurSize 控制)
vec2 texel = blurSize / 5.0 * u_texel.x * vec2(1.0, 0.0);
sum += texture(cc_spriteTexture, uv) * w0; // 中心点
// 对称采样
for(int i = 1; i <= 5; i++) {
float weight;
if(i == 1) weight = w1;
else if(i == 2) weight = w2;
else if(i == 3) weight = w3;
else if(i == 4) weight = w4;
else weight = w5;
sum += texture(cc_spriteTexture, uv + texel * float(i)) * weight;
sum += texture(cc_spriteTexture, uv - texel * float(i)) * weight;
}
return sum;
}
留意在第 12 行的计算中,我们将 blurSize 除以了 5(对称采样循环的步数,即除中心点之外的采样总次数的一半),来得到每一步要偏移的尺寸。
这是为了确保在进行最后一次对称采样时(即 for 循环中的 i 等于 5 时),偏移距离正好等于 blurSize。
为了方便理解,假设 blurSize 的值为 5.0,则:
/** 没有除以 5 的 for 循环 **/
i=1 → 偏移 5*1 = 5 像素
i=2 → 偏移 5*2 = 10 像素
...
i=5 → 偏移 5*5 = 25 像素 // 严重超出预期!
/** 除以 5 的 for 循环 **/
i=1 → 偏移 1 像素
i=2 → 偏移 2 像素
...
i=5 → 偏移 5 像素 // 刚好等于 blurSize 的值
因此将 blurSize 除以「对称采样循环的次数」很有必要。
此时设定 blurSize 的值为 35.0 时,执行效果如下:
1.3 垂直方向采样和混合
前一小节我们仅在水平方位进行了采样与混合,而高斯模糊最少需要在水平及垂直两个方位进行处理,因此我们可以在着色器里新增 isHorizontal 参数,方便统一维护水平和垂直两方位的高斯模糊逻辑:
/** CCEffect **/
properties:
textureSize: { value: [192.0, 192.0] }
blurSize: { value: 5.0 }
isHorizontal: { value: 1, editor: { range: [0, 1, 1]} }
/** 片元着色器 **/
uniform UBO {
vec2 textureSize;
float blurSize;
int isHorizontal; // 1 水平模糊,0 垂直模糊
};
// 略...
vec4 frag () {
vec2 u_texel = vec2(1.0, 1.0) / textureSize;
vec4 sum = vec4(0.0);
vec2 offsetDir = isHorizontal > 0 ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
// 单步采样偏移量(受 blurSize 控制)
vec2 texel = blurSize / 5.0 * (isHorizontal > 0 ? u_texel.x : u_texel.y) * offsetDir;
sum += texture(cc_spriteTexture, uv) * w0;
// 对称采样
for(int i=1; i<=5; i++) {
float weight;
if(i == 1) weight = w1;
else if(i == 2) weight = w2;
else if(i == 3) weight = w3;
else if(i == 4) weight = w4;
else weight = w5;
sum += texture(cc_spriteTexture, uv + texel * float(i)) * weight;
sum += texture(cc_spriteTexture, uv - texel * float(i)) * weight;
}
return sum;
}
针对水平方位和垂直方位的处理,我们需要使用两个材质 gaussian-blur-horizontal.mtl 和 gaussian-blur-vertical.mtl,其中垂直方位的材质将 isHorizontal 设为 0 即可:
我们新增额外的摄像头以及 Render Texture 文件,将(使用了 gaussian-blur-horizontal.mtl 材质)水平模糊后的画面捕获了渲染到一个 Sprite 节点上,再对其应用 gaussian-blur-vertical.mtl 材质进一步在垂直方向上做模糊处理。执行效果:
完整的高斯模糊流程图如下:
1.4 更多点采样的实现
在前文的实现我们固定单次仅采样 11 个点(即单方向采样 11 次,两个方向共采样 22 次),当 blurSize 参数值较大时(例如等于 50.0),模糊的效果比较粗糙:
解决此问题较好的方案是,将采样的次数可配置化,开发者可以根据模糊强度的需要来自定义采样次数。
我们新增一个 sampleCount 参数来做为除了中心点之外的采样次数,接着遍历其一半的值来进行对称采样:
uniform UBO {
vec2 textureSize;
float blurSize;
int isHorizontal;
int sampleCount; // 除了中心点之外的采样次数
};
// 高斯函数,用于计算权重
float gaussian(float x, float sigma) {
return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(6.2831853) * sigma);
}
vec4 frag () {
int halfSampleCount = sampleCount / 2;
vec2 texel = 1.0 / textureSize;
vec2 dir = isHorizontal > 0 ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
float step = blurSize / float(halfSampleCount);
float sigma = 4.0;
vec4 sum = vec4(0.0);
// 归一化加权和,确保最终输出的颜色值不会因为权重和不为1而导致过亮或过暗
float weightSum = 0.0;
// 先采中心点
float w0 = gaussian(0.0, sigma);
sum += texture(cc_spriteTexture, uv) * w0;
weightSum += w0;
// 对称采样,左右(或上下)一次采两边
for (int i = 1; i < halfSampleCount; i++) {
float w = gaussian(float(i), sigma); // 获取权重
vec2 offset = dir * texel * step * float(i);
vec4 sample1 = texture(cc_spriteTexture, uv + offset);
vec4 sample2 = texture(cc_spriteTexture, uv - offset);
sum += (sample1 + sample2) * w;
weightSum += 2.0 * w;
}
return sum / weightSum;
}
第 24 行的
weightSum作用是确保权重归一化,避免混合后的颜色值因为权重和不为 1 而导致过亮或过暗。假设所有采样的权重和为
0.8,返回的sum的 RGBA 分量值就只有 80%(导致变暗),通过sum / weightSum可以将分量值放大回 100%。
此时控制台会报错:
这是因为在 GLSL 中,for 循环会被展开成 GPU 指令,即 for 循环次数的上下限必须是常量才行,而 halfSampleCount 是通过 uniform 参数 sampleCount 计算得到的运行时动态参数,导致了错误。
绕过该问题的方案比较取巧,改为在循环内部做判断:
for (int i = 1; i < 30; i++) { // 循环上限写死为 30
if (i > halfSampleCount) break; // 实际由 halfSampleCount 控制循环上限
float w = gaussian(float(i), sigma);
// ...
}
我们将 blurSize 的值设为 50.0, sampleCount 的值设为 20,执行后高斯模糊的效果会比前文单方向采样 11 次的效果平滑不少:
💡 采样次数过多会严重影响性能,故应当使用尽可能少的
sampleCount值。💡 可以在后续的新文章《高斯模糊的高性能实现》中学习高性能的实现方案和技巧。
1.5 利用 Linear sampling 减少采样次数
当纹理采样器的过滤模式设为 LINEAR(2D 纹理对应 Bilinear)时,GPU 本身会在硬件层面自动采样相邻 4 个 texel,并按距离做一次插值:
TL ----- TR
| |
| X | ← X 是你采样的位置
| |
BL ----- BR
其等效于我们在着色器中书写了一次线性插值混合。
假设原本的纹理使用的过滤模式为 Nearest(GPU 不会做任何插值处理),且在着色器中单方位采样了 10 次:
for (int i = 0; i < 10; i++) {
float w = gaussian(float(i), sigmaNearest);
// ...
}
几乎等价于使用了 Bilinear 过滤模式的纹理,在着色器中仅采样 5 次的效果:
for (int i = 0; i < 5; i++) {
float w = gaussian(float(i), sigmaLinear);
// ...
}
因此通过给纹理的过滤模式开启 LINEAR 来利用 GPU 硬件层面的单次插值计算,可以让我们在着色器中原有的采样次数减半,也能达成同样效果,进而减轻了 GPU 的负载。
GPU 的 Bilinear filtering 插值几乎不额外消耗性能,因为它是 GPU 纹理单元里的硬件固定管线操作,是为「免费」而设计的。
更多信息可参考《Efficient Gaussian blur with linear sampling》的 Linear sampling 一节。
1.6 加粗模糊轮廓
如果对一行文本(Label)应用上述的着色器,模糊后的文本在视觉上可能会被弱化和变细,这是因为模糊过程会"稀释"文本的实心区域。
如果希望让模糊轮廓呈现更粗的实心部分,可以对混合后的色值进行膨胀:
膨胀后的效果如下(留意亮度会比常规模糊高出不少):
vec4 blur = sum / weightSum;
return min(blur * 2.3, 1.0); // 膨胀 2.3 倍
另一个更好的方案则是启用 Label 组件的描边功能,通过动态减少描边宽度和 blurSize 的值,来达成「模糊时轮廓变粗」的视觉效果:
二、径向模糊
2.1 核心原理
径向模糊与常规模糊不同,它的模糊方向从画面中心向外辐射(或向内聚焦),每个像素的模糊方向取决于其相对于画面中心的位置:
像素的采样与混合可以跟前文的高斯模糊保持一致,只是处理方向变更为径向方向,且在该方向上做对称采样即可(高斯模糊需要在水平和垂直方向上共做 2 次对称采样)。
2.2 径向向量
从过往文章的案例可以知道,获取当前像素到中心的向量和距离,可以通过 uv 向量相减、使用 length 函数来获得:
vec2 dir = uv - vec2(0.5, 0.5); // 中心到当前像素的向量(即径向向量)
float dist = length(dir); // 当前像素到中心的距离(基于 UV 坐标)
将二者相除,则可以获得当前像素的单位方向向量:
vec2 dirNorm = dir / dist; // 当前像素的单位方向向量
留意 dist 的值可能为零,为了避免出现除以零的计算报错,需要补充一段安全代码:
// 如果刚好是中心像素,就直接返回原色(安全代码,避免出现除零错误)
if (dist < 0.001) {
return texture(cc_spriteTexture, uv);
}
vec2 dirNorm = dir / dist;
在获取到 dirNorm 之后,可以算出采样循环时每一步的 UV 空间偏移量:
vec2 texel = blurSize / float(halfSampleCount) * u_texel * dirNorm; // 每一步的 UV 空间偏移量
2.3 复用高斯函数对称采样
参考高斯模糊的实现,径向模糊的对称采样、按权重混合实现如下:
vec4 sum = vec4(0.0);
float weightSum = 0.0;
// 中心点采样权重
float w0 = gaussian(0.0, sigma);
sum += texture(cc_spriteTexture, uv) * w0;
// 对称采样正负方向
for (int i = 1; i <= 30; i++) { // 循环上限写死为30
if(i > halfSampleCount) break; // 实际由 halfSampleCount 控制循环上限
float w = gaussian(float(i), sigma);
vec2 offset = float(i) * texel;
// 沿射线方向采样
vec4 sample1 = texture(cc_spriteTexture, uv + offset); // 远离中心
vec4 sample2 = texture(cc_spriteTexture, uv - offset); // 朝向中心
sum += (sample1 + sample2) * w;
weightSum += 2.0 * w;
}
return sum / weightSum;
此时执行效果如下(blurSize 和 sampleCount 分别为 15.0、16):
通过调整材质 blurSize 参数值,可以获得不同强度的径向反射效果: