Cocos Creator Shader 入门 ⑹ —— 灰阶、反色等滤镜的实现

78 阅读11分钟

💡 本系列文章收录于个人专栏 ShaderMyHead,欢迎订阅。

在片元着色器中对像素颜色进行有规律的改动,可以实现各种有趣的滤镜效果。

本文会介绍部分常规着色器滤镜(效果见下图),它们大多是用固定范式来实现的。

image.png

一、灰阶滤镜

通过 GLSL 内置的 dot 函数,计算获取像素 RGB 和 vec3(0.299, 0.587, 0.114)点积,来作为着色器的 RGB 输出。

vec4 grayscale(vec4 color) {
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    return vec4(vec3(gray), color.a);
}

// 使用
color = grayscale(color);

💡 dot 函数语法可查阅《附录 —— 二、GLSL 内置方法》

💡 输入向量 vec3(0.299, 0.587, 0.114) 中的三个数值,是最常用的 NTSC 标准数值,是基于人眼视网膜的生物学特性确定的(人眼对绿色光最敏感,红色次之,蓝色最不敏感)。

完整的 Effect 文件代码如下(后续其它滤镜不赘述):

CCEffect %{
common-pass-config: &common-pass-config
  blendState:
    targets:
      - blend: true
        blendSrc: src_alpha
        blendDst: one_minus_src_alpha
  depthStencilState:      
    depthTest: false  
    depthWrite: false
  rasterizerState: 
    cullMode: none  # 兼容 Spine,剔除背景

techniques:
  - name: gray-scale # 灰阶
    passes:
      - vert: vs:vert
        frag: gray-scale-fs:frag
        <<: *common-pass-config
}%

CCProgram vs %{
  #include "../../resources/chunk/normal-vert.chunk"
}%

// 灰阶滤镜
CCProgram gray-scale-fs %{
  precision highp float;
  #include <sprite-texture> 

  in vec2 uv;

  #if USE_LOCAL
    in vec4 v_color;
  #endif

  // 声明灰阶函数
  vec4 grayscale(vec4 color) {
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    return vec4(vec3(gray), color.a);
  }

  vec4 frag() {
    vec4 color = texture(cc_spriteTexture, uv); 

    #if USE_LOCAL
      // 若为 Spine 文件,先混合顶点色值
      color *= v_color;
    #endif

    return grayscale(color);  // 使用灰阶函数
  }
}%

滤镜效果:

image.png

如果需要实现一个逐渐置灰的动效,可以自定义一个 grayPercent 属性作为灰阶颜色要应用的百分比:

  vec4 frag() {
    vec4 color = texture(cc_spriteTexture, uv); 
    
    #if USE_LOCAL
      color *= v_color; 
    #endif

    vec4 grayColor = grayscale(color);
    
    return color + (grayColor - color) * grayPercent;
  }

再通过外部的脚本修改它的值即可(从 0.0 逐渐增加到 1.0)。

二、颜色反转

对像素色值进行「取反」—— 使用 1.0 减去 RGB 各通道的值,来作为着色器的输出:

vec4 invert(vec4 color) {
    return vec4(1.0 - color.rgb, color.a);
}

// 使用
color = invert(color);

滤镜效果:

image.png

三、像素化

在着色器中实现马赛克风格的像素滤镜很简单,原理是压缩材质的纹理坐标,按区间来采样。例如让 UV 坐标 [0.0, 0.0][0.1, 0.1] 的像素都统一取样 [0.0, 0.0] 的纹理色值:

  float pixelSize = 30.0;  // 把图片被划分成 pixelSize 乘以 pixelSize 个像素块


  vec4 pixelate(vec2 uv) {
      vec2 pixelatedUV = floor(uv * pixelSize) / pixelSize;
      return texture(cc_spriteTexture, pixelatedUV);
  }

  vec4 frag() {
    vec4 color = pixelate(uv); 

    return color;
  }

