前阵子接了个品牌落地页项目,设计稿里放了一段类似熔岩流动的动态背景,甲方要求颜色必须能随时微调、不同页面换不同色系,最好还能跟着手势有点反应。我第一反应是上视频,但转念一想,一个视频文件几个MB,换颜色就得重新导出,而且和Flutter的导航转场也不好融合。后来被逼着把Flutter Shader编程认真啃了一遍,才发现以前是被自己的惯性思维困住了。
这篇文章就围绕Flutter里的Shader编程(准确说是Fragment Shader)怎么落地展开。我不会只贴个官方Demo就完事,而是把我从配置frag文件、写GLSL、到调试真机特效踩过的坑,全部摊开讲。适合两种人看:一种是Flutter已经玩得比较熟、想给界面加点真正有辨识度的动态效果的人;另一种是刚接触Flutter,但被各种"着色器"概念劝退过的人。看完你会发现,Flutter写Shader没有想象中那么玄乎,它本质上就是给GPU写一小段并行计算程序,你只要掌握了几个关键概念,就能做出很多常规Widget动画很难做到的东西。
1. Flutter Shader能帮你做什么:先搞清它的能力边界
1.1 普通Flutter动画的短板在哪里
Flutter自带的动画体系其实很能打:AnimationController加各种Curve、AnimatedContainer、Hero、粒子库,大多数UI动效都能做。但你会发现一个共同点——这些动画操作的是对象:位置、大小、透明度、颜色插值、旋转角度。它们改变的是属性,不是像素。
一旦你要的效果是"画面上每一小块像素都按照某个规则独立变化",常规手段就非常吃力了。比如:
- 背景上有一层隐约流动的噪点,像老电影胶片颗粒;
- 水面上不断扩散、衰减的涟漪;
- 文字边缘出现周期性色散,像故障电视的RGB分离;
- 整张图片根据鼠标或手势的位置产生扭曲。
这类效果如果强行用多个Widget叠加上百个Transform或者定时器去刷,性能会很难看。因为这些效果本质上是逐像素计算,而Widget动画的粒度是组件级,硬掰不仅别扭,而且每帧都在Dart层做大量计算,根本跑不到流畅的60FPS。
Shader正好补上这一块。它不是Flutter独有的概念,任何一个图形应用、游戏引擎、图像处理软件背后都有Shader。Flutter从3.7开始把自定义Fragment Shader作为正式能力开放,允许你把一段GLSL/SkSL代码塞给GPU,让它在每帧渲染时对每一块像素并行执行这套规则。
1.2 Shader是什么:给GPU写的一段小程序
在深入Flutter API之前,我建议你先在脑子里建立一个模型:Fragment Shader(片元着色器)就是"每一个像素执行一次的GPU小程序"。
比如屏幕上一块 300x300 的区域,GPU会把它切成9万个像素,每个像素都独立地跑一遍你写的main()函数,输入是这个像素的坐标(fl_FragCoord),输出是这个像素的颜色(fragColor)。因为GPU有上千个计算核心在同时跑,所以即便每一帧要算几百万个像素,速度也比CPU快得多。
Flutter里的Shader其实遵循的是GLSL语法的一个变体,官方叫SkSL,但是你在写.frag文件时基本可以把它当成简化版GLSL来看。和标准GLSL最直观的区别有两个:
- 入口函数
main()不做任何渲染管线的事,你只需要往里写:
glsl复制out vec4 fragColor;
void main() {
fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
这段代码的意思是,让这个Shader作用范围内每个像素都输出红色。就这么简单。
- 固有点里,
fl_FragCoord表示当前片元的坐标,vec2、vec3、vec4这些向量类型和sin、cos、mix、smoothstep这些内置函数都会有。你不需要学完整版GLSL,掌握常用的二三十个函数就足够做出大量视觉特效。
1.3 适合Shader的特效清单与不适合的场景
我把这几年见过的Flutter Shader落地场景归了个类,方便你判断要不要往这个方向走:
| 适合用Shader的场景 | 典型效果 | 备注 |
|---|---|---|
| 品牌/产品页动态背景 | 流动渐变、熔岩、极光、星空 | 比视频体积小,颜色可编程控制 |
| 图片/视频艺术化处理 | 像素化、模糊后处理、色调分离、果冻扭曲 | 需要和采样器配合 |
| 数据可视化 | 热力图、场强图、波形图 | 天然适合逐像素计算 |
| 游戏化界面 | 水面波动、粒子云、霓虹扫描线 | Flutter做游戏UI的加分项 |
| 过渡动画 | 页面切换时的像素擦除、波纹置换 | 比普通位移更有质感 |
不适合的场景也很明确:如果你只想做个简单的线性渐变或者轻微阴影,不要用Shader。Flutter自带Gradient、BoxShadow、BackdropFilter已经做得很好了,引入Shader等于增加加载耗时、调试成本和跨平台兼容风险,属于杀鸡用牛刀。
另外要提醒一句:Shader做的是"像素级无状态计算",它天然不擅长交互状态管理。比如你要响应点击事件改变某个区域的颜色,虽然可以通过uniform传参实现,但逻辑多了以后会很难维护。这种情况建议把"状态管理"留在Dart层,把"视觉呈现"交给Shader,各干各的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 运行链路拆解:frag文件、FragmentProgram与uniform传参
2.1 配置shaders目录与pubspec声明
搞清楚Shader能干嘛之后,下一步就是把它接到Flutter工程里。Flutter的官方玩法是:在项目根目录建一个shaders文件夹(名字可以自己定,但后面的声明要和路径一致),里面放.frag文件,然后在pubspec.yaml里声明:
yaml复制flutter:
uses-material-design: true
shaders:
- shaders/gradient.frag
- shaders/water_ripple.frag
改完pubspec后记得重新运行flutter run,Flutter工具会把这些.frag文件编译成平台相关的二进制内容,随应用打进去。
这里有个常见的坑:很多新手在pubspec.yaml里写完声明后不重启应用,然后加载报Unable to load asset。Shader资源不是普通的assets,它需要Flutter工具链在构建阶段做一次编译,所以必须重启构建流程,热重载不一定能感知到新配置。
编译的原理我不展开细说,简单理解就是:Dart侧的FragmentProgram.fromAsset('shaders/gradient.frag')会把编译产物加载进来,再通过program.fragmentShader()拿到一个可设置参数、可绑定到Paint上的FragmentShader对象。这个对象在一帧渲染里可以反复使用,每次使用前更新uniform参数即可。
2.2 加载Shader并绑定到Paint
Shader最终要作用到画面,常见有两种方式:
第一种是绑定到Paint.shader,然后用Canvas画出来:
dart复制import 'dart:ui' as ui;
class ShaderPainter extends CustomPainter {
ShaderPainter(this.shader, this.time);
final ui.FragmentShader? shader;
final double time;
@override
void paint(Canvas canvas, Size size) {
if (shader == null) return;
shader!
..setFloat(0, time)
..setFloat(1, size.width)
..setFloat(2, size.height);
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant ShaderPainter oldDelegate) {
return oldDelegate.time != time || oldDelegate.shader != shader;
}
}
第二种是包在ShaderMask里,让Shader影响子Widget的渲染:
dart复制ShaderMask(
shaderCallback: (rect) {
shader
..setFloat(0, time)
..setFloat(1, rect.width)
..setFloat(2, rect.height);
return shader;
},
blendMode: BlendMode.srcIn,
child: Text('Flutter Shader', style: TextStyle(fontSize: 48)),
)
这两种方式没有优劣之分,主要看你的目标是"独立绘制一块特效区域"还是"把特效叠加在已有内容上"。做背景我一般用CustomPaint,做文字特效或复杂UI遮罩我倾向于ShaderMask。
2.3 坐标系与uniform顺序:两个最常见的坑
我在这个环节踩过两个坑,几乎每个刚上手Flutter Shader的人都会遇到。
第一个坑是坐标系。在Flutter的Fragment Shader里,fl_FragCoord的坐标范围是逻辑像素,从(0,0)到(width,height),不是标准化到0~1的UV坐标。所以你第一件事通常是把坐标归一化:
glsl复制uniform vec2 uSize;
void main() {
vec2 uv = fl_FragCoord.xy / uSize.xy; // 现在uv的范围是0~1
// 后续所有逻辑都用uv计算
}
如果不除以uSize,你会发现所有用到uv的数学公式(比如距离、圈数、比例)会随屏幕宽度变化而不可预测,同一段代码在iPhone和Android上效果完全不同。记住这个习惯:能用UV就用UV,不要直接拿原始像素坐标做距离运算。
第二个坑是uniform参数顺序和数量必须严格匹配。你在.frag文件里声明:
glsl复制uniform float uTime;
uniform vec2 uSize;
那么在Dart侧调用顺序就该是:
dart复制shader.setFloat(0, time); // 对应uTime
shader.setFloat(1, size.width); // 对应uSize.x
shader.setFloat(2, size.height);// 对应uSize.y
setFloat的索引从0开始,顺序一旦反了,画面会莫名奇妙。尤其当你有多个vec2、vec3混在一起时,特别容易数错。我的习惯是在frag文件里用注释把uniform序号标出来,避免数错。
如果你发现shader效果很像但颜色不对、扭曲方向反了,先查uniform顺序和坐标系,这两个问题占了Shader调试里八成以上的"灵异事件"。
3. 三个可复用的特效实战:从渐变到水波纹再到glitch
3.1 动态渐变背景:掌握uniform传递和uv坐标
先来一个最简单的动态渐变背景,它虽然简单,但能帮你把整条链路跑通:pubspec配置、加载Shader、每帧更新uniform。
创建shaders/flow_gradient.frag:
glsl复制uniform float uTime;
uniform vec2 uSize;
out vec4 fragColor;
void main() {
vec2 uv = fl_FragCoord.xy / uSize.xy;
vec3 colorA = vec3(0.13, 0.36, 0.77);
vec3 colorB = vec3(0.93, 0.36, 0.63);
float mixValue = 0.5 + 0.5 * sin(uv.x * 3.0 + uTime * 1.5);
vec3 color = mix(colorA, colorB, mixValue);
color += 0.05 * sin(uv.y * 20.0 + uTime * 4.0);
color = clamp(color, 0.0, 1.0);
fragColor = vec4(color, 1.0);
}
这段代码做的事情很直白:横向按照正弦波在蓝色系和粉色系之间来回混合,纵向叠加了一层细微的波纹,让背景看起来像有光线在流动。mixValue的计算方式你可以随意替换,改成uv.x + uv.y就是对角渐变,改成distance(uv, vec2(0.5))就是中心扩散。
Dart侧核心代码我放在一个StatefulWidget里,用AnimationController驱动:
dart复制class FlowGradientBackground extends StatefulWidget {
const FlowGradientBackground({super.key});
@override
State<FlowGradientBackground> createState() => _FlowGradientBackgroundState();
}
class _FlowGradientBackgroundState extends State<FlowGradientBackground>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
ui.FragmentShader? _shader;
@override
void initState() {
super.initState();
_controller =
AnimationController(vsync: this, duration: const Duration(seconds: 8))
..repeat();
_loadShader();
}
Future<void> _loadShader() async {
final program =
await ui.FragmentProgram.fromAsset('shaders/flow_gradient.frag');
if (!mounted) return;
setState(() => _shader = program.fragmentShader());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: ShaderPainter(_shader, _controller.value * 2 * 3.1415926),
child: child,
);
},
);
}
}
注意我在AnimatedBuilder里没有设置size,它默认会撑满父容器。ShaderPainter的代码就是上一节贴过的那一段。跑起来之后,你应该能看到一个颜色不断流动的渐变背景。
我强烈建议你先把这个最小Demo跑通,再往后看。因为后续所有特效都是同样的链路,只是frag文件里的数学不一样。
3.2 水波纹涟漪:距离场与时间动画
动态渐变跑通之后,你可以尝试一个更典型的特效——水波纹。这个效果的核心是距离场:每个像素到某个中心点的距离,决定了它当前处于波纹的哪个阶段。
创建shaders/water_ripple.frag:
glsl复制uniform float uTime;
uniform vec2 uSize;
uniform vec2 uCenter;
out vec4 fragColor;
void main() {
vec2 uv = fl_FragCoord.xy / uSize.xy;
vec2 center = uCenter / uSize.xy;
float dist = distance(uv, center);
float wave = sin(dist * 30.0 - uTime * 4.0) * 0.5 + 0.5;
float falloff = exp(-dist * 4.0);
float alpha = wave * falloff;
vec3 deepWater = vec3(0.02, 0.08, 0.20);
vec3 waveColor = vec3(0.30, 0.70, 0.95);
vec3 color = mix(deepWater, waveColor, alpha);
fragColor = vec4(color, alpha * 0.9);
}
Dart侧你只需要在每次绘制时额外传入一个点击坐标作为圆心:
dart复制shader
..setFloat(0, time)
..setFloat(1, size.width)
..setFloat(2, size.height)
..setFloat(3, center.dx)
..setFloat(4, center.dy);
exp(-dist * 4.0)是衰减函数,让波纹离开中心后迅速变淡。这里用exp而不是1.0 - dist,好处是指数衰减更接近真实水波,而且不会在距离为0附近出现突兀的截断。你可以尝试把30.0改成5.0或80.0,感受波纹密度变化。这也是调试Shader最爽的部分——所有参数都是实时反馈的。
如果你想让波纹从多个点同时向外扩散,只需要把单个距离场的计算循环几遍:
glsl复制vec2 points[3];
points[0] = vec2(0.3, 0.3);
points[1] = vec2(0.7, 0.5);
points[2] = vec2(0.5, 0.8);
float wave = 0.0;
for (int i = 0; i < 3; i++) {
float d = distance(uv, points[i]);
wave += sin(d * 30.0 - uTime * 4.0) * exp(-d * 5.0);
}
循环在Shader里虽然能用,但次数别太多,移动端GPU的循环次数会影响性能,一般个位数没问题。
3.3 像素噪点与故障偏移:hash函数和采样
第三个特效是故障风(Glitch),这几年很多产品喜欢在启动页或营销页用它制造科技感。它由两个关键部分组合而成:随机噪点和按行偏移。
先写一个伪随机hash函数:
glsl复制float hash(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
这不是严格意义上的随机,但对视觉效果足够了。基于它,我们可以对每个像素的垂直位置做随机偏移:
创建shaders/glitch_shift.frag:
glsl复制uniform float uTime;
uniform vec2 uSize;
out vec4 fragColor;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = fl_FragCoord.xy / uSize.xy;
float blockRows = 60.0;
float rowIndex = floor(uv.y * blockRows);
float offsetAmount = 0.08 * hash(vec2(rowIndex, floor(uTime * 10.0)));
if (hash(vec2(rowIndex, uTime)) > 0.85) {
offsetAmount = 0.0;
}
uv.x = fract(uv.x + offsetAmount);
vec3 color = vec3(0.1, 0.8, 0.6);
color *= 1.0 + 0.3 * sin(uv.y * 100.0 + uTime * 5.0);
fragColor = vec4(color, 1.0);
}
这段代码的思想是:把画面按高度切成60行,每一行根据一个hash值决定水平偏移量。uTime参与hash,所以每一行在不同时间会随机切换到新的偏移状态。后面的0.85判断是为了让部分行保持不偏移,制造一种"偶尔抽风"的故障感。
如果把vec3 color换成对原始图像的采样,这个效果就能直接作用在照片上:
glsl复制uniform sampler2D uTexture;
void main() {
// ...
vec4 texColor = texture(uTexture, uv);
fragColor = vec4(texColor.rgb * colorShift, texColor.a);
}
Flutter较新版本里,可以通过setImageData把ui.Image喂给sampler2D,具体API在不同SDK版本略有差异,我用的时候会先查一下当前版本是否支持。老版本如果遇到问题,可以退一步选择对整张图做Widget级的ShaderMask,效果类似但自由度低一些。
4. 真机性能与渲染器兼容:把特效安全送上线的经验
4.1 Shader加载时机与首帧卡顿
Shader特效最容易翻车的地方不是效果做不出来,而是首帧卡顿。原因在于FragmentProgram.fromAsset是异步加载,而且底层要做编译,耗时可能从几十毫秒到几百毫秒不等。如果你在页面第一次build时才去加载,用户很可能会看到白屏或闪一下。
我的做法是提前预加载。在App初始化阶段或者页面路由跳转前,就把需要的FragmentProgram加载好并缓存起来:
dart复制class ShaderLibrary {
static final Map<String, ui.FragmentProgram> _cache = {};
static Future<ui.FragmentProgram> load(String assetPath) async {
if (_cache.containsKey(assetPath)) return _cache[assetPath]!;
final program = await ui.FragmentProgram.fromAsset(assetPath);
_cache[assetPath] = program;
return program;
}
}
这样进入页面时,fragmentShader()的创建是同步的,首帧就能直接画。缓存的好处还有一个:多个页面复用同一个Shader时不会重复编译。
另外要注意,FragmentShader对象本身不是特别重,但如果你每帧都重新fragmentShader()再setFloat,会造成不必要的对象分配。正确做法是只创建一个FragmentShader实例,在paint里反复setFloat更新参数。Shader描述的是规则,uniform是参数,对象本身可以复用。
4.2 Impeller vs Skia:遇到黑屏和花屏先这么排查
Flutter的渲染引擎在过去几年经历了一次大迁移:从Skia转向Impeller。Impeller在iOS和Android上大大减少了首次运行的着色器编译卡顿,但它对自定义Fragment Shader的支持是渐进式的。
我自己在实际项目里遇到过这样的情况:同一个.vert/.frag在模拟器和旧引擎下正常,切到Impeller渲染后整个画面变黑或纹理扭曲。后来排查下来,发现是自定义Shader在Impeller的某些版本里对uniform类型的支持还不完整。
如果你也遇到"代码明明没问题,但真机上就是黑屏"的情况,我建议按这个顺序排查:
- 在命令行加
--no-enable-impeller跑一次,如果恢复正常,说明问题出在Impeller兼容性上。 - 检查你的Flutter版本是不是偏旧,升级到较近的稳定版,很多Shader兼容问题在新版本已经修复。
- 检查frag文件是否有标准GLSL之外的写法,比如一些GLSL编辑器常用的
gl_FragCoord在Flutter里就要换成fl_FragCoord。
这里说句实话:你不要指望Shader在所有设备上效果完全一致。移动GPU厂商(高通、联发科、苹果)对浮点数的处理有细微差异,这在Shader里会被放大。色彩渐变类的效果还好,一旦涉及复杂的sin嵌套或者高位精度计算,低端机可能出现轻微色带或闪烁。遇到这种情况,我通常会在frag里对最终颜色做一次clamp,或者加一层微弱的噪点来掩盖色带。
4.3 性能监控与常见报错
Shader性能问题不能只靠"感觉卡不卡"。我一般用Flutter自带的PerformanceOverlay来观察帧耗时,开启方式很简单:
dart复制void main() {
runApp(
const MaterialApp(
home: PerformanceOverlay(child: MyApp()),
),
);
}
如果帧耗时在Shader区域明显上升,优先排查这几点:
- Shader作用区域是不是太大了:全屏逐像素计算很耗GPU,很多特效其实只需要作用在一个几百像素的卡片里,尽量用
ShaderMask或CustomPaint限制作用范围。 - 分支逻辑是不是太多了:GPU的并行能力和CPU不同,过于复杂的
if-else会影响效率。能用mix、step、smoothstep完成的,不要写大量分支。 - 有没有不必要的采样:
texture()采样本身有开销,能少采就少采。
常见的运行时报错主要有两类。一类是Invalid shader,通常是.frag文件语法错误,比如少了分号、声明了不支持的变量,Flutter会把编译错误打印到控制台,仔细看信息一般能定位到行号。另一类是uniform count mismatch,说明Dart侧setFloat的数量或顺序和frag不一致,检查uniform声明顺序即可。
5. 从特效到产品落地:我能给你的几条实在建议
5.1 用ShaderMask还是CustomPaint
很多人在做文字特效时会纠结遮罩方式。我自己的经验是:
- 想让Shader作用于整个子Widget树(包括图片、文字、多个组件),用
ShaderMask,配合blendMode能在保留轮廓和重新着色之间切换。 - 想让Shader作为一个独立视觉层,不干扰其它UI,用
CustomPaint自己画。 - 想做背景动效,直接用
Stack里放一个全屏CustomPaint,上面再盖普通业务Widget,这样Shader层和UI层天然隔离,即使Shader崩了也不影响主流程。
另外要提醒一个细节:ShaderMask的shaderCallback里拿到的rect是逻辑像素区域,不是物理像素。如果你的shader需要和屏幕分辨率精确对应,注意把逻辑像素转成物理像素再传给uniform。不同设备的DevicePixelRatio不同,不处理的话模糊和错位几乎是必然的。
5.2 别忽视可访问性和降级方案
Shader特效视觉效果拉满,但产品真要上线,不能不考虑两点。
第一点是系统Deep Linking和低电量模式。部分用户手机开启省电模式后,GPU性能会明显下降,动态全屏Shader在这种场景下可能掉帧严重。我现在的做法是做一个简单的开关:在设置页允许用户关闭动态特效,关闭后Shader动画停在静态首帧,或者切换成普通的Gradient静态背景。
第二点是缩放的适配。Shader的坐标系是基于逻辑像素的,但手机屏幕尺寸从4.7寸到7寸都有,长宽比更是五花八门。你在6.1寸屏上调好的波纹密度,放到小屏或平板可能就变稀了。我的习惯是少用固定数值,多用相对坐标:比如把波纹数量定义成uv.x * 比例而不是固定像素值,这样不同屏幕下观感更一致。
5.3 后续可以继续探索的方向
Flutter Shader编程上手之后,你很快会发现它其实是一整个视觉技术域的大门。顺着这条路继续挖,有几个我认为性价比很高的方向:
- 图片后处理:给相册页面加实时滤镜,黑色暗角、复古颗粒、霓虹色板,Shader天然是滤镜的好载体。
- 页面转场过渡:Flutter的页面转场目前大多是位移、淡入、缩放。用Shader搭配
AnimatedBuilder给转场中间帧加一个像素置换,效果会非常有辨识度,而且实现成本不高。 - 和手势深度结合:用手指拖拽水流、点击产生发光波纹、长按让背景扭曲。手势坐标通过uniform传进Shader,实时反馈,这种体验在普通App里非常少见,但有Shader加持之后其实不难。
- 数据驱动的动态背景:把当前音乐播放的频谱、天气的湿度、股票涨跌转换成Shader参数,让整个App背景和业务数据同频呼吸。这是在"玩"和"实用"之间平衡得很好的一个方向。
不过说句实在话,Flutter的Shader生态相比WebGL、Unity Shader Graph这些成熟体系还比较年轻,很多资料需要自己去啃Skia和Impeller的源码,社区里现成的轮子也没那么多。这既是挑战也是机会——你只要比大多数人早一步掌握这套能力,就能在Flutter开发者的"内容辨识度"上拉开明显差距。
最后分享一个我自己的小技巧:在调试Shader时,不要一上来就调复杂的数学公式,先在frag里写一个最简单的颜色输出(比如只输出uv.x作为红色通道),确认整条加载链路是通的,再逐步添加效果。每次只改一个变量,观察它带来的变化。这样调试一小时,胜过瞎试一整天。
