Qt Quick自定义模糊:用ShaderEffect实现高性能风格化效果 有段时间我在做某跨平台系统的一个设置页面底部要悬浮一张信息卡卡片背后的列表内容得有一种“毛玻璃”式的模糊感。一开始我图省事直接挂了Qt Quick自带的GaussianBlurradius调到20多静态预览确实漂亮。接入真实数据后问题马上暴露列表一滚动GPU占用率肉眼可见地往上走掉帧变得非常明显。换成FastBlur倒是快了一些但糊得发灰边缘处理也很生硬MaskedBlur则需要额外准备蒙版素材转移效果又不够灵活。那段时间我对Qt内置特效几乎失去信心。后来我认真研究了一遍ShaderEffect才算真正把“自定义模糊”这件事想明白——模糊并不一定要靠框架自带的几个滤镜自己写片段着色器反而更可控、更高效。这篇文章就把我从原理到落地、从均匀模糊到径向模糊的完整过程写出来适合正在被QML内置模糊效果折磨的界面开发者也适合想做出一些风格化视觉效果的读者。内容会涉及部分GLSL代码但我会把关键逻辑掰开揉碎保证你即使之前没写过shader也能看懂。1. 被内置模糊卡到怀疑人生才回头学ShaderEffect1.1 内置特效的尴尬好看但经不起考验Qt Quick里做模糊效果大家首先想到的通常是GaussianBlur、FastBlur、MaskedBlur这三个。它们用起来确实简单几行代码就能让一块区域“糊起来”。但在真实项目里它们的短板非常明显效果模糊方向性能特点适合场景GaussianBlur全方向均匀半径越大采样开销越大静态背景、低频滚动的常规模糊FastBlur降采样后拉伸速度较快但细节丢失多轻量级毛玻璃、低端机兜底方案MaskedBlur依赖蒙版控制中等局部区域模糊、形状遮罩模糊内置GaussianBlur为了兼顾通用性内部走的是全屏多次采样的路线。你把它半径调大GPU承担的采样量会呈平方级增长。我做的那次实验里radius从8调到28之后同一台测试设备的渲染耗时明显上升列表快速滚动时画面开始卡顿。FastBlur虽然便宜但它是通过把图像缩小再放大来“假装模糊”半径一大画面边缘会出现明显的马赛克感和暗边用在精致一点的UI上很难看。MaskedBlur又绕不开准备蒙版图而且它本质上还是固定的卷积模式你做不出径向放射、方向拖影这类效果。当时我需要的一个特效很朴素信息卡背后的列表模糊但模糊强度要随着列表滚动位置变化滚动越快、越模糊。这个用GaussianBlur做只能勉强全局调radius加上联动逻辑后卡顿更严重。我一度怀疑是不是自己用法不对查了很多资料最后发现问题不在用法而在工具本身——内置模糊特效的目标是“够用”不是“可定制”。1.2 ShaderEffect为什么是终局方案在着色器层面自己写采样ShaderEffect是Qt Quick里一块非常老的积木但它一直被大多数项目低估。它允许你直接写片段着色器决定每一个像素最终输出什么颜色。GPU上每个像素的计算是并行的你只要写好“给定坐标附近怎么取色、怎么加权”的逻辑剩下的事交给硬件。举一个很直观的例子内置GaussianBlur做一次9x9模糊需要在一个像素周围采集81个点而你用ShaderEffect手动控制则可以选择只采9个横向点、9个纵向点用“两次一维模糊”拼出同等效果采样数降到原来的四分之一都不止。这个优化思路内置特效给不了你必须自己写shader才能做。而且“自定义”这三个字的价值不只是性能。当你开始自己控制采样逻辑你会发现模糊其实只是一系列艺术的起点沿运动方向拖影是动感模糊从中心向外辐射是径向模糊中央焦点清晰、四周虚化是景深效果。这些效果在QML里靠自带滤镜几乎做不出来但在ShaderEffect里都只是几十行GLSL的事。这篇文章后面会逐个展开讲。2. 模糊的本质是一张卷积不是玄学2.1 卷积核每个像素都是一次小规模加权投票很多人在网上搜“模糊shader”拿到代码就复制粘贴一旦效果不对就不知道怎么改。因为缺少一个底层认知模糊本质上就是卷积。卷积这个词听起来唬人其实可以做这样一个类比假设你手里有一个表格里面记录了周围9个像素的“投票权重”中间像素自己权重最高越远越低。每个新像素的颜色就是周围像素按这些权重“投票”之后的结果。这个权重表格就是卷积核。均值模糊的卷积核最简单9个格子的权重全相等1/9。高斯模糊则讲究一些中间权重高、四周权重低权重分布像一个钟形曲线所以视觉效果更柔和不会出现明显的“方块感”。一个经典的3x3高斯核长这样1 2 1 2 4 2 1 2 1所有格子加起来是16所以最终颜色要除以16做归一化。这个核虽然小但它已经能体现出“中心像素贡献最大、对角像素贡献最小”的原则。你把卷积核变大、拉长、偏移方向模糊就会呈现出完全不同的形态——这就是“自定义模糊”的想象力来源。理解这一点之后再看网上那些长得很吓人的模糊shader就不会懵了。它们本质上都是在做同一件事定义一组采样坐标、一组权重然后遍历累加。2.2 UV坐标和texelSize让采样点走对路写GLSL模糊时新手最容易栽的第一个跟头是搞不清纹理坐标和像素坐标的关系。GLSL里texture2D采样的坐标是0.0到1.0的UV坐标不是像素坐标。也就是说一个512像素宽的纹理从左边的像素挪到右边相邻像素UV变化量是1/512而不是1。这个“每个像素在纹理里占多长一段UV区间”就是texelSize。假如你不管texelSize直接在shader里写uv vec2(5.0, 5.0)那在512px宽的纹理上相当于向右偏移了5个像素但同样的shader放到一张2048px宽的纹理上偏移量就变成了20个像素模糊效果完全不可控。所以正确的做法是把纹理尺寸换算成UV偏移量作为uniform传给shader。在QML侧一般是这么写的property vector2d texelSize: Qt.size(1.0 / source.width, 1.0 / source.height)然后在shader里每次采样坐标都写成vec2 uv qt_TexCoord0; vec4 color texture2D(source, uv vec2(1.0, 0.0) * texelSize);这个vec2(1.0, 0.0) * texelSize的含义就是“往右偏移一个像素”。把这里的偏移向量换成任意方向和距离你就得到了不同风格的模糊。这一课看起来基础但它是一切自定义采样的地基。3. 用ShaderEffect写第一版能看的均匀模糊3.1 ShaderEffectSource先把要被模糊的内容变成纹理ShaderEffect和普通的Image、Rectangle不同它本身不知道“模糊谁”。你得先用ShaderEffectSource把想模糊的内容“打包”成一张纹理然后把这张纹理交给ShaderEffect去处理。这里有个关键的架构选择我见过很多人用layer.enabled true配合layer.effect shaderEffect来做模糊。这个方案也能跑但源Item一旦变大、变复杂会引入额外的帧缓冲对象开销而且坑很多。我更推荐显式声明一个ShaderEffectSource把数据流理清楚Item { width: 480 height: 640 Rectangle { id: pageBg anchors.fill: parent gradient: Gradient { GradientStop { position: 0.0; color: #243447 } GradientStop { position: 1.0; color: #d3474f } } } ShaderEffectSource { id: blurSource sourceItem: pageBg anchors.fill: pageBg hideSource: true } ShaderEffect { id: blurEffect anchors.fill: blurSource property variant source: blurSource property vector2d texelSize: Qt.size(1.0 / blurSource.width, 1.0 / blurSource.height) fragmentShader: ... } }这里有几个关键点。sourceItem指向你想模糊的源对象hideSource: true表示源对象不再直接绘制到屏幕上只作为纹理提供给shader消费这样可以避免同一份内容在场景里被重复绘制两次。ShaderEffect里声明property variant source: blurSource这个属性会被自动映射成shader里的uniform sampler2D source。很多人忽略的一点ShaderEffectSource的尺寸会影响texelSize。如果源Item是480x640那texelSize就应该是Qt.size(1.0 / 480, 1.0 / 640)。这个值一定要和源纹理尺寸匹配否则模糊半径在不同分辨率上会表现不一致。3.2 一个5x5均匀模糊的完整示例我先给一个最容易看懂的版本5x5均值模糊也就是每个像素取周围25个点的平均值。之所以先写均值模糊是因为代码结构清晰、权重容易理解方便你把注意力集中在采样思路上。ShaderEffect { anchors.fill: blurSource property variant source: blurSource property vector2d texelSize: Qt.size(1.0 / blurSource.width, 1.0 / blurSource.height) property real blurRadius: 4.0 fragmentShader: varying highp vec2 qt_TexCoord0; uniform sampler2D source; uniform lowp float qt_Opacity; uniform highp vec2 texelSize; uniform highp float blurRadius; void main() { vec2 uv qt_TexCoord0; vec4 sum vec4(0.0); for (int x -2; x 2; x) { for (int y -2; y 2; y) { sum texture2D(source, uv vec2(float(x), float(y)) * texelSize * blurRadius); } } gl_FragColor sum / 25.0 * qt_Opacity; } }这个shader的思路很直白对当前UV坐标周围5x5范围内的每个点都采样一次加起来除以25。blurRadius控制采样距离默认4.0意思是采样点以4个像素为间隔向外扩散。你看一旦理解了texelSize这个模糊逻辑其实就跟“把颜料搅匀”差不多。不过均值模糊有个视觉问题边缘过渡不够柔和大半径时能隐约看到“格子感”。如果你想做得更精致把每个采样点的权重从等权改成高斯权重即可。但这里我不建议一上来就手写9x9的高斯展开因为81次纹理采样对低端GPU绝对是个考验后面第4部分会讲更聪明的做法。3.3 半径可变的动态模糊我做那个设置页的时候真正想要的不是恒定模糊而是模糊半径跟着滚动速度动态变化。用上面这个shader实现起来非常简单因为blurRadius是uniform变量QML侧可以直接改property real blurRadius: 8.0 // 在某个状态或动画里 blurRadius Math.max(16.0, listView.highlightMoveVelocity / 2000.0)当QML里的blurRadius改变时shader里的uniform值会同步更新下一帧所有像素都会按新的采样距离重新计算。这是一个非常讨巧的做法卷积核权重完全不用变只改采样距离视觉上就能产生“模糊程度变化”的效果省去了一堆纹理重采样开销。这里有个值得记住的经验模糊半径变化是一个高频交互需求但你不应该在shader里动态生成高斯权重。高斯权重是固定的变的只是采样偏移把“权重”和“偏移距离”解耦这是动态模糊性能的关键。4. 横向纵向两趟模糊采样数从四位数降到两位数4.1 高斯核的“可分离”特性一个二维卷积拆成一横一竖写模糊shader写到后面你一定会撞上性能墙。比如你想实现一个质量不错的大半径模糊卷积核开到9x9一次采样81个点。放到GPU上跑一帧还行但如果模糊区域很大或者需要连续动画帧率就开始跳水了。这里有一个数学上的捷径叫“可分离卷积”。简单说一个二维高斯卷积核可以拆成一个水平方向的一维高斯核和一个垂直方向的一维高斯核。你先对图像做一遍水平模糊再做一遍垂直模糊最终效果和直接做二维模糊几乎一样。采样次数从9x981次变成9918次直接缩到原来的四分之一不到。如果核更大差距更夸张。17x17的二维模糊需要289次采样拆开后只要34次。这就是两趟模糊的意义。卷积核大小二维直接采样次数分离后采样次数性能提升倍数3x3961.5倍5x525102.5倍9x981184.5倍17x17289348.5倍越是追求高质量、大半径分离模糊的优势就越明显。这也是很多图像处理库里“高斯模糊”都默认走两趟pass的原因。4.2 用两个ShaderEffect串出双pass模糊在QML里实现分离模糊本质上就是串联两个ShaderEffect。第一个只做水平采样第二个读取第一个的输出再做垂直采样。先看水平方向的shaderuniform highp vec2 texelSize; uniform highp float blurRadius; void main() { vec2 uv qt_TexCoord0; vec4 sum texture2D(source, uv) * 0.2270270270; sum texture2D(source, uv vec2(1.3846153846, 0.0) * texelSize * blurRadius) * 0.3162162162; sum texture2D(source, uv vec2(-1.3846153846, 0.0) * texelSize * blurRadius) * 0.3162162162; gl_FragColor sum * qt_Opacity; }这其实是一个经典的5次采样近似高斯9格权重方案中间采样1次左右各2次通过调整采样偏移距离和权重分布模拟出9个采样点的效果。这里我没有用标准的0.05、0.25之类的权重而是用了业界常用的近似系数视觉上比纯均值模糊柔和很多。垂直方向的shader一模一样只是偏移向量从vec2(1.3846153846, 0.0)改成vec2(0.0, 1.3846153846)。然后在QML里把两个效果串起来ShaderEffect { id: blurH anchors.fill: parent property variant source: blurSource property vector2d texelSize: Qt.size(1.0 / blurSource.width, 1.0 / blurSource.height) property real blurRadius: 8.0 fragmentShader: horizontalShader } ShaderEffectSource { id: blurHResult anchors.fill: blurH sourceItem: blurH hideSource: true } ShaderEffect { id: blurV anchors.fill: blurHResult property variant source: blurHResult property vector2d texelSize: Qt.size(1.0 / blurHResult.width, 1.0 / blurHResult.height) property real blurRadius: 8.0 fragmentShader: verticalShader }这里有一个容易被忽略的性能点blurHResult要设置hideSource: true。因为中间结果只是一个过渡纹理你不需要用户看到“只横糊了一下”的画面。如果不加hideSourceQt会多绘制一遍中间结果白白浪费一次全屏填充。我实际项目里把GaussianBlur换成这套分离模糊之后同样半径下的渲染负载明显下降。在测试机上列表滚动配合动态半径变化帧率从原来的明显卡顿变成基本稳定这个差异是能直接感知到的。4.3 顺手收获单方向模糊本身就是风格滤镜两趟分离模糊的结构其实还附带了一个彩蛋如果你只保留水平那一趟把垂直那趟去掉画面就会呈现一种“横向拖影”的视觉效果。很多音乐播放器的封面背景、MV风格的UI、故障艺术站点用的都是这种单方向模糊。反过来只做垂直模糊就是一道“速度线”效果画面像被从上到下拉出残影很适合用在“极速下滑”“翻页加速”的转场动效里。这些效果用GaussianBlur根本做不出来但在分离模糊结构里就是删掉一个ShaderEffect的事。当时我为了在设置页里体现“页面快速滚动时有加速度感”就用纵向模糊做了实时速度反馈滚动越快纵向模糊半径越大。这个效果如果靠内置滤镜得叠加好几层还未必可控现在一行属性绑定就搞定了。5. 从模糊到风格化方向模糊、径向模糊和缩放模糊5.1 方向模糊拖着走的光影均匀模糊解决的是“糊不糊”的问题但很多场景里你要的不是糊而是“往某个方向糊”。比如一个物体水平快速飞过视觉上应该有水平的拖影一辆车转弯轮胎应该有弧形的动态模糊。这些效果用固定卷积核做不出来因为卷积核的方向是死的。方向上自定义的模糊实现起来并不复杂核心就是把采样点从“方形网格”换成“一条直线”uniform highp vec2 blurDirection; // 归一化方向向量 uniform highp float blurStrength; // 拖影强度 const int SAMPLES 12; void main() { vec2 uv qt_TexCoord0; vec4 sum vec4(0.0); for (int i 0; i SAMPLES; i) { float t float(i - (SAMPLES - 1) / 2) / float(SAMPLES); sum texture2D(source, uv blurDirection * t * blurStrength); } gl_FragColor sum / float(SAMPLES) * qt_Opacity; }这个shader里blurDirection是传入的方向向量采样点在方向上前后铺开然后取平均。QML侧可以这样传property vector2d blurDirection: Qt.size(1.0, 0.3)改向量就能改变残影的走向。我有一个印象很深的试验把方向向量设成对角线模糊出来的画面特别像“柏油路上被风拉长的车灯”这种效果放在页面背景里氛围感很强。它不像普通模糊那样“温和”而是带有明确的叙事感。5.2 径向/缩放模糊从中心炸开的冲击感如果说方向模糊是“向一个方向拉”那径向模糊就是“向四面八方同时拉”。它特别适合表现速度与冲击力中心清晰四周像爆炸一样向外撕扯。径向模糊的核心思想是每个像素先算出自己相对于中心点的方向向量然后沿着这条方向向量做多次采样。离中心越远拖拽感越强。uniform highp vec2 center; // 模糊中心通常是(0.5, 0.5) uniform highp float strength; // 模糊强度0.0~0.2左右 const int SAMPLES 10; void main() { vec2 uv qt_TexCoord0; vec2 dir uv - center; vec4 color vec4(0.0); for (int i 0; i SAMPLES; i) { float scale float(i) / float(SAMPLES - 1) * strength; color texture2D(source, uv - dir * scale); } gl_FragColor color / float(SAMPLES) * qt_Opacity; }代码里uv - dir * scale的意思是从当前像素位置出发逆着“指向中心”的方向往回采几个点。对每一个像素来说它看到的是“一条从中心射向自己的轨迹上的多个点”整体效果就是在中心处留住焦点、四周呈现放射状残影。这个效果用途非常广。我做某个项目原型的欢迎页时加载动画就是一个旋转的圆环配上径向模糊每当加载进度跳一大段模糊半径就猛地放大再收回视觉冲击力比单纯转圈强很多。Qt内置特效里没有一个能直接实现这种效果但ShaderEffect写起来一百行都不到。缩放模糊其实就是径向模糊的一个变体当你把所有像素的采样方向设定为“从中心向外”而不是“沿自身径向”画面会呈现出镜头快速推拉的效果。经常有人把径向模糊和缩放模糊混为一谈它们的视觉差异用一句话概括径向模糊像“星轨旋转”缩放模糊像“冲向隧道”。5.3 焦点清晰、边缘模糊的景深效果还有一种很常见的UI需求中间内容要看得清楚四周背景做虚化用来突出焦点。典型的场景比如卡片弹窗、相册预览、视频播放器后台。如果直接做全屏模糊中间信息也被糊掉了不合适。实现思路其实是把“原图”和“模糊图”混合起来然后用一个遮罩控制混合比例uniform highp vec2 center; uniform highp float radius; void main() { vec2 uv qt_TexCoord0; // 中心焦点区域原样保留四周使用模糊结果 float dist length(uv - center); float mask smoothstep(0.1, 0.6, dist); vec4 sharpColor texture2D(source, uv); vec4 blurColor radialBlur(uv); // 复用上面的径向模糊逻辑 gl_FragColor mix(sharpColor, blurColor, mask) * qt_Opacity; }简单解释一下smoothstep(0.1, 0.6, dist)会生成一个0到1的渐变值距离中心越远、越接近1。mix函数根据这个值在清晰画面和模糊画面之间插值于是中心区域保持锐利边缘渐变成模糊。这个技巧不限于径向模糊所有自定义模糊都能配合使用是“自定义模糊”工具箱里很实用的一个扩展。6. 实战绕不开的五个坑与我的拆解顺序6.1 着色器版本与兼容性Qt Quick里的ShaderEffect默认使用的是旧式GLSL风格有些开发者习惯性地在shader里写textureSize()这类更高版本GLSL才有的函数结果在部分设备上编译失败打包出去黑屏问题还很难定位。我的建议很简单纹理尺寸相关的信息一律从QML端用uniform传进来就像前面代码里一直出现的那样。另外旧式GLSL对循环的兼容性并不是所有设备都一致。虽然现代GPU基本都能处理for循环但老驱动上for循环里的采样次数如果和uniform值动态绑定很可能会导致shader编译失败或者性能骤降。稳妥的做法是循环次数写死成const常量半径等动态变化量乘到偏移距离上而不是乘到循环计数器上。这个细节救过我一次当时我图省事把模糊半径直接当作循环上限结果在某个测试设备上shader一直编译不过。改成固定循环次数之后问题立刻消失。6.2 边缘发黑、重复纹理和采样边界模糊边缘发黑是ShaderEffect新手最常撞到的问题之一。原因很简单采样点跑到了纹理范围之外GPU不知道该怎么取值。Qt的ShaderEffectSource默认采样方式是ClampToEdge也就是超出边缘时取边缘像素大多数情况下这是安全的。但如果你把horizontalTileMode或verticalTileMode设成了Repeat边缘区域采样时就会跑到纹理的另一侧把远处的颜色拉到模糊边缘形成奇怪的亮边或色带。如果你想做“无缝平铺背景”的模糊Repeat模式看起来很有吸引力但建议只在背景至少重复四个方向且内容比较均匀时使用。否则边缘真的会非常难看。还有一个经验是如果模糊边缘还是发暗往往不是shader的问题而是源内容本身四周没有留安全边距。给源对象周围多留十几二十像素的余量模糊效果会自然很多。6.3 ShaderEffectSource的live和hideSourceShaderEffectSource的性能和正确性很大程度上取决于你对live和hideSource这两个属性的理解。live: true意味着源Item一旦变化纹理立刻更新live: false则只取一份快照。静态背景下建议把live关掉省去持续监听和纹理重采样的开销。但如果你模糊的内容在动画中live必须保持true否则模糊永远是第一帧的画面。hideSource: true则是确保源Item不直接绘制到屏幕上。这个属性一定要和你的视觉需求匹配如果你把源Item设置为visible同时又在ShaderEffect里引用它场景里会同时出现清晰版和模糊版两份内容颜色叠加之后很容易发白。如果你发现模糊区域比预期亮了一截先检查这里。6.4 动态属性更新导致的渲染负载ShaderEffect的属性绑定到uniform之后任何属性变化都会触发渲染器重新执行片段着色器。这个机制本身没问题但如果你用鼠标跟手拖拽来动态改模糊半径每次移动都会导致模糊区域整片重算。低端设备上这就是掉帧的来源。我的处理办法是把动画频率控制在合理范围。比如用NumberAnimation绑定半径或者自己写一个节流逻辑不要每帧都更新uniform。另外需要连续动态变化时考虑把卷积核缩小。比如实时拖拽时用5x5拖拽结束后再切换到质量更高的9x9用户几乎感知不到差别但性能体验完全不一样。6.5 调试办法status、日志和二分排查写ShaderEffect最怕的是shader编译失败屏幕一片黑你根本不知道问题在哪。Qt其实给了一个很好用的属性status。你可以把它输出到控制台Component.onCompleted: { console.log(shaderEffect.status) }status有枚举值Compiled表示编译成功Error表示失败Uncompiled表示还没有开始编译。如果发现Error基本可以确定是shader代码问题。这时候我会先把shader内容替换成最简单的一句gl_FragColor vec4(1.0, 0.0, 0.0, 1.0)如果画面变红说明整条渲染管线是通的问题出在shader逻辑本身如果画面依然黑那就要回头检查source绑定和ShaderEffectSource配置了。还有一个很实用的二分排查思路模糊shader里采样点越多出错的概率越高。不要一次性写一个复杂的方向模糊先让shader原样输出纹理只采样中心一个点确认能正常显示再逐步增加采样点、加入方向变量。每加一次就验证一次很快就能定位到是哪一行把画面搞坏的。这个方法比我见过的大多数“对着shader发愁半小时”要高效得多。我在实际项目里把这套方法落地的体会是自定义模糊真正的价值不在于做出比内置滤镜“更糊”的效果而在于让你获得对视觉的完全掌控权。性能问题可以靠分离卷积来解决风格化问题可以靠改采样方向来解决两者叠加之后你会发现QML界面视觉设计的边界一下子宽了很多。如果你也正卡在“想做一个内置特效做不出来的模糊效果”上不妨从这篇文章里的最简单均匀模糊开始拆着玩几轮你会上瘾的。