Perché gli shader in Flutter
Quando serve un gradiente animato, un effetto dissolvenza, un blur creativo o una transizione "liquida", la strada classica è comporre widget e animazioni. Funziona, ma a un certo punto si scontra con un limite: ogni pixel dell'effetto viene calcolato dalla CPU o richiede molti layer di composizione.
Dal rilascio dell'API FragmentProgram (stabile da Flutter 3.7) è possibile scrivere fragment shader in GLSL e farli eseguire direttamente dalla GPU. Il risultato è codice grafico estremamente veloce, indipendente dalla complessità della UI, che con Impeller viene per giunta precompilato in fase di build — quindi senza il classico jank da compilazione shader che affliggeva Skia.
In questa guida vediamo come integrare uno shader in un progetto Flutter, come passargli dati dall'app, come applicarlo a un widget esistente e quali trappole evitare.
Cos'è un fragment shader
Un fragment shader è un piccolo programma che viene eseguito una volta per ogni pixel della superficie da disegnare. Riceve in input la posizione del pixel e (opzionalmente) dei parametri chiamati uniform, e deve restituire un colore RGBA.
In Flutter il contratto è semplice:
- il file ha estensione
.frage usa GLSL ES 3.0 (#version 460 corenella sintassi accettata dal compilatore di Flutter); - deve includere
flutter/runtime_effect.glsl, che fornisce la funzioneFlutterFragCoord(); - deve dichiarare un output
out vec4 fragColor.
Il primo shader: gradiente animato
Creiamo shaders/gradient.frag:
#version 460 core
#include <flutter/runtime_effect.glsl>
precision mediump float;
uniform vec2 uSize; // dimensioni dell'area di disegno
uniform float uTime; // tempo in secondi
uniform vec4 uColorA;
uniform vec4 uColorB;
out vec4 fragColor;
void main() {
// coordinate normalizzate: (0,0) in alto a sinistra, (1,1) in basso a destra
vec2 uv = FlutterFragCoord().xy / uSize;
float wave = 0.5 + 0.5 * sin(uv.x * 6.2831 + uTime);
float t = clamp(uv.y * 0.6 + wave * 0.4, 0.0, 1.0);
vec3 color = mix(uColorA.rgb, uColorB.rgb, t);
fragColor = vec4(color, 1.0);
}
Il file va dichiarato nel pubspec.yaml, nella sezione flutter, sotto la chiave shaders:
flutter:
uses-material-design: true
shaders:
- shaders/gradient.frag
A questo punto, in fase di build, il compilatore impellerc traduce il GLSL nel formato intermedio richiesto dalla piattaforma di destinazione (Metal, Vulkan, OpenGL). Nessuna compilazione a runtime.
Caricare ed eseguire lo shader
Il caricamento è asincrono e va fatto una sola volta, tipicamente in initState o in un provider:
import 'dart:ui' as ui;
Future<ui.FragmentShader> loadShader() async {
final program = await ui.FragmentProgram.fromAsset('shaders/gradient.frag');
return program.fragmentShader();
}
Lo shader si usa come un normale Shader all'interno di un Paint, quindi il posto naturale è un CustomPainter:
class GradientPainter extends CustomPainter {
GradientPainter({
required this.shader,
required this.time,
required this.colorA,
required this.colorB,
required Listenable repaint,
}) : super(repaint: repaint);
final ui.FragmentShader shader;
final double time;
final Color colorA;
final Color colorB;
@override
void paint(Canvas canvas, Size size) {
shader
..setFloat(0, size.width) // uSize.x
..setFloat(1, size.height) // uSize.y
..setFloat(2, time) // uTime
..setFloat(3, colorA.red / 255)
..setFloat(4, colorA.green / 255)
..setFloat(5, colorA.blue / 255)
..setFloat(6, colorA.alpha / 255)
..setFloat(7, colorB.red / 255)
..setFloat(8, colorB.green / 255)
..setFloat(9, colorB.blue / 255)
..setFloat(10, colorB.alpha / 255);
canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
}
@override
bool shouldRepaint(GradientPainter oldDelegate) => true;
}
Attenzione all'ordine delle uniform
Questo è l'errore più comune per chi inizia. setFloat non accetta il nome della uniform ma un indice progressivo di float: un vec2 occupa due slot, un vec4 ne occupa quattro. Se aggiungi una uniform in mezzo al file GLSL, tutti gli indici successivi slittano e l'effetto si rompe in modo silenzioso.
Due accorgimenti utili:
- documentare gli indici con un commento accanto a ogni
setFloat, come nell'esempio; - oppure usare il pacchetto
flutter_shaders, che offresetFloatUniformscon un'API a callback sequenziale molto più leggibile:
import 'package:flutter_shaders/flutter_shaders.dart';
shader.setFloatUniforms((uniforms) {
uniforms
..setSize(size)
..setFloat(time)
..setColor(colorA)
..setColor(colorB);
});
Animare lo shader
Per far avanzare il tempo serve un ticker. Un AnimationController in modalità repeat() è la soluzione più semplice, e passandolo come repaint al CustomPainter si evita di ricostruire l'intero widget tree a ogni frame:
class AnimatedGradient extends StatefulWidget {
const AnimatedGradient({super.key});
@override
State<AnimatedGradient> createState() => _AnimatedGradientState();
}
class _AnimatedGradientState extends State<AnimatedGradient>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 6),
)..repeat();
ui.FragmentShader? _shader;
@override
void initState() {
super.initState();
loadShader().then((s) {
if (mounted) setState(() => _shader = s);
});
}
@override
void dispose() {
_controller.dispose();
_shader?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final shader = _shader;
if (shader == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(
painter: GradientPainter(
shader: shader,
time: _controller.value * 6.2831,
colorA: const Color(0xFF3A1C71),
colorB: const Color(0xFFFFAF7B),
repaint: _controller,
),
size: Size.infinite,
),
);
}
}
Nota il _shader?.dispose(): FragmentShader alloca risorse native e va rilasciato, altrimenti si accumulano leak nelle schermate che entrano ed escono di frequente.
Applicare uno shader a un widget esistente
La parte davvero interessante arriva quando lo shader non genera pixel dal nulla, ma trasforma il contenuto già disegnato da un sottoalbero di widget: dissolvenze, effetti di scioglimento, distorsioni su una card o su un'immagine.
Per farlo serve un sampler2D, cioè una texture in ingresso:
#version 460 core
#include <flutter/runtime_effect.glsl>
precision mediump float;
uniform vec2 uSize;
uniform float uAmount; // 0 = originale, 1 = effetto pieno
uniform sampler2D uTexture; // il widget renderizzato
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
vec4 color = texture(uTexture, uv);
float gray = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
fragColor = vec4(mix(color.rgb, vec3(gray) * color.a, uAmount), color.a);
}
Attenzione: i colori che arrivano dal sampler sono premoltiplicati per l'alpha. Se ignori questo dettaglio, i bordi antialiasati e le zone trasparenti mostreranno aloni scuri o chiari. Nell'esempio il grigio viene rimoltiplicato per color.a proprio per restare coerente.
Lato Dart, catturare il sottoalbero come immagine è scomodo a mano; flutter_shaders mette a disposizione AnimatedSampler, che lo fa in modo efficiente sfruttando il layer di rendering:
AnimatedSampler(
(ui.Image image, Size size, Canvas canvas) {
shader
..setFloat(0, size.width)
..setFloat(1, size.height)
..setFloat(2, amount)
..setImageSampler(0, image);
canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
},
child: const ProductCard(),
)
Gli indici dei sampler sono separati da quelli dei float: setImageSampler(0, ...) si riferisce al primo sampler2D dichiarato nel file, indipendentemente da quante uniform float ci sono prima.
Consigli di performance
Gli shader girano sulla GPU, ma non sono gratis: il codice viene eseguito milioni di volte al secondo. Alcune regole pratiche:
- Limita l'area di disegno. Uno shader su tutto lo schermo costa molto più di uno shader su una card di 300x200. Applicalo solo dove serve.
- Evita i loop lunghi e i branch pesanti. Cicli con molte iterazioni (tipici degli effetti di blur o noise fatti a mano) possono far crollare i frame sui dispositivi di fascia bassa.
- Usa
precision mediump floatquando l'alta precisione non serve: su molte GPU mobile è sensibilmente più veloce. - Isola il repaint con un
RepaintBoundaryattorno alCustomPaint, così l'animazione dello shader non forza il ridisegno del resto della schermata. - Un sampler in più costa.
AnimatedSamplerimplica il rendering del sottoalbero in una texture off-screen a ogni frame: se il child è statico, valuta di renderizzarlo una volta sola in unaui.Imagee riutilizzarla. - Profila con DevTools, in particolare la timeline dei frame in modalità profile su un dispositivo reale: l'emulatore non è indicativo per il carico GPU.
Supporto e limiti attuali
- Mobile e desktop con Impeller: pieno supporto, con shader precompilati al momento della build.
- Web: gli shader funzionano con il renderer CanvasKit/skwasm; il vecchio renderer HTML non li supporta. Verifica sempre sul target reale.
- GLSL supportato: è un sottoinsieme. Non sono disponibili vertex shader personalizzati, array di uniform di dimensione dinamica,
uniformdi tipo intero o operazioni tra frame (non c'è stato persistente). Tutto ciò che ti serve va passato come uniform a ogni frame. - Debug: non esiste un debugger passo-passo. Il flusso di lavoro più produttivo è prototipare l'effetto in un editor online come Shadertoy o Book of Shaders e poi adattarlo alla sintassi Flutter (sostituendo
fragCoord/iResolutionconFlutterFragCoord()euSize).
Quando (non) usarli
Gli shader sono lo strumento giusto per gradienti animati, effetti di rivelazione, distorsioni, transizioni tra schermate e "glow" personalizzati che con i widget richiederebbero decine di layer. Non sono la scelta giusta per animazioni di layout, micro-interazioni sui pulsanti o effetti già coperti da AnimatedContainer, ShaderMask con gradiente standard o BackdropFilter: in quei casi l'API di alto livello è più leggibile, più testabile e ugualmente performante.
Usati con parsimonia, però, i fragment shader sono uno dei modi più efficaci per dare a un'app Flutter un'identità visiva che difficilmente si ottiene componendo solo widget standard.