Dart Flutter Article

High-Performance Canvas Rendering

Efficient rendering strategies for complex, interactive scenes using Flutter Canvas, GPU batching, spatial indexing, and debugging techniques.
Plague Fox 20 min read
High-Performance Canvas Rendering
Photo by Steve Johnson / Unsplash

Rendering interactive and visually rich scenes in Flutter can quickly escalate in complexity and computational demand, especially when dealing with numerous dynamic objects, intricate animations, and real-time interactions. While Flutter’s built-in canvas API offers a powerful toolkit, fully unlocking its potential requires an in-depth understanding of efficient rendering practices, optimization techniques, and proper spatial data management.

This comprehensive guide explores advanced yet practical approaches to building high-performance canvas-based Flutter applications. By strategically combining optimized rendering patterns, intelligent state management, GPU acceleration, spatial structures, and robust debugging strategies, you can create smooth, responsive, and visually appealing scenes—even at large scales.

What You'll Learn in This Guide

  • Efficient Canvas Initialization and Management:
    How to choose the appropriate rendering strategy (CustomPaint, custom RenderObject, or LeafRenderObjectWidget) based on your scenario, ensuring optimal performance and maintainability.
  • Advanced Camera and Viewport Techniques:
    Implementing a flexible, high-performance camera system that efficiently manages large global spaces, supports zoom, and simplifies coordinate transformations.
  • Batching GPU Commands and Eliminating Draw Loops:
    Understanding the cost of drawing loops and how to leverage Flutter’s batched rendering methods (drawRawAtlas, drawRawPoints, drawVertices) to drastically improve performance.
  • Optimizing Spatial Queries with QuadTrees:
    Efficient collision detection, spatial indexing, and viewport culling using a QuadTree data structure, significantly improving scene scalability and responsiveness.
  • GPU Shaders and Paint Optimization:
    Leveraging GPU-accelerated shaders, properly configuring Paint objects, and understanding anti-aliasing and filtering strategies to achieve both visual quality and blazing-fast rendering.
  • Effective Caching Using Picture and Rasterization:
    Leveraging Flutter’s PictureRecorder to cache static or infrequently changing portions of your scene, substantially reducing unnecessary redraw overhead.
  • Robust Debugging and Performance Monitoring:
    Building a comprehensive debug overlay layer, activated via hotkeys (e.g., F2), providing real-time metrics (FPS, camera info, QuadTree states, DB stats), and enhancing the development and troubleshooting process.

By applying these strategies, you’ll ensure that your Flutter canvas application is robust, maintainable, and highly performant—even as complexity grows. Whether you're creating games, visualizations, or interactive tools, mastering these techniques will help you push Flutter’s canvas rendering capabilities to their maximum potential.

This guide is intended for intermediate-to-advanced Flutter developers looking to deeply optimize and scale canvas-based applications. Prior familiarity with Flutter’s widget lifecycle, rendering pipeline, and basic canvas operations will greatly enhance your understanding and effectiveness.


Choosing the Right Render Object

When dealing with complex or highly dynamic scenes in Flutter, efficient rendering is critical for smooth performance. While the standard CustomPaint widget might suffice for simpler use cases, a truly complex or performance-intensive scenario often calls for a more optimized solution. Let's explore several strategies and considerations for creating effective, high-performance canvas rendering setups in Flutter.

Flutter provides several built-in widgets and mechanisms to handle canvas drawing. Here's a quick comparison of available options:

Widget / Approach Complexity Performance Use Case
CustomPaint Easy Good Simple-to-moderate complexity
LeafRenderObjectWidget Advanced Excellent Complex scenes, precise control
RePaint package Medium Excellent Optimized custom repaint logic

Using

The simplest and most straightforward method is to use Flutter’s built-in CustomPaint widget:

dartCopyEditCustomPaint(
  painter: MyCanvasPainter(),
);

However, as your canvas rendering logic grows more intricate or performance-sensitive, this method can become insufficient due to unnecessary widget tree rebuilds and repainting.

Leveraging a Custom RenderObject with LeafRenderObjectWidget

When the complexity or performance demand increases, you should consider creating your own render object, typically extending LeafRenderObjectWidget. A custom render object gives you lower-level control over painting, layout, and lifecycle management:

class CustomRenderBoxWidget extends LeafRenderObjectWidget {
  // ...
  
  @override
  RenderObject createRenderObject(BuildContext context) {
    final box = CustomRenderBox();
    // ...
    return box;
  }
}

class CustomRenderBox extends RenderBox with WidgetsBindingObserver {

  // ...

  /// Vsync loop ticker.
  Ticker? _ticker;
  
  @override
  bool get isRepaintBoundary => true;

  @override
  bool get alwaysNeedsCompositing => false;

  @override
  bool get sizedByParent => true;
  
  @override
  Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
  
