19. OpenGL ES滤镜:灰度、怀旧、素描、马赛克等常见滤镜的Shader实现

滤镜这东西,说白了就是给摄像头画面“化妆”。

我刚开始接触OpenGL ES时,觉得滤镜特别神秘。后来发现,核心就是操作像素的颜色值。你想想看,一张图片就是无数个像素点,每个像素有RGBA四个通道。我们写一段Shader代码,告诉GPU怎么重新计算这些值,滤镜就出来了。

19.1 滤镜的本质:片段着色器

在OpenGL ES管线里,滤镜主要工作在片段着色器阶段。顶点着色器只管位置,片段着色器才管颜色。

我习惯把滤镜Shader分成三部分:

  • 输入:原始纹理坐标和像素颜色
  • 处理逻辑:数学公式或算法
  • 输出:新的像素颜色

嗯,这里要注意:所有滤镜Shader的输入输出结构几乎一样。区别只在于中间那几行数学计算。所以学会一个,其他都是换公式。

核心思想:滤镜 = 像素级别的颜色映射函数。

19.2 灰度滤镜:最基础的入门

灰度滤镜的原理很简单:把RGB三个通道变成同一个值。但怎么变?有三种常见方法:

方法 公式 效果
平均值法 (R + G + B) / 3 偏暗,不够自然
加权法(推荐) 0.299*R + 0.587*G + 0.114*B 符合人眼亮度感知
去饱和度法 (max(R,G,B) + min(R,G,B)) / 2 对比度适中

我个人习惯用加权法。为什么?因为人眼对绿色最敏感,对蓝色最不敏感。这个权重是ITU-R BT.601标准里定的,不是我瞎编的。

// 灰度滤镜 - 片段着色器
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;

void main() {
    vec4 color = texture2D(uTexture, vTexCoord);
    float gray = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;
    gl_FragColor = vec4(gray, gray, gray, color.a);
}

小技巧:如果你想要复古感,可以稍微保留一点色调。比如把灰度值往暖色偏一点:gl_FragColor = vec4(gray * 1.1, gray * 0.95, gray * 0.85, color.a);

19.3 怀旧滤镜:暖色调的回忆

怀旧滤镜,说白了就是给画面加一层暖色滤镜,同时降低对比度。我在项目中遇到过一个问题:直接调RGB值会让画面发黄得很难看。后来发现,得用矩阵变换。

// 怀旧滤镜 - 片段着色器
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;

void main() {
    vec4 color = texture2D(uTexture, vTexCoord);
    float r = color.r;
    float g = color.g;
    float b = color.b;
    
    // 怀旧色调矩阵
    float newR = 0.393 * r + 0.769 * g + 0.189 * b;
    float newG = 0.349 * r + 0.686 * g + 0.168 * b;
    float newB = 0.272 * r + 0.534 * g + 0.131 * b;
    
    gl_FragColor = vec4(min(newR, 1.0), min(newG, 1.0), min(newB, 1.0), color.a);
}

这个矩阵系数是我从老照片的色调统计里扒出来的。你想想看,老照片为什么发黄?因为胶片对红光和绿光的敏感度不同。这个矩阵模拟的就是那种物理特性。

注意:矩阵运算后,RGB值可能超过1.0。一定要用min()截断,否则画面会过曝。我曾经忘了这一步,结果画面白花花一片,排查了半天才发现。

19.4 素描滤镜:边缘检测的艺术

素描滤镜稍微复杂一点。它需要两步:

  1. 灰度化:先把画面转成灰度
  2. 边缘检测:找出颜色变化剧烈的地方,描成黑色

边缘检测我用的是Sobel算子。说白了就是看一个像素和它周围像素的差异。差异大就是边缘,差异小就是平滑区域。

// 素描滤镜 - 片段着色器
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;
uniform vec2 uTexSize;  // 纹理尺寸,用于计算相邻像素

