💡 本系列文章收录于个人专栏 ShaderMyHead,欢迎订阅。
在片元着色器中对像素颜色进行有规律的改动,可以实现各种有趣的滤镜效果。
本文会介绍部分常规着色器滤镜(效果见下图),它们大多是用固定范式来实现的。

一、灰阶滤镜
通过 GLSL 内置的 dot
函数,计算获取像素 RGB 和 vec3(0.299, 0.587, 0.114)
的点积,来作为着色器的 RGB 输出。
js
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 文件代码如下(后续其它滤镜不赘述):
js
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); // 使用灰阶函数
}
}%
滤镜效果:

如果需要实现一个逐渐置灰的动效,可以自定义一个 grayPercent
属性作为灰阶颜色要应用的百分比:
js
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 各通道的值,来作为着色器的输出:
js
vec4 invert(vec4 color) {
return vec4(1.0 - color.rgb, color.a);
}
// 使用
color = invert(color);
滤镜效果:

三、像素化
在着色器中实现马赛克风格的像素滤镜很简单,原理是压缩材质的纹理坐标,按区间来采样。例如让 UV 坐标 [0.0, 0.0]
到 [0.1, 0.1]
的像素都统一取样 [0.0, 0.0]
的纹理色值:
js
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]
。
- 将 UV 坐标映射到更大的坐标空间,划分成
-
floor(uv * pixelSize)
:- 把每个坐标取整,相当于「压缩」到每个像素块左上角的 UV 坐标。
- 所有在这个格子里的像素都会被映射到同一个整数坐标。
-
再除以
pixelSize
:- 把 UV 再还原回
[0.0, 1.0]
范围。 - 所以,这个
pixelatedUV
实际上是「跳格子式的 UV」,同一个像素块里的像素全部采样相同的纹理点。
- 把 UV 再还原回
滤镜效果:

四、边缘检测(描边)
边缘检测的原理是,通过读取像素周围的颜色值,计算图像在水平和垂直方向的亮度变化强度,从而检测出边缘。
首先获取当前像素周围 8 个方向(左上、正上、右上、正左、正右、左下、正下、右下)的像素颜色:
js
// // 计算单个像素在 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)方向的梯度:
js
// 计算梯度
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.rgba
和gy.rgba
各通道(例如gx.r
)的理论取值范围是[-4.0, 4.0]
。不过在实际图像中,亮度不会跳变那么剧烈,因此一般会远小于这个范围,通常落在[-1.5, +1.5]
区间内。
虽然我们计算出的亮度梯度结果包含 RGBA 四个通道,但常规而言边缘检测仅提取其中 RGB 一个通道来判断边缘即可(简化计算)。
在本例中,我们只提取 .r
通道来判断边缘强度:
js
// 使用红色通道计算边缘强度
float edge = length(vec2(gx.r, gy.r)); // 等同于 edge = sqrt(gx² + gy²)
这里通过 GLSL 内置的 length
方法对 gx.r
和 gy.r
进行取模,使用它们的模长来表示红色通道的亮度变化强度。
此时 edge = 0
时表示无边缘,edge
越大表示边缘强。通过对它进行取反即可绘制出描边:
js
return vec4(vec3(1.0 - edge), 1.0);

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

我们也可以保留原本材质的底色,在原纹理上进行描边:
js
// 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);

💡 扩展
如果希望使用多通道处理(而不是只处理 R 通道),可以使用亮度计算:
js// 使用亮度计算 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 胶片颗粒生成
在材质上生成随机分布的噪点颗粒:
js
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.9898
和78.233
是精心挑选的"魔数",可以最大程度避免周期性、重复性。
sin
是一个周期函数,但因为dot
的结果很大或随机,sin
的输入是非常"乱"的,乘上另一个大数43758.5453
,是为了让sin
的输出值在[-1,1]
区间拉伸到一个更大的范围,以提高 hash 的离散性。fract(...)
是为了把大而乱的数值压缩到[0.0, 1.0)
区间,成为最终的随机噪声值。
💡 本文使用了
dot
、fract
、distance
、smoothstep
等 GLSL 内置函数,读者可自行在《附录 ------ 二、GLSL 内置方法》 中查阅。
5.1.2 暗角效果
离材质中心越远则色值越暗:
js
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
):

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

最后加入 vignetteIntensity
来控制暗角扩散程度(缩放 dist
的值):
js
1.0 - smoothstep(0.3, 0.8, dist * vignetteIntensity)
5.1.3 对比度曲线
本质上是用一个sigmoid(S型)函数来增强图像中间亮度的对比度,而对黑和白的区域影响较小:
js
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
)附近变化得更快(增强对比度),即低值区域(暗部)和高值区域(亮部)变化缓慢(保留细节):
lua↑ 1| | ________ | / | __/ | / 0+------------------------→ x 0 0.5 1
- GLSL 里没有内置的 sigmoid 函数 ,不过其等效于:
-
使用
mix
与原色混合,保留部分原始感。
5.1.4 色彩分级
一般电影都使用的橙青风格来进行调色,其着色器实现为:
js
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 投入着色器
我们把封装好的函数整合到片元着色器中:
js
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
变量来实现噪点动画:

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

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