  void _onTick(Duration elapsed) {
    // ...
    markNeedsPaint();
  }
  
  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    WidgetsBinding.instance.addObserver(this);
    _ticker = Ticker(_onTick, debugLabel: 'CustomRenderBox')..start();
    // ...
  }
  
  @override
  void detach() {
    _ticker?.dispose();
    WidgetsBinding.instance.removeObserver(this);
    super.detach();
    // ...
  }
  
  @override
  void paint(PaintingContext context, Offset offset) {
    // ...
  }
}

This approach eliminates overhead associated with widget rebuilds and grants more granular control over rendering, ideal for scenarios involving dynamic, complex scenes.

Using the RePaint Package

Alternatively, my own repaint package provides utilities optimized specifically for fine-grained control over repaint logic, which may significantly improve performance.

RePaint class - repaint library - Dart API
API docs for the RePaint class from the repaint library, for the Dart programming language.
final _painter = MyGamePainter();

@override
Widget build(BuildContext context) => RePaint(
    painter: _painter,
  );
  
// ...

class MyGamePainter extends RePainterBase {
  // ...
}

Optimizing Repaints with Repaint Boundaries

If your canvas is frequently updated independently of the widget tree around it, you can significantly improve performance by isolating your widget’s repaint logic:

  • Wrap your CustomPaint widget inside a RepaintBoundary.
  • Or, if using a custom RenderObject, set the property isRepaintBoundary to true.
RepaintBoundary(
  child: CustomPaint(
    painter: MyFrequentlyUpdatedPainter(),
  ),
)

While this approach can significantly boost performance by isolating rendering operations and reducing the frequency and area of repaints, it introduces additional overhead in memory and GPU usage. Therefore, measure performance carefully to ensure the trade-off is justified.

⚠️ Avoid Wrapping Canvas Widgets in Builders or Animations

A common mistake when working with Flutter’s canvas involves wrapping canvas widgets (CustomPaint) inside builders, animation widgets, or calling setState() frequently. While this approach may seem intuitive, it introduces unnecessary widget tree rebuilds, significantly hurting performance—especially for dynamic or complex scenes.

Here's what 🚫 not to do 🚫:

AnimatedBuilder(
  animation: animationController,
  builder: (context, _) =>
      CustomPaint(painter: MyPainter()),
);

or

BlocBuilder(
  bloc: bloc,
  builder: (context, state) =>
      CustomPaint(painter: MyPainter()),
);

or

context.watch<T>();
return CustomPaint(painter: MyPainter());

Instead, leverage Flutter’s built-in optimization by providing a Listenable (e.g., AnimationController, ChangeNotifier) directly to the repaint parameter of your CustomPainter.

Correct Example:

final animationController = AnimationController(
  vsync: this,
  duration: const Duration(seconds: 1),
)..repeat();

CustomPaint(
  // Pass as repaint parameter
  painter: MyPainter(repaint: animationController), 
);

Inside your painter:

class MyPainter extends CustomPainter {
  MyPainter({required super.repaint});

  @override
  void paint(Canvas canvas, Size size) {
    // painting logic here
  }

  @override
  bool shouldRepaint(covariant MyPainter oldDelegate) => false;
}
  • Flutter efficiently schedules repaints without unnecessary widget rebuilds.
  • Avoids the performance overhead associated with setState() and unnecessary widget tree recalculations.

If you're using custom RenderObject implementations instead of CustomPainter, avoid manually triggering setState() or rebuilding widgets unnecessarily.

Instead, explicitly call:

renderObject.markNeedsPaint();

after relevant state changes (like camera movement or scene updates). This triggers a repaint efficiently at the render object level without involving Flutter’s widget rebuilding mechanism.

🚫 Avoid this... ✅ Prefer this instead...
Frequent calls to setState() for canvas updates Use a Listenable repaint parameter.
Wrapping canvas in AnimatedBuilder or builders Directly trigger markNeedsPaint() or repaint.
Unnecessary widget tree updates Efficiently listen and trigger repaint only.

Drawing Basic Shapes on Canvas

Once you've obtained access to the Canvas and know its dimensions, you're ready to start rendering basic visual elements. Let's explore essential techniques like drawing shapes and clipping the canvas to create performant and visually appealing renderings.

The most straightforward way to start drawing is by filling your canvas with a background color, then adding simple geometric shapes such as rectangles and circles.

Here's a clear example illustrating this:

final localBounds = Offset.zero & size;
final paint = Paint()..style = PaintingStyle.fill;

canvas
  // Canvas background color
  ..drawPaint(paint..color = const ui.Color(0xFF00AAFF))
  // Rectangle
  ..drawRect(
    localBounds.deflate(16), 
    paint..color = const ui.Color(0xFF00FF1A),
  )
  // Circle
  ..drawCircle(
    localBounds.center, 
    localBounds.longestSide / 4, 
    paint..color = const ui.Color(0xFFFF0000),
  );

Explanation:

  • drawPaint fills the entire canvas area with a specified color.
  • drawRect draws a rectangle that is slightly inset (deflate(16) reduces dimensions equally from all sides).
  • drawCircle creates a circle at the canvas center (localBounds.center) with a radius equal to a quarter of the canvas's longest dimension.

Clipping Canvas to Prevent Overflow

