Shader GLSL personalizzati in Flutter con FragmentProgram

Foto di Chris Appano su Unsplash

GuideAvanzato45 min Flutter 3.x (consigliata 3.19 o superiore)

Shader GLSL personalizzati in Flutter con FragmentProgram

Dalla versione 3.7 Flutter permette di compilare ed eseguire fragment shader GLSL direttamente nella pipeline di rendering, sia con Skia sia con Impeller. È lo strumento definitivo quando gradienti, BackdropFilter e CustomPainter non bastano più: effetti di dissolvenza pixel-per-pixel, distorsioni liquide, noise procedurale, transizioni cinematiche — tutto calcolato sulla GPU, senza costi sul thread UI.

In questo tutorial avanzato vedremo:

  • come dichiarare gli shader nel pubspec.yaml e quali vincoli impone Impeller;
  • la struttura di un fragment shader compatibile con Flutter (FlutterFragCoord, uniform, out vec4 fragColor);
  • come caricare un ui.FragmentProgram e disegnarlo con un CustomPainter;
  • come animarlo con un Ticker senza ricostruire l'albero dei widget;
  • come usare un widget reale come texture di input grazie a AnimatedSampler del pacchetto flutter_shaders;
  • le regole di performance, il ciclo di vita degli shader e il debug degli errori di compilazione.