关键步骤解析:

  • uv * pixelSize

    • 将 UV 坐标映射到更大的坐标空间,划分成 pixelSize × pixelSize 个格子。
    • 例如 pixelSize = 10,就相当于把纹理划分为 10×10 网格,UV 从 [0.0, 1.0] 被扩展成 [0.0, 10.0]
  • floor(uv * pixelSize)

    • 把每个坐标取整,相当于「压缩」到每个像素块左上角的 UV 坐标。
    • 所有在这个格子里的像素都会被映射到同一个整数坐标。
  • 再除以 pixelSize

    • 把 UV 再还原回 [0.0, 1.0] 范围。
    • 所以,这个 pixelatedUV 实际上是「跳格子式的 UV」,同一个像素块里的像素全部采样相同的纹理点。

滤镜效果:

image.png

四、边缘检测(描边)

边缘检测的原理是,通过读取像素周围的颜色值,计算图像在水平和垂直方向的亮度变化强度,从而检测出边缘。

首先获取当前像素周围 8 个方向(左上、正上、右上、正左、正右、左下、正下、右下)的像素颜色:

    // // 计算单个像素在 UV 空间中的尺寸,这样就能通过 uv ± pixelSize 精确采样到周围单个像素的位置
    vec2 pixelSize = 1.0 / textureSize;
    
    // 获取周围像素
    vec4 topLeft = texture(cc_spriteTexture, uv + vec2(-pixelSize.x, pixelSize.y));
    vec4 top = texture(cc_spriteTexture, uv + vec2(0.0, pixelSize.y));
    vec4 topRight = texture(cc_spriteTexture, uv + vec2(pixelSize.x, pixelSize.y));
    vec4 left = texture(cc_spriteTexture, uv + vec2(-pixelSize.x, 0.0));
    vec4 right = texture(cc_spriteTexture, uv + vec2(pixelSize.x, 0.0));
    vec4 bottomLeft = texture(cc_spriteTexture, uv + vec2(-pixelSize.x, -pixelSize.y));
    vec4 bottom = texture(cc_spriteTexture, uv + vec2(0.0, -pixelSize.y));
    vec4 bottomRight = texture(cc_spriteTexture, uv + vec2(pixelSize.x, -pixelSize.y));

接着我们通过Sobel 算子来计算水平(Gx)与垂直(Gy)方向的梯度:

    // 计算梯度
    vec4 gx = -topLeft - 2.0 * left - bottomLeft + topRight + 2.0 * right + bottomRight;
    vec4 gy = -topLeft - 2.0 * top - topRight + bottomLeft + 2.0 * bottom + bottomRight;

gx 是图像在 水平方向(x轴) 上的亮度变化趋势,gy 则是图像在 垂直方向(y轴) 上的亮度变化趋势:

  • 它们不是图像本身的亮度,而是亮度的 “变化速度”“斜率”,也可以说是 “图像的一阶导数”
  • gx 为正,表示图像从左向右变亮;若 gx 为负,表示图像从左向右变暗;若 gx = 0,表示横向亮度变化不大。同理,gy 表示纵向变化。
  • gx.rgbagy.rgba 各通道(例如 gx.r)的理论取值范围是 [-4.0, 4.0]。不过在实际图像中,亮度不会跳变那么剧烈,因此一般会远小于这个范围,通常落在 [-1.5, +1.5] 区间内。

虽然我们计算出的亮度梯度结果包含 RGBA 四个通道,但常规而言边缘检测仅提取其中 RGB 一个通道来判断边缘即可(简化计算)。

在本例中,我们只提取 .r 通道来判断边缘强度:

// 使用红色通道计算边缘强度
float edge = length(vec2(gx.r, gy.r));  // 等同于 edge = sqrt(gx² + gy²)

这里通过 GLSL 内置的 length 方法对 gx.rgy.r 进行取模,使用它们的模长来表示红色通道的亮度变化强度。

此时 edge = 0 时表示无边缘,edge 越大表示边缘强。通过对它进行取反即可绘制出描边:

return vec4(vec3(1.0 - edge), 1.0);

image.png

可以看到虽然检测出了边缘,但鉴于原图杂色较多,导致描边效果不够理想。我们可以进一步使用 smoothstep 函数来平滑边缘,减少冗余描边:

edge = smoothstep(0.1, 0.3, edge);

image.png