Objects you draw may occasionally overflow your canvas area, potentially causing unexpected visual artifacts. To avoid this, you can clip your drawing area explicitly.

The simplest clipping method uses a rectangular boundary:

context.canvas
  ..save() // Save current state
  ..clipRect(Offset.zero & size); // Define clipping bounds

// Your drawing logic here

context.canvas.restore(); // Restore to original state
  • Important: Always use canvas.save() and canvas.restore() to ensure clipping affects only intended drawing operations.

You're not limited to rectangular clipping—Flutter supports complex clipping paths, allowing you to clip drawing operations to circles, ovals, or custom shapes.

For instance, clipping to an oval shape:

final localBounds = Offset.zero & size;

canvas
  ..save()
  ..clipPath(Path()..addOval(localBounds)); // Clip using an oval path

// Your drawing logic here

canvas.restore();

Camera System for Canvas

When developing complex visualizations, games, or interactive scenes on a canvas in Flutter, you'll frequently encounter situations where your drawable scene significantly exceeds the viewport dimensions (screen size). To handle this gracefully, you need a camera system that manages navigation across your global scene, including position adjustments, zoom controls, and coordinate transformations.

Let's explore an effective way to implement a robust and flexible camera object within your Flutter application.

First, we'll establish a clear and extensible interface for our camera object. Leveraging Flutter’s ChangeNotifier allows us to easily notify widgets when the camera's state changes, enabling smooth and efficient redraws.

Here's a suggested interface (CameraView):

/// View interface for the camera.
abstract interface class CameraView implements Listenable {
  /// Center of camera position.
  ui.Offset get position;

  /// Size of the viewport.
  ui.Size get viewportSize;

  /// Camera position in the world coordinates.
  ui.Rect get bound;

  /// Camera zoom.
  /// The zoom is between 0.1 and 1.
  double get zoomDouble;

  /// Zoom level.
  /// The zoom level is between 1 and 10.
  int get zoomLevel;

  /// Convert the global position to the local position.
  ui.Offset globalToLocal(double x, double y);

  /// Convert the global offset to the local offset.
  ui.Offset globalToLocalOffset(ui.Offset offset);

  /// Convert the global rect to the local rect.
  ui.Rect globalToLocalRect(ui.Rect rect);

  /// Convert the local position to the global position.
  ui.Offset localToGlobal(double x, double y);

  /// Convert the local offset to the global offset.
  ui.Offset localToGlobalOffset(ui.Offset offset);

  /// Convert the local rect to the global rect.
  ui.Rect localToGlobalRect(ui.Rect rect);
}

Next, implement your camera class using the provided interface. Here’s an example implementation:


/// Camera implementation.
class Camera with ChangeNotifier implements CameraView {
  /// Create a new camera with the specified parameters.
  Camera({ui.Size viewportSize = ui.Size.zero, ui.Offset position = ui.Offset.zero, int zoom = 5, ui.Rect? boundary})
    : _position = position,
      _viewportSize = viewportSize,
      _halfViewportSize = viewportSize / 2,
      _bound = ui.Rect.zero,
      _zoomLevel = zoom.clamp(1, 10),
      _zoom = zoom.clamp(1, 10) / 10,
      _boundary = boundary {
    _calculateBound();
  }

  @override
  ui.Size get viewportSize => _viewportSize;
  ui.Size _viewportSize;
  ui.Size _halfViewportSize;

  @override
  ui.Offset get position => _position;
  ui.Offset _position;

  @override
  ui.Rect get bound => _bound;
  ui.Rect _bound;

  @override
  double get zoomDouble => _zoom;
  double _zoom;

  @override
  int get zoomLevel => _zoomLevel;
  int _zoomLevel = 5; // 1..10

  /// Limit the camera movement to the specified boundary.
  final ui.Rect? _boundary;

  @override
  @pragma('vm:prefer-inline')
  ui.Offset globalToLocal(double x, double y) =>
      _zoom == 1
          ? ui.Offset(x - _bound.left, y - _bound.top)
          : ui.Offset((x - _bound.left) * _zoom, (y - _bound.top) * _zoom);

  @override
  @pragma('vm:prefer-inline')
  ui.Offset localToGlobal(double x, double y) =>
      _zoom == 1
          ? ui.Offset(x + _bound.left, y + _bound.top)
          : ui.Offset(x / _zoom + _bound.left, y / _zoom + _bound.top);

  @override
  @pragma('vm:prefer-inline')
  ui.Offset globalToLocalOffset(ui.Offset offset) =>
      ui.Offset((offset.dx - _bound.left) * _zoom, (offset.dy - _bound.top) * _zoom);

  @override
  @pragma('vm:prefer-inline')
  ui.Offset localToGlobalOffset(ui.Offset offset) =>
      ui.Offset(offset.dx / _zoom + _bound.left, offset.dy / _zoom + _bound.top);