Prerequisiti: buona conoscenza di CustomPainter, del ciclo di vita dei widget e almeno una infarinatura di algebra vettoriale. Non serve essere esperti di GLSL: partiamo da zero sul linguaggio, ma a ritmo sostenuto.

  1. 1

    Configurare il progetto e dichiarare gli shader nel pubspec.yaml

    Gli shader in Flutter sono asset speciali: non vanno sotto assets: ma sotto la chiave dedicata shaders:. Al momento della build, il tool li compila in Impeller Scene / SkSL bytecode (.frag → formato intermedio) e li rende caricabili a runtime.

    Crea la cartella shaders/ nella root del progetto e aggiungi la sezione al pubspec.yaml. Aggiungiamo anche flutter_shaders, che ci servirà più avanti per usare un widget come texture.

    Attenzione ai vincoli:

    • l'estensione consigliata è .frag;
    • ogni file deve contenere un solo fragment shader;
    • non sono supportati i vertex shader personalizzati;
    • con Impeller non sono ammessi cicli con numero di iterazioni non determinabile a compile-time, né array di uniform di dimensione dinamica;
    • i nomi dei file sono case-sensitive su Android/iOS.

    Dopo la modifica esegui sempre flutter pub get e un full restart (non un hot reload): la compilazione degli shader avviene in fase di build.

    name: shader_lab
    description: Laboratorio di fragment shader in Flutter
    
    environment:
      sdk: ^3.4.0
    
    dependencies:
      flutter:
        sdk: flutter
      flutter_shaders: ^0.1.3
    
    flutter:
      uses-material-design: true
    
      # Gli shader NON vanno sotto assets: hanno una sezione dedicata
      shaders:
        - shaders/wave.frag
        - shaders/dissolve.frag

    Risultato atteso

    Il progetto compila senza errori e la cartella `shaders/` viene inclusa nel bundle. Se il file non esiste, `flutter run` fallisce con un errore esplicito sull'asset mancante.

  2. 2

    Scrivere il primo fragment shader compatibile con Flutter

    Un fragment shader è una funzione eseguita per ogni pixel dell'area disegnata. Flutter impone una struttura precisa:

    1. #version 460 core come prima riga (il tool lo traduce per la piattaforma target);
    2. #include <flutter/runtime_effect.glsl>, che espone la funzione FlutterFragCoord() — usala sempre al posto di gl_FragCoord, perché tiene conto della trasformazione del canvas;
    3. le uniform dichiarate nell'ordine in cui le imposterai da Dart;
    4. una sola variabile di output: out vec4 fragColor.

    Punto cruciale: gli uniform scalari e vettoriali condividono un unico array di float. Un vec2 occupa gli indici 0 e 1, il float successivo l'indice 2, e così via. I sampler2D hanno invece una numerazione separata, gestita da setImageSampler.

    Creiamo shaders/wave.frag: un gradiente animato con onde sinusoidali e un alone radiale.

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    precision highp float;
    
    // Ordine = indici dei float lato Dart
    uniform vec2 uSize;    // indici 0 e 1
    uniform float uTime;   // indice 2
    uniform vec3 uColorA;  // indici 3, 4, 5
    uniform vec3 uColorB;  // indici 6, 7, 8
    
    out vec4 fragColor;
    
    void main() {
      // Coordinate normalizzate 0..1 rispetto all'area disegnata
      vec2 uv = FlutterFragCoord().xy / uSize;
    
      // Onde sovrapposte su due frequenze
      float wave = sin(uv.x * 8.0 + uTime * 1.5) * 0.5
                 + sin(uv.y * 12.0 - uTime * 0.9) * 0.5;
      float t = clamp(uv.y + wave * 0.12, 0.0, 1.0);
    
      vec3 color = mix(uColorA, uColorB, t);
    
      // Alone radiale al centro
      float d = distance(uv, vec2(0.5));
      color += 0.18 * smoothstep(0.6, 0.0, d);
    
      // Flutter si aspetta colori PREMOLTIPLICATI per l'alpha
      fragColor = vec4(color, 1.0);
    }

    Risultato atteso

    Il file viene compilato al prossimo `flutter run`. Un errore di sintassi GLSL blocca la build con il messaggio del compilatore e il numero di riga.

  3. 3

    Caricare il FragmentProgram e disegnarlo con un CustomPainter

    Il caricamento è asincrono: ui.FragmentProgram.fromAsset() restituisce un Future. Il risultato è cachato internamente da Flutter per path, quindi chiamarlo più volte non ricompila lo shader, ma conviene comunque caricarlo una sola volta a livello di app o di feature.

    Da un FragmentProgram si ottiene un ui.FragmentShader con program.fragmentShader(). Questo oggetto è mutabile e stateful: contiene i valori degli uniform. Creane uno per ogni superficie animata e riutilizzalo, invece di ricrearlo a ogni frame.

    Regola fondamentale: tutti gli uniform dichiarati devono essere impostati prima del drawRect, altrimenti il comportamento è indefinito (spesso pixel neri o artefatti).

    Nel painter usiamo Paint()..shader = shader e copriamo l'intera area con drawRect. Il shouldRepaint confronta il tempo, così ridisegniamo solo quando serve.

    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    
    class WavePainter extends CustomPainter {
      WavePainter({
        required this.shader,
        required this.time,
        required this.colorA,
        required this.colorB,
      });
    
      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)
          ..setFloat(1, size.height)
          ..setFloat(2, time)
          ..setFloat(3, colorA.red / 255.0)
          ..setFloat(4, colorA.green / 255.0)
          ..setFloat(5, colorA.blue / 255.0)
          ..setFloat(6, colorB.red / 255.0)
          ..setFloat(7, colorB.green / 255.0)
          ..setFloat(8, colorB.blue / 255.0);
    
        canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
      }
    
      @override
      bool shouldRepaint(covariant WavePainter oldDelegate) =>
          oldDelegate.time != time ||
          oldDelegate.colorA != colorA ||
          oldDelegate.colorB != colorB;
    }
    
    /// Caricamento una tantum, riutilizzabile in tutta l'app.
    class ShaderCache {
      ShaderCache._();
      static final Map<String, Future<ui.FragmentProgram>> _programs = {};
    
      static Future<ui.FragmentProgram> load(String asset) =>
          _programs.putIfAbsent(asset, () => ui.FragmentProgram.fromAsset(asset));
    }

    Risultato atteso

    Il painter è pronto: dato uno `ui.FragmentShader` e un valore di tempo, dipinge il gradiente ondulato sull'intera area del `CustomPaint`.

  4. 4

    Animare lo shader con un Ticker (senza rebuild dell'albero)

    Per animare uno shader non serve un AnimationController con setState: basta un Ticker che aggiorna un ValueNotifier<double> con i secondi trascorsi. Passando il notifier a CustomPaint tramite repaint: (secondo il pattern CustomPainter(repaint: listenable)), Flutter ripassa dalla fase di paint senza rieseguire build né il layout: è la strada più efficiente.

    Qui il painter estende CustomPainter con super(repaint: time) e legge il valore direttamente dal notifier. Il risultato: 60/120 fps con zero rebuild.

    Ricorda inoltre di chiamare shader.dispose() e ticker.dispose() in dispose(): FragmentShader alloca risorse native che il GC di Dart non libera immediatamente.

    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    import 'package:flutter/scheduler.dart';
    
    class AnimatedWave extends StatefulWidget {
      const AnimatedWave({super.key});
      @override
      State<AnimatedWave> createState() => _AnimatedWaveState();
    }
    
    class _AnimatedWaveState extends State<AnimatedWave>
        with SingleTickerProviderStateMixin {
      final ValueNotifier<double> _time = ValueNotifier(0);
      late final Ticker _ticker;
      ui.FragmentShader? _shader;
    
      @override
      void initState() {
        super.initState();
        _ticker = createTicker((elapsed) {
          _time.value = elapsed.inMicroseconds / 1e6;
        });
        _init();
      }
    
      Future<void> _init() async {
        final program = await ShaderCache.load('shaders/wave.frag');
        if (!mounted) return;
        setState(() => _shader = program.fragmentShader());
        _ticker.start();
      }
    
      @override
      void dispose() {
        _ticker.dispose();
        _shader?.dispose();
        _time.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        final shader = _shader;
        if (shader == null) {
          return const ColoredBox(color: Colors.black12);
        }
        return RepaintBoundary(
          child: CustomPaint(
            painter: _WaveTickerPainter(
              shader: shader,
              time: _time,
              colorA: const Color(0xFF1A237E),
              colorB: const Color(0xFF00BCD4),
            ),
            size: Size.infinite,
          ),
        );
      }
    }
    
    class _WaveTickerPainter extends CustomPainter {
      _WaveTickerPainter({
        required this.shader,
        required this.time,
        required this.colorA,
        required this.colorB,
      }) : super(repaint: time);
    
      final ui.FragmentShader shader;
      final ValueNotifier<double> time;
      final Color colorA;
      final Color colorB;
    
      @override
      void paint(Canvas canvas, Size size) {
        shader
          ..setFloat(0, size.width)
          ..setFloat(1, size.height)
          ..setFloat(2, time.value)
          ..setFloat(3, colorA.red / 255.0)
          ..setFloat(4, colorA.green / 255.0)
          ..setFloat(5, colorA.blue / 255.0)
          ..setFloat(6, colorB.red / 255.0)
          ..setFloat(7, colorB.green / 255.0)
          ..setFloat(8, colorB.blue / 255.0);
        canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
      }
    
      @override
      bool shouldRepaint(covariant _WaveTickerPainter old) =>
          old.shader != shader || old.colorA != colorA || old.colorB != colorB;
    }

    Risultato atteso

    Uno sfondo animato fluido: le onde scorrono a 60 fps e nel DevTools Performance non compaiono rebuild del widget, solo operazioni di paint.

  5. 5

    Usare un widget reale come texture con sampler2D e AnimatedSampler

    Il salto di qualità arriva quando lo shader riceve in input il rendering di un widget. Il pacchetto flutter_shaders mette a disposizione AnimatedSampler: cattura il sottoalbero in una ui.Image (via layer offscreen) e ti passa image, size e canvas per disegnarlo come preferisci.

    Creiamo un effetto dissolve con noise: i pixel scompaiono in ordine pseudo-casuale a blocchi, come una disintegrazione.

    Nota su alpha: Flutter lavora con colori premoltiplicati, quindi per far svanire un pixel devi moltiplicare l'intero vec4 (RGB inclusi) per il fattore di trasparenza, non solo la componente a.

    Due accortezze pratiche:

    • setImageSampler usa una numerazione indipendente dagli indici float (il primo sampler2D è l'indice 0);
    • AnimatedSampler forza un layer offscreen: usalo su porzioni contenute della UI, non sull'intera schermata se puoi evitarlo.
    // shaders/dissolve.frag
    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    precision mediump float;
    
    uniform vec2 uSize;       // 0, 1
    uniform float uProgress;  // 2  (0 = intatto, 1 = dissolto)
    uniform sampler2D uTexture; // sampler index 0
    
    out vec4 fragColor;
    
    float hash(vec2 p) {
      return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
    }
    
    void main() {
      vec2 fragCoord = FlutterFragCoord().xy;
      vec2 uv = fragCoord / uSize;
    
      vec4 color = texture(uTexture, uv);
    
      // Noise a blocchi di 5px per un effetto "granulare"
      float noise = hash(floor(fragCoord / 5.0));
    
      // Bordo incandescente sulla soglia di dissolvenza
      float edge = smoothstep(uProgress, uProgress + 0.08, noise);
      float glow = (1.0 - edge) * step(uProgress - 0.08, noise);
    
      vec4 burn = vec4(1.0, 0.45, 0.1, 1.0) * glow * color.a;
    
      // Colore premoltiplicato: moltiplico tutto il vec4
      fragColor = color * edge + burn;
    }

    Risultato atteso

    Il file `dissolve.frag` compila e dichiara un sampler pronto a ricevere la texture del widget.

  6. 6

    Collegare lo shader dissolve a un widget con AnimatedSampler

    Ora avvolgiamo un widget qualsiasi (una Card, una foto, una lista) nel nostro effetto. ShaderBuilder di flutter_shaders si occupa del caricamento asincrono del programma e della dispose dello shader; AnimatedSampler fornisce la texture.

    Il progress arriva da un AnimationController: poiché il callback di AnimatedSampler viene invocato in fase di paint, un AnimatedBuilder che ricostruisce solo il wrapper è più che sufficiente.

    Un dettaglio spesso trascurato: quando progress == 0 conviene bypassare lo shader e restituire direttamente il child. Eviti il layer offscreen nello stato di riposo, che è quello in cui il widget passa il 99% del tempo.

    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    import 'package:flutter_shaders/flutter_shaders.dart';
    
    class DissolveEffect extends StatelessWidget {
      const DissolveEffect({
        super.key,
        required this.progress,
        required this.child,
      });
    
      final double progress; // 0.0 .. 1.0
      final Widget child;
    
      @override
      Widget build(BuildContext context) {
        if (progress <= 0.001) return child; // nessun costo a riposo
    
        return ShaderBuilder(
          assetKey: 'shaders/dissolve.frag',
          (context, shader, _) {
            return AnimatedSampler(
              (ui.Image image, Size size, Canvas canvas) {
                shader
                  ..setFloat(0, size.width)
                  ..setFloat(1, size.height)
                  ..setFloat(2, progress)
                  ..setImageSampler(0, image);
                canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
              },
              child: child,
            );
          },
          child: child,
        );
      }
    }
    
    // Utilizzo tipico
    class DissolveDemo extends StatefulWidget {
      const DissolveDemo({super.key});
      @override
      State<DissolveDemo> createState() => _DissolveDemoState();
    }
    
    class _DissolveDemoState extends State<DissolveDemo>
        with SingleTickerProviderStateMixin {
      late final AnimationController _c = AnimationController(
        vsync: this,
        duration: const Duration(milliseconds: 900),
      );
    
      @override
      void dispose() {
        _c.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Center(
          child: GestureDetector(
            onTap: () => _c.isCompleted ? _c.reverse() : _c.forward(),
            child: AnimatedBuilder(
              animation: _c,
              builder: (context, child) =>
                  DissolveEffect(progress: _c.value, child: child!),
              child: const Card(
                elevation: 4,
                child: Padding(
                  padding: EdgeInsets.all(24),
                  child: Text('Toccami per dissolvermi',
                      style: TextStyle(fontSize: 20)),
                ),
              ),
            ),
          ),
        );
      }
    }

    Risultato atteso

    Toccando la card, questa si disintegra in granuli con un bordo arancione incandescente e ricompare al tocco successivo, mantenendo i 60 fps.

  7. 7

    Performance, Impeller e debug: le regole da rispettare

    Gli shader girano sulla GPU, ma non sono gratis. Ecco la checklist che uso in produzione.

    1. Compilazione e jank iniziale. Con Skia il primo frame che usa uno shader può causare un micro-jank per la compilazione runtime. Con Impeller (default su iOS e Android moderni) il problema è in gran parte risolto perché la traduzione avviene in fase di build. Se sei ancora su Skia, "scalda" lo shader disegnandolo in un Offstage di 1×1 pixel all'avvio.

    2. Riusa lo ui.FragmentShader. Creane uno per superficie e aggiorna solo gli uniform. Crearne uno per frame genera allocazioni native e pressione sul GC.

    3. Libera le risorse. shader.dispose() in dispose(). ShaderBuilder lo fa per te, il codice manuale no.

    4. Isola i repaint. Avvolgi sempre l'area shaderizzata in un RepaintBoundary: eviti che il repaint continuo dell'animazione invalidi il layer del resto della schermata.

    5. Occhio alla complessità per-pixel. Il costo è larghezza × altezza × costo_del_main(). Un for con 64 iterazioni su un'area full-screen a 3x di device pixel ratio è una ricetta per il thermal throttling. Preferisci funzioni analitiche a loop, e mediump quando la precisione alta non serve.

    6. Limiti di Impeller. Niente loop con bound dinamici, niente ricorsione, niente while non risolvibili a compile-time. Su Flutter Web il supporto dei fragment shader esiste ma va verificato sul renderer in uso (CanvasKit/Skwasm): prevedi sempre un fallback grafico.

    7. Debug. Un errore di sintassi emerge in build. Se invece vedi pixel neri o trasparenti: hai quasi certamente dimenticato un setFloat o sbagliato l'indice. Tecnica rapida: sostituisci il corpo di main() con fragColor = vec4(uv, 0.0, 1.0); per verificare che le coordinate arrivino corrette.

    8. Golden test. Gli shader possono rendere non deterministici i golden test: isolali dietro un flag e disattivali nei test di regressione visiva.

    Il codice qui sotto mostra il warm-up e un widget di fallback difensivo.

    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    
    /// Precarica e "scalda" lo shader disegnandolo su una superficie minima.
    Future<void> warmUpShader(String asset) async {
      final program = await ui.FragmentProgram.fromAsset(asset);
      final shader = program.fragmentShader()
        ..setFloat(0, 1)
        ..setFloat(1, 1)
        ..setFloat(2, 0);
    
      final recorder = ui.PictureRecorder();
      final canvas = Canvas(recorder);
      canvas.drawRect(
        const Rect.fromLTWH(0, 0, 1, 1),
        Paint()..shader = shader,
      );
      final picture = recorder.endRecording();
      // Forza il rasterizzatore a processare davvero il comando
      await picture.toImage(1, 1);
      picture.dispose();
      shader.dispose();
    }
    
    /// Wrapper difensivo: se lo shader non è disponibile mostra il child intatto.
    class SafeShaderEffect extends StatelessWidget {
      const SafeShaderEffect({
        super.key,
        required this.future,
        required this.builder,
        required this.child,
      });
    
      final Future<ui.FragmentProgram> future;
      final Widget Function(ui.FragmentShader shader, Widget child) builder;
      final Widget child;
    
      @override
      Widget build(BuildContext context) {
        return FutureBuilder<ui.FragmentProgram>(
          future: future,
          builder: (context, snapshot) {
            if (snapshot.hasError || !snapshot.hasData) return child;
            return builder(snapshot.data!.fragmentShader(), child);
          },
        );
      }
    }
    
    // In main(): await warmUpShader('shaders/wave.frag');

    Risultato atteso

    Nessun jank al primo utilizzo dello shader, memoria nativa stabile nel tempo e degradazione elegante della UI sulle piattaforme in cui lo shader non è supportato.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!