我们也可以保留原本材质的底色,在原纹理上进行描边:

    // return vec4(vec3(1.0 - min(edge * 5.0, 1.0)), 1.0);

    // 保留原本颜色
    vec3 orig = texture(cc_spriteTexture, uv).rgb;
    return vec4(orig.rgb * (1.0 - edge), 1.0);

image.png

💡 扩展

如果希望使用多通道处理(而不是只处理 R 通道),可以使用亮度计算:

// 使用亮度计算
float luminance(vec4 c) {
    return 0.299*c.r + 0.587*c.g + 0.114*c.b;
}

float edge = length(vec2(luminance(gx), luminance(gy)));

五、创意彩色滤镜

5.1 电影胶卷滤镜

胶片滤镜具备几大核心要素:胶片颗粒生成、暗角效果、对比度曲线、色彩分级、褪色处理,我们封装为对应的 GLSL 函数来处理。

5.1.1 胶片颗粒生成

在材质上生成随机分布的噪点颗粒:

  float filmGrain(vec2 uv, float time) {
    // 使用简单噪声算法
    return fract(sin(dot(uv * time, vec2(12.9898, 78.233))) * 43758.5453);
  }

这是一个经典的 伪随机噪声算法,使用 UV 和时间因子作为输入,生成一个 0~1 之间的随机值来模拟颗粒:

  • 通过 dot(uv * time, vec2(...)) 变化种子。
    • dot 是点积,它在这里的作用是:把 2D 坐标哈希成一个数
    • 常数 12.989878.233 是精心挑选的"魔数",可以最大程度避免周期性、重复性。
  • sin 是一个周期函数,但因为 dot 的结果很大或随机,sin 的输入是非常“乱”的,乘上另一个大数 43758.5453,是为了让 sin 的输出值在 [-1,1] 区间拉伸到一个更大的范围,以提高 hash 的离散性。
  • fract(...) 是为了把大而乱的数值压缩到 [0.0, 1.0) 区间,成为最终的随机噪声值

💡 本文使用了 dotfractdistancesmoothstep 等 GLSL 内置函数,读者可自行在《附录 —— 二、GLSL 内置方法》 中查阅。

5.1.2 暗角效果

离材质中心越远则色值越暗:

  float vignetteIntensity = 0.8; // 暗角强度

  float vignette(vec2 uv) {
    // 计算到中心的距离
    vec2 center = vec2(0.5, 0.5);
    float dist = distance(uv, center);
    
    // 通过 vignetteIntensity 来控制暗角扩散程度,使用 smoothstep 来平滑边缘暗角
    return 1.0 - smoothstep(0.3, 0.8, dist * vignetteIntensity);
  }

这里使用了 GLSL 内置的 distance 函数来算出当前像素距离材质正中心的距离 (离中心越近,dist 的值越小,最远的距离则约等于 0.707):

image.png

此时如果通过 smoothstep(0.3, 0.8, dist) 可以在 UV 坐标 [0.3, 0.8] 区间返回一个平滑插值 (返回值在 [0.0, 1.0] 之间),通过 1.0 减去该返回值则可以取反:

image.png

最后加入 vignetteIntensity 来控制暗角扩散程度(缩放 dist 的值):

1.0 - smoothstep(0.3, 0.8, dist * vignetteIntensity)

5.1.3 对比度曲线

本质上是用一个sigmoid(S型)函数来增强图像中间亮度的对比度,而对黑和白的区域影响较小:

vec3 filmCurve(vec3 color) {
  vec3 x = color * 1.2;
  vec3 curve = x * (1.0 / (1.0 + exp(-5.0*(x-0.5))));
  return mix(color, curve, 0.7);
}
  • 通过 color * 1.2 将颜色向更亮方向拉伸,增强整个图像的亮度感(相当于增强输入值,使中间区域更容易被强调)。
  • 应用 sigmoid 对比度曲线,第 3 行相当于 x * sigmoid(5.0 * (x - 0.5))
    • GLSL 里没有内置的 sigmoid 函数,不过其等效于:sigmoid(t) = 1 / (1 + exp(-t));
    • x 在中间值(如 0.5)附近变化得更快(增强对比度),即低值区域(暗部)和高值区域(亮部)变化缓慢(保留细节):
    • 1|                             
           |                 ________
           |               /
           |            __/
           |          /
          0+------------------------→ x
               0    0.5      1
      
  • 使用 mix 与原色混合,保留部分原始感。