  @override
  @pragma('vm:prefer-inline')
  ui.Rect globalToLocalRect(ui.Rect rect) => ui.Rect.fromLTRB(
    (rect.left - _bound.left) * _zoom,
    (rect.top - _bound.top) * _zoom,
    (rect.right - _bound.left) * _zoom,
    (rect.bottom - _bound.top) * _zoom,
  );

  @override
  @pragma('vm:prefer-inline')
  ui.Rect localToGlobalRect(ui.Rect rect) => ui.Rect.fromLTRB(
    rect.left / _zoom + _bound.left,
    rect.top / _zoom + _bound.top,
    rect.right / _zoom + _bound.left,
    rect.bottom / _zoom + _bound.top,
  );

  /// Move to the specified global position.
  bool moveTo(ui.Offset position) {
    if (_position == position) return false;
    _position = position;
    _calculateBound();
    notifyListeners();
    return true;
  }

  /// Change the size of viewport.
  bool changeSize(ui.Size size) {
    if (_viewportSize == size) return false;
    _viewportSize = size;
    _halfViewportSize = size / 2;
    _calculateBound();
    notifyListeners();
    return true;
  }

  /// Change the zoom of the camera.
  /// The zoom level is between 1 and 10.
  bool changeZoom(int level) {
    final lvl = level.clamp(1, 10);
    if (_zoomLevel == lvl) return false;
    _zoomLevel = lvl;
    _zoom = lvl / 10;
    _calculateBound();
    notifyListeners();
    return true;
  }

  /// Zoom in the camera.
  void zoomIn() {
    changeZoom(_zoomLevel + 1);
  }

  /// Zoom out the camera.
  void zoomOut() {
    changeZoom(_zoomLevel - 1);
  }

  /// Reset the zoom to the default value.
  void zoomReset() {
    changeZoom(5);
  }

  @pragma('vm:prefer-inline')
  void _calculateBound() {
    if (_boundary != null) {
      if (!_boundary.contains(_position)) {
        // Clamp position to the boundary.
        _position = ui.Offset(
          _position.dx.clamp(_boundary.left, _boundary.right),
          _position.dy.clamp(_boundary.top, _boundary.bottom),
        );
      }
    }
    if (_zoomLevel == 10) {
      _bound = ui.Rect.fromLTRB(
        _position.dx - _halfViewportSize.width,
        _position.dy - _halfViewportSize.height,
        _position.dx + _halfViewportSize.width,
        _position.dy + _halfViewportSize.height,
      );
    } else {
      _bound = ui.Rect.fromLTRB(
        _position.dx - _halfViewportSize.width / _zoom,
        _position.dy - _halfViewportSize.height / _zoom,
        _position.dx + _halfViewportSize.width / _zoom,
        _position.dy + _halfViewportSize.height / _zoom,
      );
    }
  }
}

  • Represent your camera's position as the center of your viewport for intuitive and simple handling.
  • Keep the viewport size synchronized with your canvas size for seamless transformations.
  • Global-to-local: essential for rendering global objects onto the canvas viewport.
  • Local-to-global: essential for user interaction (e.g., taps or drags) on the viewport, translating back into the scene.

Integrate your camera into your painting logic like so:

@override
void paint(ui.Canvas canvas, ui.Size size) {
  camera.changeSize(size);

  canvas.save();
  // Clip canvas to viewport.
  canvas.clipRect(Offset.zero & size);

  // Draw global objects transformed to viewport.
  final globalRect = ui.Rect.fromLTWH(500, 500, 100, 100);
  final localRect = camera.globalToLocalRect(globalRect);

  final paint = ui.Paint()..color = const ui.Color(0xFF00AAFF);
  canvas.drawRect(localRect, paint);

  canvas.restore();
}

Composable Painters

When your canvas rendering becomes increasingly complex—such as in games, detailed visualizations, or interactive diagrams—managing a single large painter can quickly become impractical. Instead, breaking your scene into multiple composable painter classes can provide significant benefits in maintainability, performance, and readability.

Let's dive deeper into organizing your scene efficiently, managing updates intelligently, and optimizing repaint operations.

Instead of painting everything within a single class, split responsibilities into distinct, specialized painter classes. Each painter handles its own:

  • Layout logic
  • Painting routines
  • Input (pointer/keyboard events)
  • State management

A structured example:

abstract class ScenePainter {
  bool get needsPaint;

  bool onPointerEvent(PointerEvent event);
  bool onKeyboardEvent(KeyEvent event);
  void update(ui.Size size, double delta);
  void paint(ui.Size size, PaintingContext context);
}

This modular approach helps manage complexity, allowing each component to evolve independently and simplifying debugging.

Your painters should individually handle keyboard and pointer events. Here's how to forward events efficiently:

Forward keyboard events to all painters, allowing each painter a chance to handle the event. If at least one painter handles it, return true:

@protected
bool onKeyboardEvent(KeyEvent event) {
  if (!_hasFocus) return false;
  // Forward the keyboard event to the painters for each of them to handle it.
  // Use | instead of || to call all the painters even if one of them has handled the event.
  //final handled = _painters.fold(false, (prev, painter) => prev | painter.onKeyboardEvent(event));
  final handled = _painters.any((painter) => painter.onKeyboardEvent(event));
  return handled;
}

