我最早在某个图像处理Demo里给相机画面加马赛克,第一反应是写个双层循环,CPU一个像素一个像素地改。后来把整套滤镜逻辑挪到GPU的片元着色器里才发现,所谓马赛克、灰度、各种奇怪的几何块,其实都只干了两件事:要么改采样坐标,要么改颜色值。GLSL写滤镜的速度比CPU实现快一个量级,效果改了立刻能看,调参也极其方便。
这篇是OpenGLSL初探系列的第六篇,专门拆解四款用GLSL在片元着色器里实现的基础滤镜:灰度滤镜、正方形马赛克滤镜、六边形马赛克滤镜和三角形马赛克滤镜。适合已经能搭好OpenGL渲染管线、想在纹理采样阶段做点文章的读者。文章会给出可以直接抄走的核心着色器代码,同时把每个滤镜背后的原理、参数怎么调、换设备会踩什么坑都讲清楚。
1. 片元着色器里的滤镜本质:像素颜色只取决于取样点
1.1 每个像素都在走一段独立的小程序
片元着色器最反直觉的一点,是它无法直接访问邻居像素。每个片元都是独立执行的“小程序”,你唯一能轻松拿到的信息就是自己的纹理坐标、自己在屏幕上的位置,以及通过纹理对象去采样得到的颜色。CPU滤镜可以拿着一个像素矩阵到处跑,GPU滤镜却没这个权限。
这就导致GLSL滤镜的思路完全不一样:既然读不到邻居,那就别做“合并像素”的幻想,只需要让一个区域内的所有像素,在最后采样时都去读同一个纹理坐标,视觉上自然就糊成一块了。正方形马赛克、六边形马赛克、三角形马赛克,本质上都是同一个套路——重映射UV坐标,让每个小格子共享同一个采样点。
用生活化的类比:CPU实现像是在Excel里手动把一组单元格合并成一个大单元格;GPU实现更像是给每个单元格发一条指令,“你们别看自己那块内容了,统一去读第3行第5列的内容”。从结果上看都是马赛克,但GPU这条路快得多。
1.2 我们这节需要的基础输入
后文所有滤镜都用下面这几个约定好的输入:
v_uv:当前片元在纹理上的归一化坐标,范围大概是0到1。u_texture:要处理的纹理,用texture2D(u_texture, uv)采样。u_resolution:渲染目标的分辨率,单位是像素。
为什么要额外传入u_resolution?因为归一化坐标在屏幕尺寸不同时,同样的数值对应完全不同的物理像素大小。想要让马赛克块以“像素”为单位稳定显示,就必须把归一化坐标转成像素坐标来做计算。这个细节在后文正方形马赛克里会非常关键。
2. 灰度滤镜:不是简单平均,是按人眼亮度加权
2.1 为什么三个通道等权是错的方向
拿到一个RGB颜色,想把画面变灰,很多人的第一反应是(r + g + b) / 3。这个式子算出来不是不能用,但观感总是怪怪的——蓝色特别亮的部分会显得过曝,绿色又暗得发沉。原因很简单:人眼对三原色的敏感度差得很远,绿色感知最强,红色次之,蓝色最弱。等权平均相当于把三种颜色按1:1:1对待,明显不符合视觉规律。
实际工程里最常用的灰度权重来自BT.601标准,即:
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));这三个权重加起来正好是1.0。如果细看,绿色占了将近六成,蓝色只占一成出头,这就是人眼感知特性在公式上的体现。另外还有一套BT.709权重(0.2126, 0.7152, 0.0722),常用于高清视频的色彩空间。实时渲染里用哪套差别不大,只要权重和为1,视觉上都是自然灰度。
2.2 灰度滤镜的核心代码与强度控制
完整片段着色器逻辑很简单:
precision highp float; uniform sampler2D u_texture; varying vec2 v_uv; void main() { vec3 color = texture2D(u_texture, v_uv).rgb; float gray = dot(color, vec3(0.299, 0.587, 0.114)); gl_FragColor = vec4(vec3(gray), 1.0); }如果直接把vec3(gray)输出,就是彻底去色。实际项目中往往还需要一个强度参数,比如胶片褪色效果、黑白到彩色的过渡动画。这时候用GLSL内建的mix函数就非常顺手:
uniform float u_strength; // 0.0 保持原色,1.0 完全灰度 vec3 finalColor = mix(color, vec3(gray), u_strength);mix相当于根据第三个参数做线性插值。u_strength设为0.3,画面会像是蒙了一层淡薄的褪色感;设为1.0才是纯黑白。这个参数直接在渲染循环里用uniform传进去,时刻可调,不用重新编译着色器。
2.3 一个容易被忽略的色彩空间细节
有个细节我在实际项目中纠结过很久:texture2D采样出来的颜色,通常已经是经过显示伽马处理的非线性sRGB颜色。严格来说,亮度计算应该在线性空间里做,也就是先把sRGB解码,算灰度,再编码回sRGB。否则按0.299、0.587、0.114加权时,权重是基于线性光推导的,直接套在伽马空间值上并不“物理准确”。
但在移动端实时滤镜里,大多数场景根本不做这层转换,直接加权灰度的观感也足够自然。只有在做色彩科学级别的处理,比如HDR渲染、高级调色管线时,才需要把纹理先采样到线性空间再算。这个坑不算严重,但你如果发现某个灰度效果比预期“脏”,先别怀疑公式,看看是不是色彩空间处理漏了。
3. 正方形马赛克:一个数值上的小陷阱
3.1 floor函数才是马赛克的主角
正方形马赛克的核心操作只有一步:让格子内的所有像素,都去采样同一个纹理坐标。实现起来靠的是一组除法加floor取整。
先从最简单的版本说起:
vec2 uv = v_uv; float tileSize = 0.05; vec2 sampleUV = floor(uv / tileSize) * tileSize; vec3 color = texture2D(u_texture, sampleUV).rgb;uv / tileSize把归一化坐标放大成“第几个格子”的计数,floor把小数部分砍掉,再* tileSize缩回来。这样一来,tileSize边长的方形区域内,所有像素的sampleUV都指向同一个格子起点,颜色自然就糊成方格。
3.2 直接除UV坐标:不同屏幕上块大小会漂
上面这个写法在单个设备上没问题,但一旦把渲染窗口从手机屏切到高清大屏,tileSize=0.05的含义就不一样了:在1080p上它大约是54个像素宽,在4K屏上是108个像素宽。马赛克块凭空变大,观感完全不统一。
更实际的做法是先转成像素坐标,再做整数分块:
precision highp float; uniform sampler2D u_texture; uniform vec2 u_resolution; varying vec2 v_uv; void main() { vec2 pixelPos = v_uv * u_resolution; float blockSize = 8.0; // 正方形边长,单位像素 vec2 blockId = floor(pixelPos / blockSize); vec2 blockOrigin = blockId * blockSize; vec2 sampleUV = (blockOrigin + blockSize * 0.5) / u_resolution; vec3 color = texture2D(u_texture, sampleUV).rgb; gl_FragColor = vec4(color, 1.0); }这里blockSize直接按像素写,blockId就是像素坐标系里的格子编号。我把采样点从格子左上角改成了blockOrigin + blockSize * 0.5,也就是取格子中心。用左上角实现更快但块内颜色会整体偏左上;用中心会让每个块呈现“这一块平均视觉重心”的颜色,看起来更稳。代价只是多一次加法,性能完全可以忽略。
3.3 一块一块的边界并不“整齐”
有个视觉上的小现象值得提:马赛克块边缘会出现明显的锯齿阶跃,这不是bug,而是马赛克本身就该这样。块越大,这种“跳变感”越强。如果你想要柔和一点的毛玻璃效果,可以在边界附近叠加smoothstep做轻度过渡,但那已经是另一种滤镜了。
另外,blockSize建议保持整数。如果传个7.5这种小数,因为像素坐标是离散的,块边界会出现一行像素宽窄不一的情况,虽然不仔细看不太明显,但强迫症会很难受。我在实践中默认把blockSize取整数,并且用uniform传进来时也做一次round。
4. 六边形马赛克:蜂窝效果的本质是最近中心点采样
4.1 为什么蜂窝网格比方格更耐看
六个相邻格子到中心的距离完全相等,这是蜂窝网格最大的几何优势。正方形网格里,上下左右邻居距离1,斜对角邻居距离根号2,存在明显方向性差异,斜向线条经过时会断断续续。六边形网格从视觉上更接近照片推荐里的“Voronoi划分”,所有方向的过渡都均匀,所以作为图像滤镜,蜂窝马赛克比方格马赛克显得高级不少。
实现思路其实也和Voronoi沾边:把UV平面铺满六边形中心点,然后对每一个像素,找到距离它最近的六边形中心,采样那个中心的颜色。整个六边形块内部都采样同一个点,自然就形成六边形色块。
4.2 六边形网格的排布参数
一个正六边形,如果边长记为size,那么它的中心网格有这些规律:
- 水平方向相邻中心间距:
sqrt(3) * size - 垂直方向相邻中心间距:
1.5 * size - 奇数行的中心,相对偶数行整体偏移半个水平间距
画网格时,先按水平间距和垂直间距把平面切成矩形网格,再每隔一行把中心点挪半个水平间距。这样得到的点是标准蜂窝状平顶六边形的中心点集合。
4.3 核心函数:在候选中心里找最近
为什么不能像正方形马赛克那样,直接floor一下得到格子编号就把中心点算出来?因为六边形中心点的所属关系不是简单的行列编号能唯一确定的。一个位于网格边界的像素,它的最近中心可能来自上一行、下一行、左一格或右一格。稳妥做法是在周围3x3的候选中心里全部比一遍,取距离最近的。
GLSL实现如下:
vec2 hexCenter(vec2 uv, float size) { float w = sqrt(3.0) * size; float h = 1.5 * size; vec2 g = floor(uv / vec2(w, h)); float bestDistance = 10000.0; vec2 bestCenter = vec2(0.0); for (int dy = -1; dy <= 1; dy++) { for (int dx = -1; dx <= 1; dx++) { vec2 candidate; candidate.x = (g.x + float(dx) + 0.5) * w; candidate.y = (g.y + float(dy) + 0.5) * h; if (mod(g.y + float(dy), 2.0) != 0.0) { candidate.x += w * 0.5; } float d = distance(uv, candidate); if (d < bestDistance) { bestDistance = d; bestCenter = candidate; } } } return bestCenter; } void main() { vec2 sampleUV = hexCenter(v_uv, 0.05); vec3 color = texture2D(u_texture, sampleUV).rgb; gl_FragColor = vec4(color, 1.0); }循环里那个mod(g.y + float(dy), 2.0) != 0.0就是判断当前候选中心属于奇数行还是偶数行,奇数行把x坐标偏移半个水平间距。这个函数对每个像素只做9次距离计算,开销极低,最后仍然只采样一次纹理,非常适合移动端实时渲染。
4.4 参数与兼容性注意
六边形马赛克我习惯用归一化size控制块大小,0.02到0.08之间是比较合适的范围。小于0.02,块太小,六边形边界会和大楼的砖缝一样密集;大于0.08,画面已经很难看出原图内容了。
如果编译时遇到循环相关的报错,先检查GLSL版本。GLSL ES 2.0要求循环变量和边界必须是编译期常量,我这里用固定-1到1,基本都能过。个别比较老的GPU驱动对双层循环支持不好,可以手动把9个候选中心展开成9行代码,效果一样,只是难看一点。
5. 三角形马赛克:从简单拆分到进阶蜂窝三角
5.1 最实用的入门做法:正方形格子对角线拆分成两个三角形
三角形马赛克最直接的实现,不是算等边三角形网格,而是把正方形格子沿对角线一分为二。每个格子根据像素坐标在对角线哪一侧,归属两个三角形之一,然后采样对应三角形的重心位置。
precision highp float; uniform sampler2D u_texture; uniform vec2 u_resolution; varying vec2 v_uv; void main() { vec2 pixelPos = v_uv * u_resolution; float cellSize = 12.0; vec2 grid = floor(pixelPos / cellSize); vec2 local = fract(pixelPos / cellSize); vec2 triCenter; if (local.x + local.y < 1.0) { triCenter = (grid + vec2(1.0 / 3.0, 1.0 / 3.0)) * cellSize; } else { triCenter = (grid + vec2(2.0 / 3.0, 2.0 / 3.0)) * cellSize; } vec2 sampleUV = triCenter / u_resolution; vec3 color = texture2D(u_texture, sampleUV).rgb; gl_FragColor = vec4(color, 1.0); }这里的判断条件local.x + local.y < 1.0,是在判断像素点落在正方形对角线的哪一侧。左下三角形顶点是(0,0)、(1,0)、(0,1),重心是三个顶点坐标的平均值,也就是(1/3, 1/3);右上三角形顶点是(1,0)、(0,1)、(1,1),重心是(2/3, 2/3)。这个公式干净利落,也容易验证。
这种三角形马赛克的视觉特点是斜边统一朝某个方向,块与块之间像铺了一层斜纹砖。胜在代码短、逻辑直观,作为入门非常合适。
5.2 进阶路线:把正六边形切成六个等边三角形
如果追求真正的“蜂窝三角形网格”,可以把正六边形马赛克再升级一步:先用4.3节的hexCenter找到当前像素所属的正六边形中心,然后把这个六边形按角度切成六个扇区,每个扇区再取一个三角形重心作为采样点。
思路大致是:
- 先用
hexCenter(uv, size)算出最近六边形中心o - 计算像素相对于中心的方向角:
atan(p.y, p.x) - 根据角度判断落在六个扇区中的哪一个
- 取该扇区对应的三角形重心坐标
这一步比直角三角形拆分麻烦得多,因为三角形重心要由六边形中心和两个六边形顶点共同决定,而顶点的位置又依赖六边形的朝向。我自己的经验是,先跑通正方形拆分的版本,观察对角线方向是否符合预期,再拿六边形扇区做改造。我在做的时候,因为六边形是平顶方向的,顶点角度和hexCenter里的行偏移量有对应关系,需要仔细对一遍,不然会有半个三角形宽度的错位。
5.3 三角形的重心为什么要用顶点平均
有读者可能会问:为什么不采样三角形内部某个任意点,非要采重心?其实随便采一个点也能出马赛克,只是那块颜色会整体偏向你选的位置。采重心,视觉上更像“取整块三角形的代表色”,颜色居中,原始图像的信息也不容易偏移。如果你的目的是让马赛克显得更“随机”,那反而应该给三角形重心加一个伪随机扰动,但那是另一个画风了。
6. 把四款滤镜统一到管线里:切换、边界与性能
6.1 一个开关控制所有滤镜
实际工程里不会为每种滤镜写一套完整Shader,通常会写一个片元着色器,用uniform整数切换滤镜类型。这样渲染管线不用切换程序对象,调参和联调都方便。
uniform int u_filterType; // 0原始 1灰度 2方块马赛克 3六边形马赛克 4三角马赛克 uniform float u_mosaicScale; // 方块/三角形的像素边长 uniform float u_hexSize; // 六边形的归一化边长 vec3 applyFilter(vec2 uv) { if (u_filterType == 0) { return texture2D(u_texture, uv).rgb; } if (u_filterType == 1) { vec3 c = texture2D(u_texture, uv).rgb; float gray = dot(c, vec3(0.299, 0.587, 0.114)); return mix(c, vec3(gray), 1.0); } if (u_filterType == 2) { vec2 pixelPos = uv * u_resolution; vec2 blockId = floor(pixelPos / u_mosaicScale); vec2 sampleUV = (blockId * u_mosaicScale + u_mosaicScale * 0.5) / u_resolution; return texture2D(u_texture, sampleUV).rgb; } if (u_filterType == 3) { return texture2D(u_texture, hexCenter(uv, u_hexSize)).rgb; } // 4: 三角形马赛克 vec2 pixelPos = uv * u_resolution; vec2 grid = floor(pixelPos / u_mosaicScale); vec2 local = fract(pixelPos / u_mosaicScale); vec2 triCenter = (local.x + local.y < 1.0) ? (grid + vec2(1.0 / 3.0)) * u_mosaicScale : (grid + vec2(2.0 / 3.0)) * u_mosaicScale; return texture2D(u_texture, triCenter / u_resolution).rgb; }GLSL编译器会把if分支展开,最终实际执行哪个分支看uniform取值,运行时开销很小,完全不用怕这几个判断。
6.2 块大小与屏幕分辨率的配合
马赛克类滤镜的参数设计,我最后统一成两个方案:正方形和三角形按像素传,六边形按归一化传。正方形按像素设块大小,逻辑最直观,用户说“我要10像素一块”就传10;六边形按归一化传,是因为它的网格间距和边长换算在像素单位下比较绕,容易把自己绕晕。
如果希望所有滤镜都跟随屏幕尺寸自适应,可以在CPU侧把像素值换算成归一化值再传uniform。比如希望六边形边长占屏幕高度的1/20,就传size = 1.0 / 20.0。比起在Shader里临时除以分辨率,这种预处理让Shader更短,也方便统一调试。
6.3 三个容易忽略的坑
第一个坑是纹理翻转。OpenGL的纹理坐标原点在左下角,而很多图片解码库读出来的数据是从左上角开始的。如果渲染时没处理好V方向,马赛克块会整体错位半格甚至出现奇怪的镜像,这不是算法错,是纹理坐标约定问题。先打印一张纯原图做参照,是最快的排查方式。
第二个坑是采样点过大的锯齿。马赛克本质是阶跃函数,块边缘一定会出现硬边。我曾经想过在边缘加抗锯齿,后来发现只有一帆风顺做“块内渐变过渡”时才值得做,否则会给真正的马赛克效果“磨皮”,得不偿失。
第三个坑是平均色马赛克的诱惑。看到方块效果后,很多人会想“我能不能取块内所有像素的平均值,这样更柔和”。能,但代价很大——每个片元要做N次纹理采样,块越大采样越多,移动端直接发热。实时滤镜基本不会这么干,单点采样加中心偏移已经覆盖了绝大多数场景。
6.4 性能观察:这类滤镜其实非常便宜
最后聊点性能。上面这些滤镜(除了六边形割三角的进阶方案),每个片元最多只做一到两次纹理采样,加几次浮点运算,对现代GPU来说几乎可以忽略。我把四种滤镜跑在同一台手机尺寸的测试环境上,帧率基本没有区别。真正影响性能的通常不是滤镜本身,而是后处理链路的尺寸、纹理格式、是否开了多重采样抗锯齿这些外围配置。
所以你在实际项目里可以放心大胆地叠加这些滤镜,不需要为“数字变多了”而焦虑。如果发现帧率掉了,先查是不是采样次数上去了,再查是不是传了超大分辨率的纹理。
我个人在实际项目里的最后一个习惯:所有滤镜都保留一个filterType = 0的原始通道,方便随时对比调参。马赛克类滤镜尤其适合用滑杆实时拖块大小,一边拖一边看蜂窝和三角块的边界在画面上滚动,你会非常直观地理解这些公式到底在做什么。灰度、方块、六边形、三角形四种放到一起调参,差不多就是GLSL滤镜入门最快的四条路径了。