GPU Rendering in Flutter: Custom Shaders and Canvas
A code-first guide to GPU rendering in Flutter: painting with CustomPainter and the Canvas API, then writing GLSL fragment shaders and driving them from Dart with FragmentProgram.fromAsset, FragmentShader uniforms, and Float32List under the Impeller engine.
Published on • August 18, 2026
AI Assistant

Flutter already renders every frame on the GPU. The Canvas API lets you describe drawing operations, and the framework hands them to a low-level engine that rasterizes them with hardware acceleration. But most apps stop at gradients, shadows, and ColorFilter objects. When you need an effect the SDK does not ship - a particle field, a distortion pass, a heat map, a per-pixel bloom - the escape hatch is a custom fragment shader: a GLSL program compiled by Flutter and executed directly on the GPU, one invocation per covered pixel.
This post is code-centric. You will build a CustomPainter that draws with the Canvas API, author a GLSL fragment shader in a .frag file, load it at runtime with FragmentProgram.fromAsset, feed it parameters through FragmentShader uniforms backed by Float32List, and render the result through the Impeller rendering engine. By the end you will have a working, animated shader-backed widget and a mental model of how every piece connects.
Prerequisites
- Flutter 3.10+ (Impeller is the default on iOS and Android since 3.16, but the
FragmentProgramAPI works on Skia too) - A project created with
flutter createand thedart:uiimport available - Basic familiarity with GLSL and the
vec2/vec3/vec4types
Painting with the Canvas API
Every custom drawing in Flutter starts with CustomPainter. Your paint method receives a Canvas - an interface for recording graphical operations - plus a Size. The Canvas keeps a transformation matrix (modified by translate, scale, rotate, skew) and a clip region (clipRect, clipPath), both saveable and restorable with save() and restore().
Here is a painter that builds a starfield entirely from Canvas primitives, no shader involved yet:
import 'dart:math';
import 'dart:ui';
class StarfieldPainter extends CustomPainter {
StarfieldPainter(this.t);
final double t;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = const Color(0xFF10213B);
canvas.drawRect(Offset.zero & size, paint);
final starPaint = Paint()..color = Colors.amberAccent;
final random = Random(42);
for (var i = 0; i < 120; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final drift = (t * 0.5 + i * 0.01) % 1.0;
final radius = 1.0 + drift * 2.5;
canvas.drawCircle(Offset(x, y), radius, starPaint);
}
}
@override
bool shouldRepaint(covariant StarfieldPainter oldDelegate) => oldDelegate.t != t;
}
Every one of those 120 circles is a separate draw call issued to the GPU. For a static starfield that is fine; for thousands of particles per frame it becomes the bottleneck. The entire point of a fragment shader is to replace thousands of draw calls with a single rectangle that the GPU fills in one pass.
Authoring a GLSL fragment shader
Flutter does not support vertex shaders. You author fragment shaders as GLSL source files that, by convention, end in .frag. Any GLSL version from 100 up to 460 core is accepted; Flutter restricts some features (no UBOs or SSBOs, sampler2D is the only sampler type, no additional varying inputs, no unsigned integers or booleans). The shader imports flutter/runtime_effect.glsl to get FlutterFragCoord(), which returns the local coordinates of the fragment being evaluated - the portability-safe replacement for gl_FragCoord.
This shader renders a radial glow with a moving pulse, driven by three uniforms:
#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize;
uniform vec2 uCenter;
uniform float uTime;
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
float d = distance(uv, uCenter);
float pulse = 0.5 + 0.5 * sin(uTime * 2.0 - d * 18.0);
vec3 base = vec3(0.05, 0.12, 0.28);
vec3 glow = vec3(0.10, 0.85, 1.00) * pulse * exp(-d * 6.0);
fragColor = vec4(base + glow, 1.0);
}
Note the output contract: fragColor must be a normalized color in the range 0.0 to 1.0 with premultiplied alpha - not the 0-255 unpremultiplied values Flutter uses elsewhere in Dart. Colors have no built-in GLSL type; they are plain vec4 values.
Registering and loading the shader
Shaders must be declared in the shaders section of pubspec.yaml. The Flutter tool compiles the GLSL to each backend’s format (SPIR-V for Impeller on Vulkan, Metal shaders, GLSL for OpenGL ES) and bundles it like an asset:
flutter:
shaders:
- shaders/radial_glow.frag
In debug mode, edits to the .frag file trigger recompilation during hot reload or hot restart. At runtime you load the compiled program with FragmentProgram.fromAsset, then create shader instances from it:
import 'dart:ui';
Future<FragmentProgram> loadProgram() {
return FragmentProgram.fromAsset('shaders/radial_glow.frag');
}
A FragmentProgram is the compiled pipeline. A FragmentShader is that program bound to a concrete set of uniform values - you can create many from one program, each with different parameters.
Setting uniforms from Dart
Uniforms are the shader’s configuration parameters. Float uniforms of type float, vec2, vec3, and vec4 are set with FragmentShader.setFloat, one call per component, in the order the uniforms are declared in the GLSL source. For our shader: uSize occupies slots 0 and 1, uCenter slots 2 and 3, uTime slot 4. Any uniform you leave unset defaults to 0.0.
class ShaderScenePainter extends CustomPainter {
ShaderScenePainter(this.shader, this.time);
final FragmentShader shader;
final double time;
@override
void paint(Canvas canvas, Size size) {
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
shader.setFloat(2, 0.5);
shader.setFloat(3, 0.5);
shader.setFloat(4, time);
final paint = Paint()..shader = shader;
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
}
@override
bool shouldRepaint(covariant ShaderScenePainter oldDelegate) =>
oldDelegate.time != time;
}
Because a FragmentShader wraps its uniforms in typed buffers, setting them through setFloat writes into the backing Float32List held by the engine; the shader sees those values on the next draw. Samplers (sampler2D) are a separate concern and are bound with setImageSampler, whose indices restart at 0 and do not count against the float slots. The ImageFilter API also accepts custom shaders through ImageFilter.shader, but that path is Impeller-only and injects the input image as the sampler at index 0 plus the image dimensions as floats at indices 0 and 1 automatically.
Wiring it into a widget
Now assemble everything. FragmentProgram.fromAsset is async, so load it once (ideally in initState), then drive a CustomPaint with a repeating AnimationController to feed time into the painter:
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'dart:math' as math;
class GlowScene extends StatefulWidget {
const GlowScene({super.key});
@override
State<GlowScene> createState() => _GlowSceneState();
}
class _GlowSceneState extends State<GlowScene>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
ui.FragmentProgram? _program;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
)..repeat();
_load();
}
Future<void> _load() async {
final program = await ui.FragmentProgram.fromAsset('shaders/radial_glow.frag');
if (!mounted) return;
setState(() => _program = program);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final program = _program;
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
if (program == null) {
return const SizedBox.expand(child: Center(child: CircularProgressIndicator()));
}
return CustomPaint(
painter: ShaderScenePainter(
program.fragmentShader(),
_controller.value * math.pi * 4,
),
child: const SizedBox.expand(),
);
},
);
}
}
program.fragmentShader() returns a fresh FragmentShader bound to the program. If you create one per frame, remember the docs’ advice: reuse a single FragmentShader across frames where possible, since allocating one every frame adds overhead. And on the Skia backend, shader compilation happens lazily at runtime - precache your programs before an animation starts to avoid first-frame hitches.
Putting It All Together
The full pipeline is: declare the .frag file in pubspec.yaml under flutter.shaders; load it once with FragmentProgram.fromAsset; obtain a FragmentShader from the program; set float uniforms in declaration order with setFloat (each vecN gets N calls); assign it to Paint().shader; and draw a rectangle covering the area you want shaded. The CustomPainter gives you the Canvas, and the Canvas gives you the draw call. Under the hood, Impeller compiles your GLSL to the platform’s native shader format and issues a single fill whose fragments your code colors on the GPU.
For state changes - time, pointer position, a tapped center - store the values on the painter and return true from shouldRepaint so Flutter re-rasterizes. uSize should always be passed so the shader can normalize FlutterFragCoord() into UV space instead of assuming a fixed resolution, which keeps the effect device-independent.
Conclusion & Next Steps
You now know the three layers: CustomPainter + Canvas for drawing commands, GLSL .frag files for per-pixel programs, and FragmentProgram.fromAsset + FragmentShader.setFloat for loading and parameterizing them. A single shader-backed rectangle replaces hundreds of draw calls and opens effects - plasma, heat maps, distorted text, fluid sims - that are impractical to express as primitives.
Next steps: sample a dart:ui Image inside a shader with a sampler2D uniform bound via setImageSampler; experiment with ImageFilter.shader and BackdropFilter to post-process live content (Impeller only); and read the “Writing efficient shaders” guide in the Flutter engine docs before shipping effects to low-end devices. The Book of Shaders and Shadertoy are excellent sources of shader techniques to port into Flutter’s GLSL dialect.