Pointer events follow a similar logic:

@override
void onPointerEvent(PointerEvent event) {
  if (!_hasFocus) return;
  // Forward the pointer event to the painters until one of them handles it.
  // Use | instead of || to call all the painters even if one of them has handled the event.
  //final _ = _painters.fold(false, (prev, painter) => prev | painter.onPointerEvent(event));
  final _ = _painters.any((painter) => painter.onPointerEvent(event));
}

Using .any() ensures you avoid unnecessary event handling once the event is consumed.

To optimize performance, each painter should maintain internal state flags indicating when layout or repainting is required:

/// Scene layout (positions, composition) changed and needs recalculation.
bool _needsRelayout = true;

/// Visual content changed and requires repaint.
/// Trigger `markNeedsPaint()`
bool _needsPaint = true;

// Update painter only when necessary:
@override
void update(ui.Size size, double delta) {
  if (_needsRelayout) {
    _calculateLayout();
    _needsRelayout = false;
    _needsPaint = true;
  }
}

At your RenderObject at every vsync tick you can check needsPaint
before mark needs paint:

// Update painters:
painter.update(size, delta);

// Mark the scene dirty if repaint needed after update:
if (painter.needsPaint) {
  markNeedsPaint();
}

Implement boolean flags to intelligently control when recalculations and repaints occur. When updating painters, combine their repaint flags:

@override
void update(RePaintBox box, Duration elapsed, double delta) {
  final size = box.size;
  _camera.changeSize(size); // Update camera dimensions.

  for (final painter in _painters)
    painter.update(size, delta);

  // If any painter needs paint, mark the whole scene for repaint.
  _needsPaint |= _painters.any((painter) => painter.needsPaint);
}

The drawing order significantly influences your scene's visual correctness. Clearly define and follow the paint order explicitly. For instance:

@override
void paint(Size size, Canvas canvas) {
  canvas
    ..save()
    ..clipRect(Offset.zero & size);

  // Paint layers in correct order:
  _backgroundPainter.paint(size, canvas); // Background layer
  _spritesPainter.paint(size, canvas); // Icons or sprites
  _tooltipsPainter.paint(size, canvas); // Interactive tooltips
  _debugPainter.paint(size, canvas); // Debug overlay (e.g. FPS counter)
  _miniMapPainter.paint(size, canvas); // Mini-map HUD
  _cameraPainter.paint(size, canvas); // Camera transformations

  canvas.restore();
}

Clearly naming painters enhances readability and simplifies future scene modifications.

To achieve maximum performance:

  • Minimize unnecessary recalculations:
    Ensure your painters only perform expensive computations when essential.
  • Use efficient flags (_needsPaint, _needsRelayout) to control when redraws occur.
  • Group repaint operations: Recalculate only what's necessary, avoiding redundant painting cycles.
  • Modularize: Divide your scene into focused painter classes.
  • Manage state carefully: Leverage flags to control recalculations and repainting.
  • Event propagation: Efficiently forward input events and stop when handled.
  • Explicit ordering: Clearly define painter order to manage layering.

Optimizing Canvas Drawing

Efficient rendering in Flutter, especially when dealing with complex or dynamic scenes, demands careful management of drawing operations. One critical practice is to avoid loops in the painting process and instead use batched drawing methods. This approach significantly reduces CPU overhead and fully leverages GPU rendering capabilities.

When you invoke drawing methods on a Flutter Canvas (such as drawRect, drawCircle, etc.), you're not directly writing pixels to the screen buffer. Instead, you're merely issuing instructions to the GPU. These commands are then batched together and executed by the GPU in one efficient operation.

Consequently, if you repeatedly call these methods in loops, you can unnecessarily burden the CPU, hurting overall performance.

Consider the following inefficient example:

// Inefficient drawing loop:
for (final sprite in sprites) {
  canvas.drawImageRect(sprite.image, sprite.srcRect, sprite.dstRect, paint);
}

This pattern generates numerous separate GPU commands, adding substantial overhead and hindering performance.

Flutter provides specialized Canvas methods designed explicitly for batching operations:

Method Primary Use Performance Advantage
drawAtlas Efficiently draws multiple images (sprites) from a single texture. Excellent GPU batching
drawRawAtlas Draws batches of sprites or animation frames with minimal overhead. Highest GPU efficiency
drawRawPoints Renders large numbers of points (circles, squares) efficiently. Fast rendering of numerous primitives

These batched methods should be your go-to solutions for most intensive rendering tasks.

drawRawAtlas allows drawing multiple sprites from a single texture (atlas). This method is highly GPU-efficient and ideal for sprite-based games or complex visualizations.

Keep sprite atlases reasonably sized, ideally up to 1024x1024 pixels, to balance GPU memory usage and performance.

Example usage:

final visibleSkills = skills.length;
final skillsPos = Float32List(visibleSkills * 4);
final skillsSpr = Float32List(visibleSkills * 4);