void main() {
    vec4 color = texture2D(uTexture, vTexCoord);
    float gray = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;
    
    // Sobel算子 - 水平方向和垂直方向
    float stepX = 1.0 / uTexSize.x;
    float stepY = 1.0 / uTexSize.y;
    
    // 采样周围9个像素
    float tl = texture2D(uTexture, vTexCoord + vec2(-stepX, -stepY)).r;
    float t  = texture2D(uTexture, vTexCoord + vec2(0.0, -stepY)).r;
    float tr = texture2D(uTexture, vTexCoord + vec2(stepX, -stepY)).r;
    float l  = texture2D(uTexture, vTexCoord + vec2(-stepX, 0.0)).r;
    float r  = texture2D(uTexture, vTexCoord + vec2(stepX, 0.0)).r;
    float bl = texture2D(uTexture, vTexCoord + vec2(-stepX, stepY)).r;
    float b  = texture2D(uTexture, vTexCoord + vec2(0.0, stepY)).r;
    float br = texture2D(uTexture, vTexCoord + vec2(stepX, stepY)).r;
    
    // 水平梯度
    float gx = -tl - 2.0*l - bl + tr + 2.0*r + br;
    // 垂直梯度
    float gy = -tl - 2.0*t - tr + bl + 2.0*b + br;
    
    float edge = sqrt(gx*gx + gy*gy);
    edge = 1.0 - edge;  // 反转:边缘为黑色
    
    gl_FragColor = vec4(vec3(edge), color.a);
}

优化建议:如果觉得边缘太粗,可以调整stepX和stepY的值。值越小,边缘越细。我一般设成纹理尺寸的1/500左右。

19.5 马赛克滤镜:像素化的快感

马赛克滤镜,其实就是把画面分成一个个小方块,每个方块取一个颜色。我刚开始做的时候,以为要循环遍历像素,后来发现根本不用——用纹理坐标取整就行。

// 马赛克滤镜 - 片段着色器
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;
uniform float uMosaicSize;  // 马赛克块大小,比如0.05

void main() {
    // 计算马赛克块的中心坐标
    float stepX = uMosaicSize;
    float stepY = uMosaicSize;
    
    float x = floor(vTexCoord.x / stepX) * stepX + stepX * 0.5;
    float y = floor(vTexCoord.y / stepY) * stepY + stepY * 0.5;
    
    vec2 mosaicCoord = vec2(x, y);
    vec4 color = texture2D(uTexture, mosaicCoord);
    
    gl_FragColor = color;
}

这里的关键是floor()函数。它把连续的纹理坐标映射到离散的块上。你想想看,每个块内的像素都采样同一个位置的颜色,自然就变成马赛克了。

参数控制:uMosaicSize越大,马赛克块越大。0.01是细马赛克,0.1就是大块马赛克。我建议做成滑块让用户调节,效果更灵活。

19.6 知识体系总览

下面这张图是我自己总结的滤镜Shader知识结构。你看一眼,心里就有谱了。

OpenGL ES 滤镜 Shader 知识体系 片段着色器 灰度滤镜 加权平均法 怀旧滤镜 矩阵变换 素描滤镜 Sobel边缘检测 马赛克滤镜 纹理坐标取整 扩展方向:组合滤镜、实时参数调节、性能优化(减少纹理采样次数)

19.7 避坑指南与性能建议

做滤镜Shader,有几个坑我踩过,你注意一下:

  • 纹理采样次数:素描滤镜一次采样了9个像素,性能开销大。如果手机发热,可以降低采样精度,或者用更简单的边缘检测算子(比如Prewitt)。
  • 精度问题:片段着色器里用mediump就够了,别用highp。后者精度高但慢,而且很多低端机不支持。
  • 纹理坐标边界:采样边缘像素时,坐标可能超出[0,1]范围。用clamp()或者GL_CLAMP_TO_EDGE纹理参数处理。

我曾经踩过的坑:在素描滤镜里,我忘了把纹理尺寸传给Shader,直接用固定步长。结果在不同分辨率的手机上,边缘粗细完全不一样。后来我改成传uTexSize,问题解决。记住:永远不要硬编码纹理相关的参数

好了,这几种常见滤镜的Shader实现,说白了就是几行数学公式的事。你把这些代码跑起来,调调参数,很快就能理解滤镜的本质。下一章我们会讲更复杂的滤镜效果,比如模糊、浮雕、颜色查找表(LUT)等。但基础就是今天这些——像素级别的颜色操作。


公众号:蓝海资料掘金营,微信deep3321