5.1.4 色彩分级

一般电影都使用的橙青风格来进行调色,其着色器实现为:

vec3 filmColorGrade(vec3 color) {
  vec3 shadows = vec3(0.1, 0.25, 0.3); // 青
  vec3 midtones = vec3(0.7, 0.5, 0.3); // 橙

  float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));  // 亮度
  vec3 graded = mix(shadows, midtones, smoothstep(0.2, 0.8, luminance));
  return mix(color, graded * color, 0.4);
}
  • 在第 5 行通过 dot 计算像素亮度 luminance

    • 这行代码是标准的人眼感知亮度计算,三个通道的权重来源于 ITU-R BT.709 标准(红 21%,绿 71%,蓝 7%);
    • 作用是将输入颜色转换成单通道的亮度值(范围大致在 [0, 1])。
  • 通过 smootstep 函数,用亮度在阴影(青)和中间调(橙)之间平滑插值。

  • 乘以原色并混合回来,形成经典的橙青风格

5.1.5 投入着色器

我们把封装好的函数整合到片元着色器中:


CCEffect %{
略...

techniques:   
  - name: lut # 创意色彩滤镜
    passes:
      - vert: vs:vert
        frag: lut-fs:frag
        <<: *common-pass-config
        properties:
          timeFactor: { value: 0.0 }               # 时间因子,用于控制颗粒变化速度
}%


// 创意色彩滤镜(片元着色器代码)
CCProgram lut-fs %{
  precision highp float;
  #include <sprite-texture> 

  in vec2 uv;

  #if USE_LOCAL
    in vec4 v_color;
  #endif

  uniform LutArgs {
    vec2 textureSize;
    float timeFactor;
  };

  /** 电影胶卷质感 **/
  float grainIntensity = 3.25;               // 颗粒感强度
  float vignetteIntensity = 0.8;             // 暗角强度
  vec3 fadeColor = vec3(0.15, 0.15, 0.05);   // 褪色颜色
  
  // 胶片颗粒生成函数
  float filmGrain(vec2 coord, float time) {
    // 使用简单噪声算法
    return fract(sin(dot(coord * time, vec2(12.9898, 78.233))) * 43758.5453);
  }
  
  // 暗角效果
  float vignette(vec2 uv) {
    // 计算到中心的距离
    vec2 center = vec2(0.5, 0.5);
    float dist = distance(uv, center);
    
    // 平滑边缘暗角
    return 1.0 - smoothstep(0.3, 0.8, dist * vignetteIntensity);
  }
  
  // 胶片曲线(对比度调整)
  vec3 filmCurve(vec3 color) {
    // S形曲线增强对比度
    vec3 x = color * 1.2;
    vec3 curve = x * (1.0 / (1.0 + exp(-5.0*(x-0.5))));
    return mix(color, curve, 0.7);
  }

  // 色彩分级(橙青色调)
  vec3 filmColorGrade(vec3 color) {
    // 分离色调:高光偏橙,阴影偏青
    vec3 shadows = vec3(0.1, 0.25, 0.3); // 青色调
    vec3 midtones = vec3(0.7, 0.5, 0.3); // 橙色调
    
    // 计算亮度
    float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
    
    // 混合色调
    vec3 graded = mix(shadows, midtones, smoothstep(0.2, 0.8, luminance));
    
    // 混合原始颜色
    return mix(color, graded * color, 0.4);
  }
  
  // 褪色效果
  vec3 filmFade(vec3 color) {
    return mix(color, fadeColor, 0.15);
  }

  vec4 film(vec4 color, vec2 uv) {
  
    // 应用胶片曲线
    color.rgb = filmCurve(color.rgb);
    
    // 应用色彩分级
    color.rgb = filmColorGrade(color.rgb);
    
    // 添加褪色效果
    color.rgb = filmFade(color.rgb);
    
    // 应用暗角
    color.rgb *= vignette(uv);
    
    // 添加胶片颗粒(带时间变化)
    float grain = filmGrain(uv, timeFactor) * grainIntensity;
    color.rgb += (grain - 0.5) * 0.1;
    
    return color;
  }


  vec4 frag() {
    vec4 color = texture(cc_spriteTexture, uv); 

    #if USE_LOCAL
      color *= v_color;
    #endif

    return film(color, uv);
  }
}%