// Relayout and composition
for (var i = 0; i < visibleSkills; i++) {
  final skill = skills[i];
  final sprite = skill.sprite ?? skill.tags.first.sprite;
  final rect = skill.boundary;
  final size = rect.longestSide;

  // Skill sprites
  skillsSpr
    ..[i * 4 + 0] = sprite.dx // left
    ..[i * 4 + 1] = sprite.dy // top
    ..[i * 4 + 2] = sprite.dx + sprite.size // right
    ..[i * 4 + 3] = sprite.dy + sprite.size; // bottom

  // Set the position matrix for the skill.
  final Offset(:dx, :dy) = _camera.globalToLocal(rect.left, rect.top);

  // Place the skill from the top left corner of the sprite.
  skillsPos
    ..[i * 4 + 0] = size * _camera.zoomDouble / sprite.size
    ..[i * 4 + 1] = 0
    ..[i * 4 + 2] = dx
    ..[i * 4 + 3] = dy;
}

final Paint skillsAtlasPaint =
  Paint()
    ..style = ui.PaintingStyle.fill
    ..blendMode = BlendMode.srcOver
    // Disable anti-aliasing for pixel art and performance.
    ..filterQuality = FilterQuality.none
    ..isAntiAlias = false;

// Draw the skills with the atlas by 500 elements per iteration.
// Use `visibleSkills` to iterate over the visible skills buffers.
for (var offset = 0; offset < visibleSkills; offset += 500) {
  final start = offset;
  final end = math.min(offset + 500, visibleSkills);
  final positionsView = Float32List.sublistView(skillsPos, start * 4, end * 4);
  final spritesView = Float32List.sublistView(skillsSpr, start * 4, end * 4);
  //final colorsView = Int32List.sublistView(skillsColors, start, end);
  canvas.drawRawAtlas(
    _atlas,
    positionsView,
    spritesView,
    null, // colorsView
    BlendMode.srcOver, // BlendMode.src
    null,
    skillsAtlasPaint,
  );
}
  • Significantly reduces CPU overhead.
  • Maximizes GPU utilization by batching rendering commands.

drawRawPoints efficiently batches the drawing of numerous points, such as particle effects or data visualizations.

Example usage:

// Draw circles
final points = Float32List.fromList([
  100, 100, 200, 200, 300, 300, // x1,y1, x2,y2, x3,y3...
]);

final paint = Paint()
  ..color = const Color(0xFFFF0000)
  ..isAntiAlias = true
  ..blendMode = BlendMode.srcOver
  ..style = PaintingStyle.stroke
  ..strokeCap = StrokeCap.round
  ..strokeJoin = StrokeJoin.round
  ..strokeWidth = 6;

canvas.drawRawPoints(PointMode.points, points, paint);

  • You can specify shape style (PointMode.points, PointMode.lines, or PointMode.polygon) based on your needs.
  • Circles or squares rendered by this method incur minimal overhead.

Remember, Flutter canvas methods translate directly into GPU instructions. Optimizing your canvas code means leveraging GPU batching wherever possible:

  • Do: batch operations using drawAtlas, drawRawAtlas, drawRawPoints.
  • Avoid: loops calling methods like drawRect, drawCircle, drawImageRect repeatedly for each object.

Optimizing Flutter canvas performance isn't limited to avoiding loops and batching draw calls—leveraging GPU optimizations and carefully managing your Paint objects can produce substantial gains. Let's explore how to achieve this effectively through GPU acceleration (using shaders) and smart paint object reuse.

Custom GPU shaders, written in GLSL and executed directly on the GPU, offer unparalleled rendering efficiency. A well-crafted shader significantly accelerates complex graphical effects, animations, or pixel-perfect renderings compared to CPU-driven methods.

Efficiently configuring and reusing Paint objects is another critical strategy for canvas performance. Creating many temporary Paint objects leads to increased memory allocation and garbage collection overhead, reducing performance.

Recommended practice: Create and reuse shared paint objects configured for specific visual cases.

E.g. Pixel Art (without anti-aliasing):

final pixelArtPaint = Paint()
  ..isAntiAlias = false
  ..filterQuality = FilterQuality.none
  ..style = PaintingStyle.fill;
  • Prevent unwanted blur, maintaining crisp pixels.

E.g. Smooth Curves and Lines (anti-aliasing enabled):

final smoothLinePaint = Paint()
  ..isAntiAlias = true
  ..strokeWidth = 2.0
  ..style = PaintingStyle.stroke;
  • Removes visual aliasing ("jaggies") on lines and curves.

E.g. Rounded points:

final pointPaint = Paint()
  ..color = Colors.red
  ..strokeWidth = 8
  ..strokeCap = StrokeCap.round; // Rounded points

canvas.drawPoints(PointMode.points, points, pointPaint);
  • Reuse Paint objects instead of frequently instantiating new ones.
  • Explicitly configure isAntiAlias and filterQuality per your visual needs.
  • Utilize custom shaders (vertex and fragment) to offload expensive visual computations to GPU.
  • Batch drawing commands with optimized methods (drawRawAtlas, drawRawPoints) to minimize overhead.

