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 .frag e usa GLSL ES 3.0 (#version 460 core nella sintassi accettata dal compilatore di Flutter);
  • deve includere flutter/runtime_effect.glsl, che fornisce la funzione FlutterFragCoord();
  • 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 offre setFloatUniforms con 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 float quando l'alta precisione non serve: su molte GPU mobile è sensibilmente più veloce.
  • Isola il repaint con un RepaintBoundary attorno al CustomPaint, così l'animazione dello shader non forza il ridisegno del resto della schermata.
  • Un sampler in più costa. AnimatedSampler implica il rendering del sottoalbero in una texture off-screen a ogni frame: se il child è statico, valuta di renderizzarlo una volta sola in una ui.Image e 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, uniform di 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/iResolution con FlutterFragCoord() e uSize).

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.