Building 60fps High-Performance GPU Graphics Animations with Flutter CustomPainter & Canvas API

Flutter CustomPainter & Canvas API: Master 60fps High-Performance GPU Animations
When building complex dynamic charts, audio visualizers, particle systems, or real-time map overlays in Flutter, instantiating hundreds of standard Widgets often leads to severe UI jank due to CPU layout overhead.
The definitive solution is CustomPainter combined with the Canvas API. Paired with Flutter’s Impeller GPU rendering engine, this bypasses heavy Widget tree rebuilds and renders graphics directly on the GPU at smooth 60fps or 120fps.
This guide explores the CustomPainter rendering pipeline, layer isolation with RepaintBoundary, and a complete 60fps particle animation implementation.
1. Widget Tree vs CustomPainter Rendering Architecture
| Metric / Aspect | Standard Widget Composition | CustomPainter & Canvas API |
|---|---|---|
| Rendering Pipeline | Widget Tree ➡️ RenderObject ➡️ Layer Split | Single RenderCustomPaint ➡️ Direct Canvas Drawing |
| 1,000 Elements Load | Allocates 1,000 Element/RenderObject instances | 1 Painter executing GPU canvas commands in a loop |
| CPU vs GPU Overhead | High CPU layout/rebuild cost | Accelerated GPU rasterization |
| Use Cases | Forms, lists, static UI elements | Particle systems, real-time charts, games |
2. 3 Core Performance Rules for 60fps Animations
Rule 1: Strict shouldRepaint Control
The shouldRepaint(covariant CustomPainter oldDelegate) method determines whether to redraw the canvas. Avoid returning true unconditionally unless properties truly change across frames.
Rule 2: Render Layer Isolation with RepaintBoundary
Wrapping your CustomPaint widget inside a RepaintBoundary isolates its painting layer from parent and sibling widgets, preventing cascading paint invalidations.
RepaintBoundary(
child: CustomPaint(
painter: ParticlePainter(particles: _particles),
size: Size.infinite,
),
)
Rule 3: Pass AnimationController to CustomPainter’s repaint Parameter
Instead of calling setState() and rebuilding the whole widget tree, wire the AnimationController directly into the repaint: Listenable parameter of the CustomPainter constructor. The Listenable you pass to super(repaint: repaint) automatically triggers paint() on every change, so only the canvas layer redraws on each tick — no rebuild required. (Note that CustomPaint’s willChange is just a plain bool hint for the raster cache; it does not accept a Listenable.)
3. Production 60fps GPU Particle Animation Code
Here is a complete, runnable Flutter sample animating 150 particles on a GPU canvas at 60fps:
import 'dart:math';
import 'package:flutter/material.dart';
class Particle {
double x, y, dx, dy, radius;
Color color;
Particle({
required this.x,
required this.y,
required this.dx,
required this.dy,
required this.radius,
required this.color,
});
void update(Size size) {
x += dx;
y += dy;
if (x < 0 || x > size.width) dx = -dx;
if (y < 0 || y > size.height) dy = -dy;
}
}
class ParticleCanvasScreen extends StatefulWidget {
const ParticleCanvasScreen({super.key});
@override
State<ParticleCanvasScreen> createState() => _ParticleCanvasScreenState();
}
class _ParticleCanvasScreenState extends State<ParticleCanvasScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
final List<Particle> _particles = [];
final Random _random = Random();
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
for (int i = 0; i < 150; i++) {
_particles.add(Particle(
x: _random.nextDouble() * 400,
y: _random.nextDouble() * 800,
dx: (_random.nextDouble() - 0.5) * 3,
dy: (_random.nextDouble() - 0.5) * 3,
radius: _random.nextDouble() * 4 + 2,
color: Colors.indigoAccent.withValues(alpha: _random.nextDouble() * 0.7 + 0.3),
));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
body: RepaintBoundary(
child: CustomPaint(
painter: ParticlePainter(particles: _particles, repaint: _controller),
size: Size.infinite,
),
),
);
}
}
class ParticlePainter extends CustomPainter {
final List<Particle> particles;
ParticlePainter({required this.particles, required Listenable repaint})
: super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (var particle in particles) {
particle.update(size);
paint.color = particle.color;
canvas.drawCircle(Offset(particle.x, particle.y), particle.radius, paint);
}
}
@override
bool shouldRepaint(covariant ParticlePainter oldDelegate) => true;
}
4. Summary & Profiling Tips
- Use
CustomPainterandCanvas APIwhen drawing large volumes of dynamic graphical elements. - Isolate paint layers with
RepaintBoundaryto prevent unnecessary subtree repaints. - Monitor raster times using Flutter DevTools Performance Overlay to keep frame render times below 16.6ms (60fps).