Efficient Spatial Management

When working with interactive scenes on a Flutter canvas, you’ll eventually face the need to efficiently calculate object positions, perform hit-testing, collision detection, and determine what objects are currently visible within your camera viewport. While a simple linear iteration through objects might suffice for small scenes (under 100 elements), more complex scenes require optimized spatial data structures.

A widely-used and effective structure for this is a QuadTree.

QuadTree class - repaint library - Dart API
API docs for the QuadTree class from the repaint library, for the Dart programming language.

A QuadTree 🌳 is a tree data structure that recursively divides a two-dimensional space into four quadrants. It enables rapid querying of objects within spatial boundaries, drastically reducing search complexity and boosting performance in interactive applications.

Advantages of QuadTrees:

  • Fast spatial queries: Efficiently find all objects within a region.
  • Efficient collision detection: Quickly identify potential collisions.
  • Scalability: Performs well even with thousands of objects.

Here’s a simplified, effective implementation of managing objects using a QuadTree in Flutter:

// Define the spatial boundary (world size) of your QuadTree.
final QuadTree qt = QuadTree(
  boundary: Rect.fromLTWH(0, 0, worldWidth, worldHeight),
  depth: 5,      // Max depth of tree
  capacity: 24,  // Max objects per node before subdividing
);

/// Mapping from QuadTree id to the actual object.
/// Maintain a flat list to map QuadTree indices to actual objects.
List<Object?> _qt2object = List<Object?>.filled(64, null, growable: false);

/// Inserts an object into the QuadTree.
int put(HitBox obj) {
  final id = qt.insert(obj.rect);
  if (_qt2object.length <= id) {
    final prev = _qt2object;
    _qt2object = List<Object?>.filled(
      math.max(64, _qt2object.length << 1),
      null,
      growable: false,
    )..setAll(0, prev);
  }
  _qt2object[id] = obj;
  return id;
}

/// Query objects within a given rectangular area.
Iterable<HitBox> query(Rect rect) =>
  qt.queryIds(rect).map((id) => _qt2object[id]).whereType<HitBox>();

When performing collision or hittest checks, always start with a "cheap" check before expensive computations:

  • First, compare bounding rectangles (bounding boxes).
  • Only if bounding rectangles intersect, perform more precise collision tests.
bool checkCollision(HitBox obj1, HitBox obj2) {
  // Cheap bounding box check
  if (!obj1.rect.overlaps(obj2.rect)) return false;
  // Perform detailed collision test only if necessary
  return detailedCollisionCheck(obj1, obj2);
}

To efficiently query visible objects within your camera viewport, inflate the viewport rect slightly to include objects that are partially visible or near edges:

final cameraBoundsInflated = camera.bound.inflate(32);
final visibleObjects = quadTree.query(cameraBoundsInflated);

This prevents visual pop-in when objects enter your viewport boundary.

Avoid running spatial queries on every single paint or update call, especially if the scene is mostly static. Instead, leverage Flutter’s reactive patterns (ChangeNotifier) to trigger recalculations only when necessary.

Subscribe to camera and QuadTree changes:

void init() {
  camera.addListener(_onChange);
  quadTree.addListener(_onChange);
}

/// Mark painters for relayout upon changes.
void _onChange() {
  _skillsPainter.changed();
  _selectorPainter.changed();
  _miniMapPainter.changed();
}

class SkillsPainter {
  bool _needsRelayout = true;
  bool _needsPaint = true;
  bool get needsPaint => _needsPaint;

  void changed() => _needsRelayout = true;

  void update(Size size, double delta) {
    if (_needsRelayout) _relayout();
  }

  void _relayout() {
    _needsRelayout = false;
    final objects = quadTree.query(camera.bound.inflate(64));
    final skills = objects.whereType<RoadmapSkill>().toList(growable: false);
    
    // Perform layout logic...
    _needsPaint = true; // Mark for repaint if changes occurred
  }
}

Ensure your scene follows a clear lifecycle:

  1. Change Detection: Mark components as "dirty" (_needsRelayout) on relevant events.
  2. Update Phase: Recalculate layout and states only if "dirty".
  3. Paint Phase: Redraw visuals only if necessary (_needsPaint).
@override
bool get needsPaint => _needsPaint |= painters.any((painter) => painter.needsPaint);

This greatly reduces redundant computations, improving overall performance.

Using Picture Caching

When developing complex Flutter canvas scenes, certain parts may rarely or never change after initial rendering. Repainting these static or infrequently updated areas on every frame is inefficient. Instead, you can significantly optimize performance by caching these areas using Flutter’s Picture class.

Picture class - dart:ui library - Dart API
API docs for the Picture class from the dart:ui library, for the Dart programming language.

Let's explore how to effectively implement this powerful optimization.

Each time Flutter’s canvas redraws a frame, it executes drawing instructions that can be computationally expensive. If portions of your scene remain static across multiple frames, you can dramatically boost performance by caching these instructions in a reusable form:

  • Picture: Caches GPU drawing instructions.
  • Image: Renders a cached raster image texture.