将材质应用到节点组件后就能得到电影胶卷的风格化效果,还可以通过修改材质的 timeFactor 变量来实现噪点动画:

77.gif

5.2 更多滤镜

通过其它固定范式对纹理颜色进行修改,可以获得「老照片」、「故障效果」等有趣的滤镜,效果和片元着色器参考代码如下:

image.png

CCEffect %{
略...

techniques:   
  - name: lut # 创意色彩滤镜
    passes:
      - vert: vs:vert
        frag: lut-fs:frag
        <<: *common-pass-config
        properties:
          textureSize: { value: [192, 192] }       # 纹理尺寸
          timeFactor: { value: 0.0 }               # 时间因子,用于控制颗粒变化速度
}%

CCProgram lut-fs %{
  precision highp float;
  #include <sprite-texture> 

  in vec2 uv;

  #if USE_LOCAL
    in vec4 v_color;
  #endif

  uniform LutArgs {
    vec2 textureSize;
    float timeFactor;
  };

  // 老照片滤镜(棕褐色调)
  vec4 sepia(vec4 color) {
    vec3 newColor;
    newColor.r = dot(color.rgb, vec3(0.393, 0.769, 0.189));
    newColor.g = dot(color.rgb, vec3(0.349, 0.686, 0.168));
    newColor.b = dot(color.rgb, vec3(0.272, 0.534, 0.131));
    return vec4(min(newColor, vec3(1.0)), color.a);  // min 是 GLSL 内置的方法,在参数里选取最小的值
  }

  // 赛博朋克滤镜
  vec4 cyberpunk(vec4 color) {
    // 增强对比度
    color.rgb = (color.rgb - 0.5) * 2.0 + 0.5;
    
    // 调整色调
    float r = color.r * 1.2;
    float g = color.g * 0.9;
    float b = color.b * 1.5;
    
    // 添加紫色偏移
    r = min(r + color.b * 0.2, 1.0);
    b = min(b + color.r * 0.3, 1.0);
    
    return vec4(r, g, b, color.a);
  }

  // 双色调
  vec4 duotone(vec4 color) {
    vec3 darkColor = vec3(0.0, 0.0, 0.5);  // 深色调
    vec3 lightColor = vec3(1.0, 0.8, 0.2); // 浅色调

    float luminance = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    return vec4(mix(darkColor, lightColor, luminance), color.a);
  }

  // 故障效果
  vec4 glitchEffect(vec4 color, vec2 uv) {
    float time; // 时间变量
    float intensity = 0.02; // 位移强度

    // 红色通道位移
    vec2 redUV = uv + vec2(cos(time * 10.0) * intensity, sin(time * 8.0) * intensity);
    float r = texture(cc_spriteTexture, redUV).r;
    
    // 绿色通道位移
    vec2 greenUV = uv + vec2(sin(time * 12.0) * intensity, cos(time * 9.0) * intensity);
    float g = texture(cc_spriteTexture, greenUV).g;
    
    // 蓝色通道位移
    vec2 blueUV = uv + vec2(cos(time * 7.0) * intensity, sin(time * 11.0) * intensity);
    float b = texture(cc_spriteTexture, blueUV).b;
    
    return vec4(r, g, b, color.a);
  }

  // 电影胶卷质感
  // 略...


  vec4 frag() {
    vec4 color = texture(cc_spriteTexture, uv); 

    #if USE_LOCAL
      color *= v_color;
    #endif

    // 通过自定义宏选择要使用的 LUT 滤镜
    #if USE_SEPIA
      color = sepia(color);
    #elif USE_CYBERPUNK
      color = cyberpunk(color);
    #elif USE_DUOTONE
      color = duotone(color);
    #elif USE_GLITCH
      color = glitchEffect(color, uv);
    #elif USE_FILM
      color = film(color, uv);
    #endif

    return color;
  }
}%