GLSL着色器实战:灰度与三种马赛克滤镜的数学原理与代码实现

📅 发布时间:2026/10/15 17:49:19
GLSL着色器实战:灰度与三种马赛克滤镜的数学原理与代码实现
现在把这块硬骨头啃透用GLSL写灰度、正方形马赛克、六边形马赛克和三角形马赛克滤镜。标题里提到的OpenGLSL其实就是指OpenGL ES环境下的GLSL着色器编程移动端做实时滤镜几乎都走这条路。这篇我把每个滤镜从数学原理到片元着色器代码逐行拆开顺便补上我在实际调试中踩过的坑和总结出来的小技巧。无论你是刚接触着色器编程还是已经在做相机类App这篇都值得花十几分钟过一遍。1. 滤镜实现的核心思路为什么都在片元着色器里做图像滤镜的本质是一张纹理的逐像素重映射。在OpenGL ES的渲染管线里顶点着色器只负责把全屏四边形铺满视口真正的颜色计算全部落在片元着色器里。片元着色器会被GPU并行地执行到每一个输出像素上这正好是滤镜技术最理想的执行环境——不需要CPU逐像素遍历性能瓶颈只取决于GPU并发能力和着色器中指令的复杂度。我们这个系列用的纹理坐标是归一化的0到1范围通过sampler2D内置采样函数texture2D取出当前像素的颜色值。灰度滤镜属于逐像素独立运算不依赖周围像素也就是所谓的点操作point operation。马赛克滤镜则属于邻域操作neighborhood operation它需要把一个区域内的所有像素统一抽成一个代表性颜色这样画面才会出现“打格子”的视觉效果。理解了这两类操作的差别后面写代码时思路会清晰很多。滤镜的本质可以写成一个映射关系输入是一张纹理和片元坐标输出是一个新的颜色值。任何滤镜都是在改变这个映射规则。灰度只是把RGB压缩成亮度值正方形马赛克是按格子的坐标重新定位采样点六边形和三角形马赛克则是重新设计了采样网格让采样中心点呈蜂窝状或三角形阵列分布。接下来我把每一类都拆开讲清楚。2. 灰度滤镜加权平均法的原理与实现细节灰度滤镜是所有滤镜中最基础、最容易理解的一个把RGB三个通道合并成一个亮度值再把这个亮度值同时复制到RGB三个通道画面就呈现出黑白效果了。2.1 为什么灰度转换要用加权平均而不是直接平均如果直接对RGB取平均(r g b) / 3得到的灰度图往往偏暗且对比度不足。原因是人眼对光谱中不同波长的敏感度不同绿色在视觉感知中占比最大红色次之蓝色最弱。早期的NTSC电视标准中就使用了从亮度方程推导出的灰度转换系数Y 0.299 * R 0.587 * G 0.114 * B这三个系数不是拍脑袋定的它们对应人眼视锥细胞对不同颜色的响应曲线。实际做滤镜时我几乎都采用这套标准系数效果最自然。2.2 灰度滤镜的GLSL代码与变量解析下面是灰度滤镜的片元着色器核心代码只有几行关键在理解精度修饰符和坐标声明precision mediump float; varying vec2 v_texCoord; uniform sampler2D inputImageTexture; void main() { vec4 color texture2D(inputImageTexture, v_texCoord); float gray dot(color.rgb, vec3(0.299, 0.587, 0.114)); gl_FragColor vec4(vec3(gray), color.a); }这里用dot点积运算一次性算完加权求和比分别写三次乘法再相加更简洁GPU执行效率也略高。alpha通道原样保留透明区域不会被破坏。precision mediump float是中等精度声明移动端GPU上比highp快对颜色计算这种场景精度已经足够。2.3 灰度滤镜的变体冷调、暖调和单通道灰度实际业务中常遇到产品经理提出“灰度要带一点冷/暖色调”的需求。做法很简单计算灰度值后分别在R和B通道上做偏移比如让R通道乘以0.95、B通道乘以1.05画面就会呈现轻微冷调。如果只取单个通道比如只保留G通道gray color.g出来的效果会有一种胶片感模拟老照片的通道缺失效果。这些变体在片元着色器里都只是微调一行代码的事但能适配不同的视觉风格。3. 正方形马赛克滤镜从对齐格子到清晰格边的实现正方形马赛克应该是大众认知度最高的马赛克效果实现原理一句话就能说明把画面按等间距切分成正方形网格每个网格内取一个固定位置的颜色填充整个格子。关键在于格子边界的计算以及采样点的定位。3.1 取整思路floor函数实现坐标分格在片元着色器里拿到的是连续变化的归一化坐标0~1。马赛克的本质就是对这个连续坐标做取整运算。假设马赛克格子大小是0.05坐标0.033和坐标0.071这两个片元落在同一个格子内float gridSize 0.05; float gridCoord floor(v_texCoord.x / gridSize) * gridSize;这一步是把连续坐标映射到离散的格子坐标——floor函数向下取整。用格子坐标替代原始坐标去采样纹理就能让同一格子内的所有像素取到同一个颜色值。这里有个关键选择格子坐标要加上半个格子的偏移量也就是采样格子中心点否则格子边缘会出现颜色渗漏和锯齿感。实际项目里马赛克格子的大小不能简单地用归一化尺寸更好的做法是传入像素级别的格宽和格高。因为纹理尺寸不一定是正方形如果用同一个归一化尺寸控制x和y方向可能会导致格子变成矩形。代码这样写precision highp float; varying vec2 v_texCoord; uniform sampler2D inputImageTexture; uniform float widthPixelSize; // 格子宽度像素 uniform float heightPixelSize; // 格子高度像素 void main() { vec2 coordinate v_texCoord * vec2(widthPixelSize, heightPixelSize); vec2 gridCoordinate floor(coordinate) / vec2(widthPixelSize, heightPixelSize); vec2 center gridCoordinate vec2(0.5 / widthPixelSize, 0.5 / heightPixelSize); gl_FragColor texture2D(inputImageTexture, center); }先乘以像素尺寸把坐标映射到像素空间再floor取整最后除以尺寸恢复成归一化坐标。这样不管纹理尺寸是多少格子都是精确的正方形。3.2 边缘处理和自适应格子大小马赛克滤镜常见的问题是边缘黑边和最后一个格子不完整。黑边出现的原因主要是纹理坐标在0和1边界时floor得到的结果可能落在纹理范围之外采样器默认的clamp_to_edge模式会去拉伸边缘像素产生一条暗线。解决思路是用min函数把格子坐标限制在有效范围内同时对边界片元做merge处理。还有一个细节如果想让边缘格子是整齐的可以在片元着色器里动态计算自适应格子大小即把剩余的分格宽度均摊给每一列格子这里的均摊涉及浮点除法优化vec2 adaptiveGrid floor(v_texCoord / gridSize); vec2 remain v_texCoord - adaptiveGrid * gridSize;这种做法适合对边缘完整性要求极高的场景比如UI贴图和文字图标。4. 六边形马赛克滤镜蜂窝网格构造与坐标偏移计算六边形马赛克是几个马赛克滤镜里写起来最需要数学功底的。它不是简单地画一个六边形纹理然后叠加而是要从网格几何上构造一个蜂窝状的采样结构让每个片元知道自己属于哪个六边形格子然后取该格子的中心点进行采样。4.1 蜂窝网格的核心思想隔行偏移蜂窝结构本质上是一个被“压扁”了的矩形网格——相邻行的格子错开半个格子宽度。这种排列跟蜂巢里六边形完美密铺的方式等价。用代码实现时不需要真的去算六边形的六个顶点只需要根据片元所在位置的行列判断它应该属于哪个“交错网格”的采样点。实现时先分两步第一步把纹理坐标放大让格子大小变成单位1然后对x和y分别取整得到行列号。第二步判断奇偶行。偶数行格子保持原样奇数行格子向右错开半个格宽。如果把x和y坐标直接交错偏移画面上会出现细密的菱形纹理这是六边形网格的骨架。vec2 coord v_texCoord * vec2(6.0, 3.0); // 放大系数可根据分辨率调整 coord.x step(1.0, mod(coord.y, 2.0)) * 0.5;vec2(6.0, 3.0)这个放大系数很关键。蜂窝网格的水平间隔是垂直间隔的两倍所以才需要分别设置不同的放大系数。step函数根据当前行的奇偶性返回0或1决定是否加上0.5的偏移量。4.2 六边形网格的完整着色器实现完整实现要处理质心距离判断。每个片元在完成奇偶行偏移后只需要比较自己和邻近两个候选中心的距离取距离最近的作为采样中心。代码如下precision highp float; varying vec2 v_texCoord; uniform sampler2D inputImageTexture; uniform float mosaicSize; // 控制蜂窝格子大小 void main() { float size mosaicSize; float ratio 1.1547005; // 2 / sqrt(3)用于修正六边形宽高比 vec2 coord v_texCoord * vec2(size * ratio, size); vec2 index floor(coord); vec2 fraction coord - index; // 根据奇数行偏移半格 float offset step(1.0, mod(index.y, 2.0)) * 0.5; index.x offset; coord.x offset; // 两个候选中心 vec2 center1 index 0.5; vec2 center2 index vec2(1.5, 0.5); vec2 point coord; float dist1 distance(point, center1); float dist2 distance(point, center2); vec2 nearest dist1 dist2 ? center1 : center2; gl_FragColor texture2D(inputImageTexture, nearest / vec2(size * ratio, size)); }ratio的作用是校正六边形横向拉伸。如果不校正蜂窝格子会显得扁长视觉上更像平行四边形而不是正六边形。这个系数来源于正六边形外接圆半径与边长的几何关系2.0 / sqrt(3.0)约等于1.1547005属于写一次就固定不变的常量。4.3 六边形滤镜性能优化与动态大小适配六边形马赛克相比正方形马赛克的计算量多了一个distance判断和一次模运算但实际用下来在主流移动设备上依然是完全实时的。这里有个优化点能用乘法减少除法能用简化距离判断就不要调用完整开方。distance内部用了sqrt如果对极致的性能有要求可以改成比较平方距离float dist1 dot(center1 - point, center1 - point); float dist2 dot(center2 - point, center2 - point);这样省去了两次sqrt调用视觉上完全没有差别。动态大小适配方面mosaicSize可以关联到纹理宽高比也可以做成uniform参数让用户在运行中调节。推荐关联分辨率而不是固定值否则在不同设备上蜂窝密度差异会很大。5. 三角形马赛克滤镜六边形到三角形的几何分解三角形马赛克是所有马赛克类型里最出效果的一种它本质是六边形马赛克的升级版——先把画面切成六边形网格再把每个六边形切分成6个小三角形。采样时对每个三角形区域取这个三角形的中心点或者取整个六边形的中心点。5.1 三角形网格的几何构成原理正六边形可以看成6个等边三角形拼成每个三角形的顶点是六边形的中心和两个相邻的六边形顶点。因此构造三角形马赛克的关键在于找到每个三角形重心的位置。实现思路分三步按六边形网格的方式生成蜂窝坐标在六边形内部做三角形细分判断当前片元落在哪个三角形中并采样对应的重心点。判断片元落在哪个三角形里可以用重心坐标系barycentric coordinates实现。把六边形中心点和边上的两个顶点视为三角形三个顶点计算片元在三角形内的重心坐标根据坐标符号判断归属。实际写起来还有一种更取巧的做法——对蜂窝坐标进行二次交错偏移产生两组三角形中心点根据片元坐标的斜线方程判断它应该用哪一组中心点。// 核心代码三角形中心点的两组候选 vec2 point v_texCoord * vec2(size * ratio, size); vec2 index floor(point); vec2 fraction point - index; float offset step(1.0, mod(index.y, 2.0)) * 0.5; index.x offset; point.x offset; vec2 centerA index vec2(0.5, 0.0); vec2 centerB index vec2(1.0, 0.5); // 判断使用哪个重心 float s33 fraction.x fraction.y - 0.5; vec2 nearest s33 0.0 ? centerA : centerB;这段代码的价值在于只用了一次加减和一次条件判断就把三角形划分完成了效率比通用重心坐标方案高不少因为GPU上分支判断的代价很高能简化为算术表达式就尽量简化。5.2 三角形马赛克完整代码与三角边缘调优以下是我在一款实时渲染Demo中实测通过的完整着色器包含宽高比修正和清晰的三角形分块precision highp float; varying vec2 v_texCoord; uniform sampler2D inputImageTexture; uniform float mosaicSize; void main() { float size mosaicSize; float ratio 1.1547005; vec2 point v_texCoord * vec2(size * ratio, size); vec2 index floor(point); vec2 fraction point - index; float offset step(1.0, mod(index.y, 2.0)) * 0.5; index.x offset; point.x offset; vec2 centerA index vec2(0.5, 0.0); vec2 centerB index vec2(1.0, 0.5); float s33 fraction.x fraction.y - 0.5; vec2 center s33 0.0 ? centerA : centerB; // 对边缘区域修正避免出现细缝 center mix(center, index vec2(0.5, 0.5), smoothstep(0.02, 0.0, abs(s33))); gl_FragColor texture2D(inputImageTexture, center / vec2(size * ratio, size)); }smoothstep在这里的作用是让三角形分块的边缘稍微模糊过渡消除因像素对齐导致的锯齿。这一步不是必须的但对于追求画质的场景能明显改善观感。如果不想要模糊过渡直接用硬条件判断即可。三角形马赛克有一个容易出问题的地方是三角形方向混乱。正常情况下相邻三角形的倾斜方向应该交替变化如果代码里没有处理好奇偶行偏移会出现成片的三角形朝向不一致视觉上像破碎的玻璃。调试时可以先把输出改成纯色网格用红绿蓝区分不同的三角形中心点确认方向正确后再叠加纹理采样。6. 高频问题排查边缘黑块、菱形变形和性能掉帧写GLSL滤镜最痛苦的环节不是算法写不出来而是现象在设备上出现了但找不到原因。我把这四类滤镜开发中最常遇到的五个问题整理成了速查表这些全是我自己做项目时逐个排查过的可以直接照方抓药。问题现象可能原因解决方案马赛克边缘出现黑色细线texture2D采样越界采样器ClampToEdge导致边缘像素被压缩用clamp函数把采样坐标限制在0.001到0.999之间六边形网格变形不是正六边形缺少宽高比修正ratio参数设置错误确认ratio 1.1547005并检查size是否需要区分x和y方向三角形朝向混乱像碎玻璃mod奇偶行判断和offset计算不正确单独输出index.x和offset的颜色值可视化调试马赛克边缘闪烁分辨率不同表现不一致格子大小固定为屏幕像素而不是归一化坐标把格子大小按纹理分辨率做归一化或用uniform传入设备尺寸滤镜整体偏色灰度发蓝权重系数使用错误或者忘了处理alpha通道核对权重是否为0.299、0.587、0.114gl_FragColor的alpha是否透传帧率骤降尤其在低端设备上shader内使用了动态分支或高精度浮点运算过多用算术替换分支将precision从highp降为mediump减少distance函数其中灰度发蓝这个案例我印象最深。有一次灰度滤镜在真机上画面整体偏蓝排查了很久才发现是因为某个版本的GPU驱动对mediump浮点数的精度支持不一致导致矩阵点积的结果被截断。换回highp并简化运算顺序后问题就消失了。这也提醒了一个原则滤镜类着色器尽量少用highp但在涉及点积和权重系数时精度缺失带来的色偏反而更明显。两害相权取其轻建议灰度滤镜单独保留highp。6.1 调试技巧没有print也能看清变量GLSL片元着色器里没有办法像CPU代码一样打日志。遇到定位困难的bug最实用的一招是把变量值映射成颜色输出到屏幕上。比如想确认六边形网格的索引是否正确就把index.x写到R通道、index.y写到G通道gl_FragColor vec4(fract(index * 0.5), 0.0, 1.0);这样屏幕上会出现规整的颜色渐变条纹根据条纹分布就能快速判断奇偶行偏移是否生效。检查完记得改回正式代码。7. 滤镜扩展方向从静态马赛克到动态网格动效做完了这四个静态滤镜其实你已经掌握了GLSL滤镜开发里最核心的数学能力坐标变换和网格分块。接下来可以沿着三个方向继续扩展。第一个方向是动态化。给马赛克格子大小传入一个随时间变化的uniform值或者用sin函数驱动格子边界偏移就能做出类似故障艺术glitch art的抖动效果。同样的网格分块思路稍微改改就能变成像素风扩散、百叶窗转场、碎片化消散这类效果。第二个方向是边缘检测叠加。在分块网格的基础上对格子边界的亮度做高亮处理可以做出漫画网格线风格。第三个方向是纹理扭曲。把网格坐标做旋转或极坐标转换就能在蜂窝网格上叠加扭曲变形效果。就单说六边形马赛克加一个极坐标到直角坐标的映射就能把蜂窝网格变成螺旋形蜂窝视觉效果完全不同但底层网格分块逻辑一行都不用改。我个人在实际操作中的体会是GLSL滤镜学习最忌讳只背代码核心的坐标变换思想一定要在纸上推导一遍。拿六边形马赛克来说你亲自画一遍蜂窝网格标出奇数行偏移的方向写代码时心里就踏实很多。调试时多用颜色可视化多改参数观察变化顺手总结每个参数的边界行为。这样一个滤镜做完收获的远不止一段着色器代码而是整套图形学的思考方式。