Using these techniques minimizes redundant computations and GPU calls.

Flutter’s built-in PictureRecorder class records your canvas drawing commands, allowing you to cache instructions for later reuse.

PictureRecorder class - dart:ui library - Dart API
API docs for the PictureRecorder class from the dart:ui library, for the Dart programming language.

Here's how you can create a cached Picture:

final recorder = PictureRecorder();
final rect = Offset.zero & Size(w, h);
final canvas = Canvas(recorder, rect);

canvas
  ..save()
  ..clipPath(Path()..addOval(rect))
  ..drawCircle(center, radius, paint);

// Add your expensive drawing calls here

canvas.restore();

final picture = recorder.endRecording();
  • Subsequent paints reuse the recorded instructions, skipping recalculation.
  • Highly beneficial for complex shapes, static backgrounds, or minimaps.

Sometimes you need a static raster image (texture). Convert your Picture to an image texture with:

// Async
image = await picture.toImage(width, height);
// Sync (blocking event loop)
image = picture.toImageSync(width, height);

This image is GPU-friendly and reusable across frames.

Example Implementation:

// Record drawing commands once
Future<void> generateMiniMap() async {
  final recorder = PictureRecorder();
  final rect = Offset.zero & _maxSize;
  final canvas = Canvas(recorder, rect);

  canvas
    ..save()
    ..clipPath(Path()
      ..addOval(rect))
    ..drawCircle(rect.center, _maxSize.width / 2, paint);
  
  // Your complex mini-map drawing logic...
  
  canvas.restore();

  final picture = recorder.endRecording();

  // Convert to raster image for further efficiency
  _miniMapTexture = await picture.toImage(
    _maxSize.width.toInt(),
    _maxSize.height.toInt(),
  );

  _needsPaint = true;
}
void paint(Size size, Canvas canvas) {
    // Draw the mini map from the texture.
    if (_miniMapTexture case Image texture)
      canvas.drawImageRect(
        texture, // Mini map texture.
        Offset.zero & _maxSize, // Source rect.
        _miniMapRect, // Destination rect.
        _miniMapPaint, // Paint.
      );
}

Approach Best Use Cases Performance
Picture Rarely-changing complex shapes or drawings Fast, cached GPU instructions
Image (Texture) Static raster graphics, sprites, mini-maps Extremely fast, cached GPU texture

You can use both for even greater efficiency:

  • Cache complex drawing commands with Picture.
  • Rasterize to an Image if the scene remains static.

Only regenerate your cached content when absolutely necessary (size changes, scene updates), and use a throttler or debouncer.
This prevents unnecessary recalculations and leverages the cached instructions to their fullest potential.

Robust Debugging and Visualization Layer

It's crucial to have an effective debugging strategy when building complex, high-performance canvas applications. Creating a toggleable debug overlay provides valuable insights into your rendering pipeline, application state, and performance metrics.

Let's explore building an efficient debugging layer activated by a hotkey (e.g., F2) that reveals critical information at runtime.

Here's a comprehensive breakdown of recommended debug visualizations:

Feature Description Benefit
🗺️ QuadTree Visualization Render QuadTree nodes and quadrants visually. Validate spatial partitioning accuracy
📐 Grid Overlay Show alignment grid lines for precise object placement. Simplifies debugging layout
📷 Camera Overlay Display camera viewport, zoom level, center, and bounds. Debug camera-related issues
🖥️ FPS Display Real-time frames-per-second and paint count metrics. Track rendering performance
🎨 Paint Spinner Spinner indicating each repaint or relayout (similar to Flutter’s DevTools rainbow border). Visual indication of repaint events
📈 Flutter Perf Overlay Built-in Flutter performance overlay (PerformanceOverlay). Analyze raster and GPU workloads
🌐 Network Stats Display network ping, data rates, and payload sizes. Diagnose latency and throughput issues
🛢️ Database Stats Current DB states, record counts, or query performance. Troubleshoot DB bottlenecks
🛠️ Dart Developer Info Dart runtime metrics (dart:developer). Memory profiling, isolates monitoring

For example, how to add Performance Overlay to your render object:

void _paintPerformanceOverlay(Size size, PaintingContext context) {
  if (_DebugMode.performanceOverlay.disabled) return;
  const maxWidth = 380.0;
  final width = math.min(size.width - 16, maxWidth);
  if (width < 160) return;
  final overlayRect = ui.Rect.fromLTWH(
    size.width - width - 8,
    32,
    width,
    math.min(size.height - 32 - 8, 64 * 2 + 40),
  );
  context.addLayer(
    PerformanceOverlayLayer(
      overlayRect: overlayRect,
      optionsMask: PerformanceOverlayOption.values.fold(0, (mask, option) => mask | (1 << option.index)),
    ),
  );
}
Share
Comments
More from Plague Fox

Plague Fox

Engineer by day, fox by night. Talks about Flutter & Dart.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Plague Fox.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.