Cocos Creator Shader 入门 ⒁ —— 高斯模糊和径向模糊的实现

1,036 阅读11分钟

💡 本系列文章收录于个人专栏 ShaderMyHead

💡 本文案例可以在 Github 上进行演示

在游戏画面的实时渲染中,模糊效果不仅能提升画面质感,还常用于引导玩家视线、制造景深或动态速度感等视觉效果。

其中,高斯模糊(Gaussian Blur) 以其平滑柔和的特性,被广泛应用于景深、UI 背景虚化等场景。

image.png

(高斯模糊示意图)

径向模糊(Radial Blur) 则能营造出速度感与视觉聚焦效果,非常适合表现冲刺、爆炸或特殊技能释放等瞬间。

image.png

(径向模糊示意图)

本文将介绍这两种模糊效果的原理及其基于 Cocos Creator 着色器的实现方法。

一、高斯模糊

1.1 核心原理

图像的模糊算法有很多,比如:

  • 均值模糊: 将中心像素和周围像素颜色数值加起来求平均,作为中心像素的模糊结果;

  • 中值模糊 把中心像素和周围像素的颜色排个顺序,取中间像素的颜色数值作为模糊结果。

高斯模糊则通过将图像与高斯函数进行卷积来模糊图像、减少图像噪声和细节层次,其数学公式为:

G(x)=1σ2πex22σ2G(x) = \frac{1}{\sigma\sqrt{2\pi}} e^{-\frac{x^2}{2\sigma^2}}

💡 在 GLSL 中可封装为以下函数:

  float gaussian(float x, float sigma) {
    return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(6.2831853) * sigma);
  }

其中 σ(sigma) 是标准差(standard deviation),它控制了高斯曲线的宽度和平滑程度:

image.png

该函数乍一看很复杂,其实它就是我们一直都很熟悉的 正态分布(高斯分布) 的概率密度函数,它们是同一个数学概念在不同领域的称呼。

高斯模糊的核心原理正是利用正态分布的权重分配,对像素周围指定区域进行加权混合,最终得到模糊的混合效果。

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(保证能量守恒),才能保持混合后的像素亮度不变。

以水平方向为例,把该区域的像素分量值按相应权重混合,来得到中心点(在水平方向)模糊后的像素:

image.png

在片元着色器中的实现如下:

  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;
  }

该段代码在水平方位实现了两侧对称采样和混合,此时效果如下:

image.png

可见高斯模糊效果不明显,这是因为目前采样的距离过于紧凑,我们可以新增 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 时,执行效果如下:

image.png

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.mtlgaussian-blur-vertical.mtl,其中垂直方位的材质将 isHorizontal 设为 0 即可:

image.png

我们新增额外的摄像头以及 Render Texture 文件,将(使用了 gaussian-blur-horizontal.mtl 材质)水平模糊后的画面捕获了渲染到一个 Sprite 节点上,再对其应用 gaussian-blur-vertical.mtl 材质进一步在垂直方向上做模糊处理。执行效果:

image.png

完整的高斯模糊流程图如下:

image.png

1.4 更多点采样的实现

在前文的实现我们固定单次仅采样 11 个点(即单方向采样 11 次,两个方向共采样 22 次),当 blurSize 参数值较大时(例如等于 50.0),模糊的效果比较粗糙:

image.png

解决此问题较好的方案是,将采样的次数可配置化,开发者可以根据模糊强度的需要来自定义采样次数。

我们新增一个 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%。

此时控制台会报错:

image.png

这是因为在 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.0sampleCount 的值设为 20,执行后高斯模糊的效果会比前文单方向采样 11 次的效果平滑不少:

image.png

💡 采样次数过多会严重影响性能,故应当使用尽可能少的 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 倍

image.png

另一个更好的方案则是启用 Label 组件的描边功能,通过动态减少描边宽度和 blurSize 的值,来达成「模糊时轮廓变粗」的视觉效果:

Aug-14-2025 01-41-06.gif

二、径向模糊

2.1 核心原理

径向模糊与常规模糊不同,它的模糊方向从画面中心向外辐射(或向内聚焦),每个像素的模糊方向取决于其相对于画面中心的位置:

image.png

像素的采样与混合可以跟前文的高斯模糊保持一致,只是处理方向变更为径向方向,且在该方向上做对称采样即可(高斯模糊需要在水平和垂直方向上共做 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;

此时执行效果如下(blurSizesampleCount 分别为 15.016):

image.png

通过调整材质 blurSize 参数值,可以获得不同强度的径向反射效果:

3.gif